diff options
author | Daniel Schadt <kingdread@gmx.de> | 2022-06-30 22:31:37 +0200 |
---|---|---|
committer | Daniel Schadt <kingdread@gmx.de> | 2022-06-30 22:31:37 +0200 |
commit | 4482a367f1d2266fb1649397e1524fc8ef501467 (patch) | |
tree | 421fbd966d3feab090df8cefd0e83fc2030f1148 | |
parent | ff6ed73363da196309cdb021aa3daca676dc2a8f (diff) | |
download | fietsboek-4482a367f1d2266fb1649397e1524fc8ef501467.tar.gz fietsboek-4482a367f1d2266fb1649397e1524fc8ef501467.tar.bz2 fietsboek-4482a367f1d2266fb1649397e1524fc8ef501467.zip |
fix lints
Sooner or later, I'd like to have pylint running on the code in the CI.
It's better to fix errors sooner, than to be greeted with hundreds of
pylint issues once it will be turned on later.
-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: |