aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--fietsboek/__init__.py5
-rw-r--r--fietsboek/models/__init__.py7
-rw-r--r--fietsboek/models/badge.py15
-rw-r--r--fietsboek/models/meta.py1
-rw-r--r--fietsboek/models/track.py132
-rw-r--r--fietsboek/models/user.py81
-rw-r--r--fietsboek/pshell.py6
-rw-r--r--fietsboek/routes.py2
-rw-r--r--fietsboek/scripts/initialize_db.py11
-rw-r--r--fietsboek/security.py14
-rw-r--r--fietsboek/summaries.py70
-rw-r--r--fietsboek/util.py25
-rw-r--r--fietsboek/views/admin.py47
-rw-r--r--fietsboek/views/default.py33
-rw-r--r--fietsboek/views/detail.py33
-rw-r--r--fietsboek/views/edit.py29
-rw-r--r--fietsboek/views/notfound.py8
-rw-r--r--fietsboek/views/profile.py40
-rw-r--r--fietsboek/views/upload.py72
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: