diff options
-rw-r--r-- | fietsboek/__init__.py | 5 | ||||
-rw-r--r-- | fietsboek/models/__init__.py | 7 | ||||
-rw-r--r-- | fietsboek/models/badge.py | 15 | ||||
-rw-r--r-- | fietsboek/models/meta.py | 1 | ||||
-rw-r--r-- | fietsboek/models/track.py | 132 | ||||
-rw-r--r-- | fietsboek/models/user.py | 81 | ||||
-rw-r--r-- | fietsboek/pshell.py | 6 | ||||
-rw-r--r-- | fietsboek/routes.py | 2 | ||||
-rw-r--r-- | fietsboek/scripts/initialize_db.py | 11 | ||||
-rw-r--r-- | fietsboek/security.py | 14 | ||||
-rw-r--r-- | fietsboek/summaries.py | 70 | ||||
-rw-r--r-- | fietsboek/util.py | 25 | ||||
-rw-r--r-- | fietsboek/views/admin.py | 47 | ||||
-rw-r--r-- | fietsboek/views/default.py | 33 | ||||
-rw-r--r-- | fietsboek/views/detail.py | 33 | ||||
-rw-r--r-- | fietsboek/views/edit.py | 29 | ||||
-rw-r--r-- | fietsboek/views/notfound.py | 8 | ||||
-rw-r--r-- | fietsboek/views/profile.py | 40 | ||||
-rw-r--r-- | fietsboek/views/upload.py | 72 |
19 files changed, 571 insertions, 60 deletions
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py index caf0aed..1688dde 100644 --- a/fietsboek/__init__.py +++ b/fietsboek/__init__.py @@ -1,3 +1,7 @@ +"""Fietsboek is a web application to track and share GPX tours. + +For more information, see the README or the included documentation. +""" from pyramid.config import Configurator from pyramid.session import SignedCookieSessionFactory @@ -7,6 +11,7 @@ from .security import SecurityPolicy def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ + # pylint: disable=unused-argument if 'session_key' not in settings: raise ValueError("Please set a session signing key (session_key) in your settings!") diff --git a/fietsboek/models/__init__.py b/fietsboek/models/__init__.py index 151593e..f77433e 100644 --- a/fietsboek/models/__init__.py +++ b/fietsboek/models/__init__.py @@ -1,3 +1,8 @@ +"""Main module for the fietsboek models. + +Note that all SQLAlchemy models are re-imported here. You should only need to +access the submodules if you need some of the auxiliary definitions. +""" from sqlalchemy import engine_from_config from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import configure_mappers @@ -15,10 +20,12 @@ configure_mappers() def get_engine(settings, prefix='sqlalchemy.'): + """Create an SQL Engine from the given settings.""" return engine_from_config(settings, prefix) def get_session_factory(engine): + """Create a session factory for the given engine.""" factory = sessionmaker(future=True) factory.configure(bind=engine) return factory diff --git a/fietsboek/models/badge.py b/fietsboek/models/badge.py index 42094d9..37fa140 100644 --- a/fietsboek/models/badge.py +++ b/fietsboek/models/badge.py @@ -1,3 +1,4 @@ +"""The Badge model.""" from sqlalchemy import ( Column, Integer, @@ -10,6 +11,20 @@ from .meta import Base class Badge(Base): + """Represents a badge. + + Badges have a title and an image and can be defined by the admin. + + :ivar id: Database ID. + :vartype id: int + :ivar title: Title of the badge. + :vartype title: str + :ivar image: Image of the badge. + :vartype image: bytes + :ivar tracks: Tracks associated with this badge. + :vartype tracks: list[fietsboek.models.track.Track] + """ + # pylint: disable=too-few-public-methods __tablename__ = 'badges' id = Column(Integer, primary_key=True) title = Column(Text) diff --git a/fietsboek/models/meta.py b/fietsboek/models/meta.py index d659c78..87c82b2 100644 --- a/fietsboek/models/meta.py +++ b/fietsboek/models/meta.py @@ -1,3 +1,4 @@ +"""Base metadata definition for the SQLAlchemy models.""" from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.schema import MetaData diff --git a/fietsboek/models/track.py b/fietsboek/models/track.py index 66bf12e..5089a7a 100644 --- a/fietsboek/models/track.py +++ b/fietsboek/models/track.py @@ -1,3 +1,20 @@ +"""GPX Track model definitions. + +A freshly uploaded track is stored as a :class:`Upload`, until the user +finishes putting in the extra information. This is done so that we can parse +the GPX file on the server-side and pre-fill some of the fields (such as the +date) with GPX metadata. + +Once the user has finished the upload, the track is saved as a :class:`Track`. +Each track can have an associated cache that caches the computed values. This +keeps the user's metadata and the computed information separate, and allows for +example all cached data to be re-computed without interfering with the other +meta information. +""" +import enum +import gzip +import datetime + from sqlalchemy import ( Column, Integer, @@ -8,17 +25,12 @@ from sqlalchemy import ( Float, Enum, Table, - Boolean, ) from sqlalchemy.orm import relationship from sqlalchemy.types import TypeDecorator from pyramid.i18n import TranslationString as _ -import enum -import gzip -import datetime - from markupsafe import Markup from babel.numbers import format_decimal @@ -28,7 +40,9 @@ from .. import util class TagBag(TypeDecorator): """A custom type that represents a set of tags.""" + # pylint: disable=abstract-method impl = Text + python_type = set def process_bind_param(self, value, dialect): tags = list(set(v.lower() for v in value)) @@ -41,6 +55,7 @@ class TagBag(TypeDecorator): class Visibility(enum.Enum): + """An enum that represents the visibility of tracks.""" PRIVATE = enum.auto() FRIENDS = enum.auto() PUBLIC = enum.auto() @@ -63,6 +78,40 @@ track_badge_assoc = Table( class Track(Base): + """A :class:`Track` represents a single GPX track. + + The :class:`Track` object only contains the attributes that we need to + store. Attributes and metadata that is taken from the GPX file itself is + not stored here. Instead, a :class:`Track` has an associated + :class:`TrackCache` object, where the computed information can be stored. + + :ivar id: Database ID. + :vartype id: int + :ivar owner_id: ID of the track owner. + :vartype owner_id: int + :ivar title: Title of the track. + :vartype title: str + :ivar description: Textual description of the track. + :vartype description: str + :ivar date: Set date of the track. + :vartype date: datetime.datetime + :ivar gpx: Compressed GPX data. + :vartype gpx: bytes + :ivar visibility: Visibility of the track. + :vartype visibility: Visibility + :ivar tags: Tags of the track. + :vartype tags: set + :ivar link_secret: The secret string for the share link. + :vartype link_secret: str + :ivar owner: Owner of the track. + :vartype owner: fietsboek.models.user.User + :ivar cache: Cache for the computed track metadata. + :vartype cache: TrackCache + :ivar tagged_people: List of people tagged in this track. + :vartype tagged_people: list[fietsboek.models.user.User] + :ivar badges: Badges associated with this track. + :vartype badges: list[fietsboek.models.badge.Badge] + """ __tablename__ = 'tracks' id = Column(Integer, primary_key=True) owner_id = Column(Integer, ForeignKey('users.id')) @@ -76,7 +125,8 @@ class Track(Base): owner = relationship('User', back_populates='tracks') cache = relationship('TrackCache', back_populates='track', uselist=False) - tagged_people = relationship('User', secondary=track_people_assoc, back_populates='tagged_tracks') + tagged_people = relationship('User', secondary=track_people_assoc, + back_populates='tagged_tracks') badges = relationship('Badge', secondary=track_badge_assoc, back_populates='tracks') # GPX files are XML files with a lot of repeated property names. Really, it @@ -87,6 +137,11 @@ class Track(Base): # 792K 20210902_111541.gpx.gz @property def gpx_data(self): + """The actual GPX data. + + Since storing a lot of GPS points in a XML file is inefficient, we + apply transparent compression to reduce the stored size. + """ return gzip.decompress(self.gpx) @gpx_data.setter @@ -110,10 +165,11 @@ class Track(Base): # Alternatively, if the track is set to friends visibility and the # logged in user is a friend. if self.visibility == Visibility.FRIENDS: - return request.identity in self.owner.get_friends() + return user in self.owner.get_friends() return False def ensure_cache(self): + """Ensure that a cached version of this track's metadata exists.""" if self.cache is not None: return self.cache = TrackCache(track=self) @@ -245,24 +301,60 @@ class Track(Base): :return: The generated HTML. :rtype: Markup """ - number = lambda n: format_decimal(n, locale=localizer.locale_name) + def number(num): + return format_decimal(num, locale=localizer.locale_name) + rows = [ (_("tooltip.table.length"), f'{number(round(self.length / 1000, 2))} km'), (_("tooltip.table.uphill"), f'{number(round(self.uphill, 2))} m'), (_("tooltip.table.downhill"), f'{number(round(self.downhill, 2))} m'), (_("tooltip.table.moving_time"), f'{self.moving_time}'), (_("tooltip.table.stopped_time"), f'{self.stopped_time}'), - (_("tooltip.table.max_speed"), f'{number(round(util.mps_to_kph(self.max_speed), 2))} km/h'), - (_("tooltip.table.avg_speed"), f'{number(round(util.mps_to_kph(self.avg_speed), 2))} km/h'), + (_("tooltip.table.max_speed"), + f'{number(round(util.mps_to_kph(self.max_speed), 2))} km/h'), + (_("tooltip.table.avg_speed"), + f'{number(round(util.mps_to_kph(self.avg_speed), 2))} km/h'), ] rows = [ - "<tr><td>{}</td><td>{}</td></tr>".format(localizer.translate(name), value) + f"<tr><td>{localizer.translate(name)}</td><td>{value}</td></tr>" for (name, value) in rows ] return Markup(f'<table>{"".join(rows)}</table>') class TrackCache(Base): + """Cache for computed track metadata. + + In order to repeatedly compute the track metadata from GPX files, this + information (such as length, uphill, downhill, ...) is stored in a + :class:`TrackCache`. + + :ivar id: Database ID. + :vartype id: int + :ivar track_id: ID of the track this cache belongs to. + :vartype track_id: int + :ivar length: Length of the track, in meters. + :vartype length: float + :ivar uphill: Uphill amount, in meters. + :vartype uphill: float + :ivar downhill: Downhill amount, in meters. + :vartype downhill: float + :ivar moving_time: Time spent moving, in seconds. + :vartype moving_time: float + :ivar stopped_time: Time stopped, in seconds. + :vartype stopped_time: float + :ivar max_speed: Maximum speed, in meters/second. + :vartype max_speed: float + :ivar avg_speed: Average speed, in meters/second. + :vartype avg_speed: float + :ivar start_time: Start time of the GPX recording. + :vartype start_time: datetime.datetime + :ivar end_time: End time of the GPX recording. + :vartype end_time: datetime.datetime + :ivar track: The track that belongs to this cache entry. + :vartype track: Track + """ + # pylint: disable=too-many-instance-attributes,too-few-public-methods __tablename__ = 'track_cache' id = Column(Integer, primary_key=True) track_id = Column(Integer, ForeignKey('tracks.id'), unique=True) @@ -280,6 +372,23 @@ class TrackCache(Base): class Upload(Base): + """A track that is currently being uploaded. + + Once a upload is done, the :class:`Upload` item is removed and a proper + :class:`Track` is created instead. + + :ivar id: Database ID. + :vartype id: int + :ivar uploaded_at: Date of the upload. + :vartype uploaded_at: datetime.datetime + :ivar owner_id: ID of the uploader. + :vartype owner_id: int + :ivar gpx: Compressed GPX data. + :vartype gpx: bytes + :ivar owner: Uploader of this track. + :vartype owner: fietsboek.model.user.User + """ + # pylint: disable=too-many-instance-attributes,too-few-public-methods __tablename__ = 'uploads' id = Column(Integer, primary_key=True) uploaded_at = Column(DateTime) @@ -290,6 +399,7 @@ class Upload(Base): @property def gpx_data(self): + """Access the decompressed gpx data.""" return gzip.decompress(self.gpx) @gpx_data.setter diff --git a/fietsboek/models/user.py b/fietsboek/models/user.py index b8036a0..72e64c2 100644 --- a/fietsboek/models/user.py +++ b/fietsboek/models/user.py @@ -1,3 +1,6 @@ +"""User models for fietsboek.""" +import os + from sqlalchemy import ( Column, Index, @@ -15,13 +18,11 @@ from sqlalchemy.orm.session import object_session from sqlalchemy.orm.attributes import flag_dirty from sqlalchemy import select, union, delete, func -from .meta import Base - -import os - from cryptography.hazmat.primitives.kdf.scrypt import Scrypt from cryptography.exceptions import InvalidKey +from .meta import Base + class PasswordMismatch(Exception): """Exception that is raised if the passwords mismatch. @@ -51,6 +52,29 @@ friends_assoc = Table( class User(Base): + """A fietsboek user. + + :ivar id: Database ID. + :vartype id: int + :ivar name: Name of the user. + :vartype name: str + :ivar password: (Hashed) Password of the user. Note that you should use + :meth:`set_password` and :meth:`check_password` to interface with the + password instead of accessing this field directly. + :vartype password: bytes + :ivar salt: Password salt. + :vartype salt: bytes + :ivar email: Email address of the user. + :vartype email: str + :ivar is_admin: Flag determining whether this user has admin access. + :vartype is_admin: bool + :ivar tracks: Tracks owned by this user. + :vartype tracks: list[fietsboek.models.track.Track] + :ivar tagged_tracks: Tracks in which this user is tagged. + :vartype tracks: list[fietsboek.models.track.Track] + :ivar uploads: Currently ongoing uploads by this user. + :vartype uploads: list[fietsboek.models.track.Upload] + """ __tablename__ = 'users' id = Column(Integer, primary_key=True) name = Column(Text) @@ -60,7 +84,8 @@ class User(Base): is_admin = Column(Boolean) tracks = relationship('Track', back_populates='owner') - tagged_tracks = relationship('Track', secondary='track_people_assoc', back_populates='tagged_people') + tagged_tracks = relationship('Track', secondary='track_people_assoc', + back_populates='tagged_people') uploads = relationship('Upload', back_populates='owner') @classmethod @@ -95,6 +120,10 @@ class User(Base): def check_password(self, password): """Checks if the given password fits for the user. + Does nothing if the passwords match, raises an exception otherwise. + + :raises PasswordMismatch: When the password does not match the stored + one. :param password: The password to check. :type password: str """ @@ -107,6 +136,13 @@ class User(Base): @property def all_tracks(self): + """Yields all tracks in which the user participated. + + This includes the user's own tracks, as well as any tracks the user has + been tagged in. + + :rtype: list[fietsboek.models.track.Track] + """ yield from self.tracks yield from self.tagged_tracks @@ -119,9 +155,13 @@ class User(Base): :return: All friends of this user. :rtype: list[User] """ - q1 = select(User).filter(friends_assoc.c.user_1_id == self.id, friends_assoc.c.user_2_id == User.id) - q2 = select(User).filter(friends_assoc.c.user_2_id == self.id, friends_assoc.c.user_1_id == User.id) - query = select(User).from_statement(union(q1, q2)) + qry1 = (select(User) + .filter(friends_assoc.c.user_1_id == self.id, + friends_assoc.c.user_2_id == User.id)) + qry2 = (select(User) + .filter(friends_assoc.c.user_2_id == self.id, + friends_assoc.c.user_1_id == User.id)) + query = select(User).from_statement(union(qry1, qry2)) yield from object_session(self).execute(query).scalars() def remove_friend(self, friend): @@ -142,7 +182,8 @@ class User(Base): :type friend: User """ if self.id > friend.id: - return friend.add_friend(self) + friend.add_friend(self) + return stmt = friends_assoc.insert().values(user_1_id=self.id, user_2_id=friend.id) object_session(self).execute(stmt) @@ -152,13 +193,31 @@ Index('idx_users_email', User.email, unique=True) class FriendRequest(Base): + """Represents a request of friendship between two :class:`User`\\s. + + :ivar id: Database ID. + :vartype id: int + :ivar sender_id: ID of the friendship initiator. + :vartype sender_id: int + :ivar recipient_id: ID of the request recipient. + :vartype recipient_id: int + :ivar date: Date of the request. + :vartype date: datetime.datetime + :ivar sender: Initiator of the friendship request. + :vartype sender: User + :ivar recipient: Recipient of the friendship. + :vartype recipient: User + """ + # pylint: disable=too-few-public-methods __tablename__ = 'friend_requests' id = Column(Integer, primary_key=True) sender_id = Column(Integer, ForeignKey('users.id')) recipient_id = Column(Integer, ForeignKey('users.id')) date = Column(DateTime) - sender = relationship('User', primaryjoin='User.id == FriendRequest.sender_id', backref='outgoing_requests') - recipient = relationship('User', primaryjoin='User.id == FriendRequest.recipient_id', backref='incoming_requests') + sender = relationship('User', primaryjoin='User.id == FriendRequest.sender_id', + backref='outgoing_requests') + recipient = relationship('User', primaryjoin='User.id == FriendRequest.recipient_id', + backref='incoming_requests') __table_args__ = (UniqueConstraint('sender_id', 'recipient_id'),) diff --git a/fietsboek/pshell.py b/fietsboek/pshell.py index b0847ee..cc6988f 100644 --- a/fietsboek/pshell.py +++ b/fietsboek/pshell.py @@ -1,7 +1,13 @@ +"""Shell for interactive access to the Pyramid application.""" from . import models def setup(env): + """Sets the shell environment up. + + :param env: The environment to set up. + :type env: pyramid.scripting.AppEnvironment + """ request = env['request'] # start a transaction diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 1e3fe9b..1d7694c 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -1,4 +1,6 @@ +"""Route definitions for the main Fietsboek application.""" def includeme(config): + # pylint: disable=missing-function-docstring config.add_static_view('static', 'static', cache_max_age=3600) config.add_route('home', '/') config.add_route('login', '/login') diff --git a/fietsboek/scripts/initialize_db.py b/fietsboek/scripts/initialize_db.py index 755e308..d3de7ce 100644 --- a/fietsboek/scripts/initialize_db.py +++ b/fietsboek/scripts/initialize_db.py @@ -1,13 +1,21 @@ +"""Script to initialize the tables in the database.""" +# pylint: disable=dangerous-default-value import argparse import sys from pyramid.paster import bootstrap, setup_logging -from sqlalchemy.exc import OperationalError from .. import models def parse_args(argv): + """Parse the given args. + + :param argv: List of arguments. + :type argv: list[str] + :return: The parsed arguments. + :rtype: argparse.Namespace + """ parser = argparse.ArgumentParser() parser.add_argument( 'config_uri', @@ -17,6 +25,7 @@ def parse_args(argv): def main(argv=sys.argv): + """Main entry point.""" args = parse_args(argv) setup_logging(args.config_uri) env = bootstrap(args.config_uri) diff --git a/fietsboek/security.py b/fietsboek/security.py index 1ebf7a4..dfc21e3 100644 --- a/fietsboek/security.py +++ b/fietsboek/security.py @@ -2,20 +2,21 @@ from pyramid.security import Allowed, Denied from pyramid.authentication import SessionAuthenticationHelper -from . import models - from sqlalchemy import select +from . import models + ADMIN_PERMISSIONS = {'admin'} class SecurityPolicy: + """Implementation of the Pyramid security policy.""" def __init__(self): self.helper = SessionAuthenticationHelper() def identity(self, request): - """Returns the `User` instance representing the logged in user.""" + """See :meth:`pyramid.interfaces.ISecurityPolicy.identity`""" userid = self.helper.authenticated_userid(request) if userid is None: return None @@ -24,14 +25,15 @@ class SecurityPolicy: return request.dbsession.execute(query).scalar_one_or_none() def authenticated_userid(self, request): - """ Return a string ID for the user. """ + """See :meth:`pyramid.interfaces.ISecurityPolicy.authenticated_userid`""" identity = self.identity(request) if identity is None: return None return str(identity.id) def permits(self, request, context, permission): - """ Allow access to everything if signed in. """ + """See :meth:`pyramid.interfaces.ISecurityPolicy.permits`""" + # pylint: disable=unused-argument identity = self.identity(request) if identity is None: return Denied('User is not signed in.') @@ -42,7 +44,9 @@ class SecurityPolicy: return Denied('User is not an administrator.') def remember(self, request, userid, **kw): + """See :meth:`pyramid.interfaces.ISecurityPolicy.remember`""" return self.helper.remember(request, userid, **kw) def forget(self, request, **kw): + """See :meth:`pyramid.interfaces.ISecurityPolicy.forget`""" return self.helper.forget(request, **kw) diff --git a/fietsboek/summaries.py b/fietsboek/summaries.py index d3e2d55..efaab42 100644 --- a/fietsboek/summaries.py +++ b/fietsboek/summaries.py @@ -1,5 +1,11 @@ """Module for a yearly/monthly track summary.""" class Summary: + """A summary of a user's tracks. + + :ivar years: Mapping of year to :class:`YearSummary`. + :vartype years: dict[int, YearSummary] + """ + def __init__(self): self.years = {} @@ -9,17 +15,42 @@ class Summary: return iter(items) def all_tracks(self): + """Returns all tracks of the summary. + + :return: All tracks. + :rtype: list[fietsboek.model.track.Track] + """ return [track for year in self for month in year for track in month.all_tracks()] def add(self, track): + """Add a track to the summary. + + This automatically inserts the track into the right yearly summary. + + :param track: The track to insert. + :type track: fietsboek.model.track.Track + """ year = track.date.year self.years.setdefault(year, YearSummary(year)).add(track) @property def total_length(self): + """Returns the total length of all tracks in this summary. + + :return: The total length in meters. + :rtype: float + """ return sum(track.length for track in self.all_tracks()) class YearSummary: + """A summary over a single year. + + :ivar year: Year number. + :vartype year: int + :ivar months: Mapping of month to :class:`MonthSummary`. + :vartype months: dict[int, MonthSummary] + """ + def __init__(self, year): self.year = year self.months = {} @@ -30,18 +61,42 @@ class YearSummary: return iter(items) def all_tracks(self): + """Returns all tracks of the summary. + + :return: All tracks. + :rtype: list[fietsboek.model.track.Track] + """ return [track for month in self for track in month] def add(self, track): + """Add a track to the summary. + + This automatically inserts the track into the right monthly summary. + + :param track: The track to insert. + :type track: fietsboek.model.track.Track + """ month = track.date.month self.months.setdefault(month, MonthSummary(month)).add(track) @property def total_length(self): + """Returns the total length of all tracks in this summary. + + :return: The total length in meters. + :rtype: float + """ return sum(track.length for track in self.all_tracks()) class MonthSummary: + """A summary over a single month. + + :ivar month: Month number (1-12). + :vartype month: int + :ivar tracks: List of tracks in this month. + :vartype tracks: list[fietsboek.model.track.Track] + """ def __init__(self, month): self.month = month self.tracks = [] @@ -52,11 +107,26 @@ class MonthSummary: return iter(items) def all_tracks(self): + """Returns all tracks of the summary. + + :return: All tracks. + :rtype: list[fietsboek.model.track.Track] + """ return self.tracks[:] def add(self, track): + """Add a track to the summary. + + :param track: The track to insert. + :type track: fietsboek.model.track.Track + """ self.tracks.append(track) @property def total_length(self): + """Returns the total length of all tracks in this summary. + + :return: The total length in meters. + :rtype: float + """ return sum(track.length for track in self.all_tracks()) diff --git a/fietsboek/util.py b/fietsboek/util.py index 0748c94..bdc128d 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -1,13 +1,12 @@ """Various utility functions.""" +import random +import string + import markdown import bleach import gpxpy -import random -import string from pyramid.i18n import TranslationString as _ - -from collections import namedtuple from markupsafe import Markup @@ -113,3 +112,21 @@ def random_alphanum_string(length=20): """ candidates = string.ascii_letters + string.digits return ''.join(random.choice(candidates) for _ in range(length)) + + +def parse_badges(badges, params, prefix='badge-'): + """Parses a reply to extract which badges have been checked. + + :param badges: List of available badges. + :type badges: list[fietsboek.models.badge.Badge] + :param params: The form parameters. + :type params: webob.multidict.NestedMultiDict + :param prefix: Prefix of the checkbox names. + :type prefix: str + :return: A list of active badges. + :rtype: list[fietsboek.models.badge.Badge] + """ + return [ + badge for badge in badges + if params.get(f'{prefix}{badge.id}') + ] diff --git a/fietsboek/views/admin.py b/fietsboek/views/admin.py index 35b73be..7ad6372 100644 --- a/fietsboek/views/admin.py +++ b/fietsboek/views/admin.py @@ -1,3 +1,4 @@ +"""Admin views.""" from pyramid.view import view_config from pyramid.httpexceptions import HTTPFound from pyramid.i18n import TranslationString as _ @@ -7,8 +8,16 @@ from sqlalchemy import select from .. import models -@view_config(route_name='admin', renderer='fietsboek:templates/admin.jinja2', request_method="GET", permission="admin") +@view_config(route_name='admin', renderer='fietsboek:templates/admin.jinja2', + request_method="GET", permission="admin") def admin(request): + """Renders the main admin overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ badges = request.dbsession.execute(select(models.Badge)).scalars() return { 'badges': badges, @@ -17,6 +26,16 @@ def admin(request): @view_config(route_name='admin-badge-add', permission="admin", request_method="POST") def do_badge_add(request): + """Adds a badge. + + This is the endpoint of a form on the admin overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + image = request.params['badge-image'].file.read() title = request.params['badge-title'] @@ -29,7 +48,18 @@ def do_badge_add(request): @view_config(route_name='admin-badge-edit', permission="admin", request_method="POST") def do_badge_edit(request): - badge = request.dbsession.execute(select(models.Badge).filter_by(id=request.params["badge-edit-id"])).scalar_one() + """Modifies an already existing badge. + + This is the endpoint of a form on the admin overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + badge = request.dbsession.execute( + select(models.Badge).filter_by(id=request.params["badge-edit-id"]) + ).scalar_one() try: badge.image = request.params['badge-image'].file.read() except AttributeError: @@ -42,7 +72,18 @@ def do_badge_edit(request): @view_config(route_name='admin-badge-delete', permission="admin", request_method="POST") def do_badge_delete(request): - badge = request.dbsession.execute(select(models.Badge).filter_by(id=request.params["badge-delete-id"])).scalar_one() + """Removes a badge. + + This is the endpoint of a form on the admin overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + badge = request.dbsession.execute( + select(models.Badge).filter_by(id=request.params["badge-delete-id"]) + ).scalar_one() request.dbsession.delete(badge) request.session.flash(request.localizer.translate(_("flash.badge_deleted"))) diff --git a/fietsboek/views/default.py b/fietsboek/views/default.py index bd6228e..0170e02 100644 --- a/fietsboek/views/default.py +++ b/fietsboek/views/default.py @@ -1,19 +1,24 @@ +"""Home views.""" from pyramid.view import view_config from pyramid.httpexceptions import HTTPFound from pyramid.security import remember, forget from pyramid.i18n import TranslationString as _ -from sqlalchemy import select from sqlalchemy.exc import NoResultFound -from collections import defaultdict - from .. import models, summaries, util from ..models.user import PasswordMismatch @view_config(route_name='home', renderer='fietsboek:templates/home.jinja2') def home(request): + """Renders the home page. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ if not request.identity: return {} @@ -33,11 +38,26 @@ def home(request): @view_config(route_name='login', renderer='fietsboek:templates/login.jinja2', request_method='GET') def login(request): + """Renders the login page. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + # pylint: disable=unused-argument return {} @view_config(route_name='login', request_method='POST') def do_login(request): + """Endpoint for the login form. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = models.User.query_by_email(request.params['email']) try: user = request.dbsession.execute(query).scalar_one() @@ -53,6 +73,13 @@ def do_login(request): @view_config(route_name='logout') def logout(request): + """Logs the user out. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ request.session.flash(request.localizer.translate(_('flash.logged_out'))) headers = forget(request) return HTTPFound('/', headers=headers) diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index f2b76af..19d2a80 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -1,3 +1,4 @@ +"""Track detail views.""" from pyramid.view import view_config from pyramid.response import Response from pyramid.httpexceptions import HTTPForbidden, HTTPFound @@ -8,6 +9,13 @@ from .. import models, util @view_config(route_name='details', renderer='fietsboek:templates/details.jinja2') def details(request): + """Renders the detail page for a given track. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Track).filter_by(id=request.matchdict["id"]) track = request.dbsession.execute(query).scalar_one() if (not track.is_visible_to(request.identity) @@ -24,6 +32,13 @@ def details(request): @view_config(route_name='gpx') def gpx(request): + """Returns the actual GPX data from the stored track. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Track).filter_by(id=request.matchdict["id"]) track = request.dbsession.execute(query).scalar_one() if (not track.is_visible_to(request.identity) @@ -34,6 +49,13 @@ def gpx(request): @view_config(route_name='invalidate-share', request_method='POST') def invalidate_share(request): + """Endpoint to invalidate the share link. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Track).filter_by(id=request.matchdict["id"]) track = request.dbsession.execute(query).scalar_one() if track.owner != request.identity: @@ -44,6 +66,13 @@ def invalidate_share(request): @view_config(route_name='badge') def badge(request): + """Returns the image data associated with a badge. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Badge).filter_by(id=request.matchdict["id"]) - badge = request.dbsession.execute(query).scalar_one() - return Response(badge.image) + badge_object = request.dbsession.execute(query).scalar_one() + return Response(badge_object.image) diff --git a/fietsboek/views/edit.py b/fietsboek/views/edit.py index 9860856..141fa6f 100644 --- a/fietsboek/views/edit.py +++ b/fietsboek/views/edit.py @@ -1,16 +1,25 @@ +"""Views for editing a track.""" +import datetime + from pyramid.view import view_config from pyramid.httpexceptions import HTTPForbidden, HTTPFound from sqlalchemy import select -import datetime - -from .. import models +from .. import models, util from ..models.track import Visibility -@view_config(route_name='edit', renderer='fietsboek:templates/edit.jinja2', permission='edit', request_method='GET') +@view_config(route_name='edit', renderer='fietsboek:templates/edit.jinja2', + permission='edit', request_method='GET') def edit(request): + """Renders the edit form. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Track).filter_by(id=request.matchdict["id"]) track = request.dbsession.execute(query).scalar_one() if request.identity != track.owner: @@ -25,16 +34,20 @@ def edit(request): @view_config(route_name='edit', permission='edit', request_method='POST') def do_edit(request): + """Endpoint for saving the edited data. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Track).filter_by(id=request.matchdict["id"]) track = request.dbsession.execute(query).scalar_one() if request.identity != track.owner: return HTTPForbidden() badges = request.dbsession.execute(select(models.Badge)).scalars() - active_badges = [ - badge for badge in badges - if request.params.get(f'badge-{badge.id}') == 'marked' - ] + active_badges = util.parse_badges(badges, request.params) track.title = request.params["title"] track.visibility = Visibility[request.params["visibility"]] diff --git a/fietsboek/views/notfound.py b/fietsboek/views/notfound.py index cad18aa..f7117fe 100644 --- a/fietsboek/views/notfound.py +++ b/fietsboek/views/notfound.py @@ -1,7 +1,15 @@ +"""Error views.""" from pyramid.view import notfound_view_config @notfound_view_config(renderer='fietsboek:templates/404.jinja2') def notfound_view(request): + """Renders the 404 response. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ request.response.status = 404 return {} diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 9e24c0d..fe1ae5a 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -1,16 +1,25 @@ +"""Views corresponding to the user profile.""" +import datetime + from pyramid.view import view_config from pyramid.i18n import TranslationString as _ from pyramid.httpexceptions import HTTPFound, HTTPNotFound, HTTPForbidden from sqlalchemy import select -import datetime - from .. import models @view_config(route_name='profile', renderer='fietsboek:templates/profile.jinja2', permission='user') def profile(request): + """Provides the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + coming_requests = request.dbsession.execute( select(models.FriendRequest).filter_by(recipient_id=request.identity.id) ).scalars() @@ -26,6 +35,15 @@ def profile(request): @view_config(route_name='add-friend', permission='user', request_method='POST') def do_add_friend(request): + """Sends a friend request. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ email = request.params['friend-email'] candidate = (request.dbsession .execute(models.User.query_by_email(email)) @@ -60,6 +78,15 @@ def do_add_friend(request): @view_config(route_name='delete-friend', permission='user', request_method='POST') def do_delete_friend(request): + """Deletes a friend. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ friend = request.dbsession.execute( select(models.User).filter_by(id=request.params["friend-id"]) ).scalar_one_or_none() @@ -70,6 +97,15 @@ def do_delete_friend(request): @view_config(route_name='accept-friend', permission='user', request_method='POST') def do_accept_friend(request): + """Accepts a friend request. + + This is the endpoint of a form on the profile overview. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ friend_request = request.dbsession.execute( select(models.FriendRequest).filter_by(id=request.params["request-id"]) ).scalar_one_or_none() diff --git a/fietsboek/views/upload.py b/fietsboek/views/upload.py index ab6eeb4..d05cf81 100644 --- a/fietsboek/views/upload.py +++ b/fietsboek/views/upload.py @@ -1,3 +1,6 @@ +"""Upload functionality.""" +import datetime + from pyramid.httpexceptions import HTTPFound, HTTPForbidden from pyramid.response import Response from pyramid.view import view_config @@ -5,30 +8,52 @@ from pyramid.i18n import TranslationString as _ from sqlalchemy import select -import datetime import gpxpy from .. import models, util from ..models.track import Visibility -@view_config(route_name='upload', renderer='fietsboek:templates/upload.jinja2', request_method='GET', permission='upload') -def upload(request): +@view_config(route_name='upload', renderer='fietsboek:templates/upload.jinja2', + request_method='GET', permission='upload') +def show_upload(request): + """Renders the main upload form. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ + # pylint: disable=unused-argument return {} @view_config(route_name='upload', request_method='POST', permission='upload') def do_upload(request): + """Endpoint to store the uploaded file. + + This does not yet create a track, it simply stores the track as a + :class:`~fietsboek.models.track.Upload` and redirects the user to "finish" + the upload by providing the missing metadata. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ try: gpx = request.POST['gpx'].file.read() except AttributeError: request.session.flash(request.localizer.translate(_('flash.no_file_selected'))) return HTTPFound(request.route_url('upload')) - # Before we do anything, we check if we can parse the file + # Before we do anything, we check if we can parse the file. + # gpxpy might throw different exceptions, so we simply catch `Exception` + # here - if we can't parse it, we don't care too much why at this point. + # pylint: disable=broad-except try: gpxpy.parse(gpx) - except: + except Exception: request.session.flash(request.localizer.translate(_('flash.invalid_file'))) return HTTPFound(request.route_url('upload')) @@ -47,6 +72,14 @@ def do_upload(request): @view_config(route_name='preview', permission='upload') def preview(request): + """Allows a preview of the uploaded track by returning the GPX data of a + :class:`~fietsboek.models.track.Upload` + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Upload).filter_by(id=request.matchdict["id"]) upload = request.dbsession.execute(query).scalar_one() if upload.owner != request.identity: @@ -54,8 +87,16 @@ def preview(request): return Response(upload.gpx_data, content_type='application/gpx+xml') -@view_config(route_name='finish-upload', renderer='fietsboek:templates/finish_upload.jinja2', request_method='GET', permission='upload') +@view_config(route_name='finish-upload', renderer='fietsboek:templates/finish_upload.jinja2', + request_method='GET', permission='upload') def finish_upload(request): + """Renders the form that allows the user to finish the upload. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Upload).filter_by(id=request.matchdict["id"]) upload = request.dbsession.execute(query).scalar_one() if upload.owner != request.identity: @@ -78,16 +119,20 @@ def finish_upload(request): @view_config(route_name='finish-upload', request_method='POST', permission='upload') def do_finish_upload(request): + """Endpoint for the "finishing upload" form. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Upload).filter_by(id=request.matchdict["id"]) upload = request.dbsession.execute(query).scalar_one() if upload.owner != request.identity: return HTTPForbidden() badges = request.dbsession.execute(select(models.Badge)).scalars() - active_badges = [ - badge for badge in badges - if request.params.get(f'badge-{badge.id}') == 'marked' - ] + active_badges = util.parse_badges(badges, request.params) track = models.Track( owner=request.identity, @@ -110,6 +155,13 @@ def do_finish_upload(request): @view_config(route_name='cancel-upload', permission='upload') def cancel_upload(request): + """Cancels the upload and clears the temporary data. + + :param request: The Pyramid request. + :type request: pyramid.request.Request + :return: The HTTP response. + :rtype: pyramid.response.Response + """ query = select(models.Upload).filter_by(id=request.matchdict["id"]) upload = request.dbsession.execute(query).scalar_one() if upload.owner != request.identity: |