Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • main
  • item-closing
  • revert-6092d6d7
  • rest-api-simple
  • tokens
  • currency
  • i18n-test-fix
  • pytest-fix
  • i18n
  • smoke-test
  • coverage
  • item-page
  • profile-page
  • flask-login
  • logging
15 results

Target

Select target project
  • Startuplab / Courses / TJTS5901 Continuous Software Engineering / TJTS5901 K23 Template
  • Planet of the Apes / TJTS5901 Apeuction
  • Uunot yliopiston leivissa / TJTS5901-uunot
  • Contain the Cry / TJTS5901 Auction system
  • Avengers / Avengers
  • CSE6 / frozen
  • 13th / 13th
  • Fast and Furious / Fast and Furious
  • Back to the Future / Delorean Auction
  • Monty Pythons the Meaning of Life / The meaning of life
  • Team Atlantis / The Empire
  • code with the wind / Code with the Wind
  • the Pirates / The Pirates
  • Do the Right Thing / Do the right thing
  • Inception / Inception
  • The Social Network Syndicate / The Social Auction Network
  • Team (The Hunt for) Red October / TJTS5901-K23 Red October
  • Good on paper / Good paper project
  • Desperados / Desperados
19 results
Select Git revision
  • main
  • Oath
  • notificationfront
  • db_test
  • deleting
  • itempage
  • tests
  • item_restapi
8 results
Show changes

Commits on Source 133

33 additional commits have been omitted to prevent performance issues.
45 files
+ 4196
130
Compare changes
  • Side-by-side
  • Inline

Files

+3 −0
Original line number Diff line number Diff line
@@ -165,3 +165,6 @@ cython_debug/
_docs/
.env
.DS_Store

# Ignore certificates
azure-sp.pem
+132 −6
Original line number Diff line number Diff line
@@ -2,6 +2,8 @@
stages:
  - build
  - test
  - staging
  - smoketest
  - deploy

variables:
@@ -13,6 +15,8 @@ variables:
  ## (Optional) More verbose output from pipeline. Enabling it might reveal secrets.
  #CI_DEBUG_TRACE: "true"

include:
  - template: Jobs/SAST.gitlab-ci.yml

## Use buildkit to build the container.
## Buildkit: https://github.com/moby/buildkit
@@ -45,6 +49,15 @@ build:
          --opt build-arg:CI_COMMIT_SHA=${CI_COMMIT_SHA} \
          --output type=image,name=${DOCKER_IMAGE_NAME}:${DOCKER_TAG},push=true


sast:
    ## Static Application Security Test
    ## You can override the included template(s) by including variable overrides
    ## SAST customization:
    ##   https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings
    stage: test


## Run the tests. If any of the tests fails, pipeline is rejected.
test:
  ## Optional: include stage and environment name
@@ -59,15 +72,105 @@ test:
  services:
    - name: mongo:4.2  # update to reflect same version used on production
      alias: mongo
  script:
      - pip install --disable-pip-version-check -e /app[test]
      ## Run tests with coverage reporting
      - coverage run -m pytest
      ## Run basic reporting for badge
      - coverage report
      ## Generate detailed report for gitlab annotations.
      - coverage xml -o coverage.xml

  coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml


## Job to setup new staging job.
deploy to staging:
    stage: staging
    ## Only run this stage when main branch receives changes.
    only:
        - main

    ## Use microsoft provided azure cli image, that contains az cli.
    image: mcr.microsoft.com/azure-cli

    ## Setup the environment variables. The can be accessed through the gitlab 
    ## Deployments -> Environments. Generates url based on the app and branch name.
    environment:
        name: $CI_JOB_STAGE
        url: https://${AZURE_APP_NAME}-${CI_ENVIRONMENT_SLUG}.azurewebsites.net

    before_script:
        ## Make sanity check that gitlab variables stage is done.
        - test -z "${AZURE_SP_NAME}" && (echo "Missing required variable AZURE_SP_NAME. See 'Staging.md'"; exit 1)
        - test -f "${AZURE_SP_CERT}" || ( echo "AZURE_SP_CERT (${AZURE_SP_CERT}) file is missing!"; exit 1)

        - test -z "${AZURE_APP_NAME}" && (echo "Missing required variable AZURE_APP_NAME. See 'Staging.md'"; exit 1)
        - test -z "${AZURE_RESOURCE_GROUP}" && (echo "Missing required variable DOCKER_AUTH_CONFIG. See 'Staging.md'"; exit 1)        
        
        ## Login into azure
        - az login --service-principal -u "${AZURE_SP_NAME}" -p "${AZURE_SP_CERT}" --tenant "jyu.onmicrosoft.com"

    script:
        ## Create staging slot and copy settings from production
        - |
          az webapp deployment slot create -n "$AZURE_APP_NAME" -g "$AZURE_RESOURCE_GROUP" \
            --slot "$CI_ENVIRONMENT_SLUG" --configuration-source "$AZURE_APP_NAME"

        ## TODO: Create a snapshot of database, and use it.

        ## If you need to change settings see: https://docs.microsoft.com/en-us/cli/azure/webapp/config/appsettings

        ## Change container tag to reflect branch we're running on
        - |
          az webapp config container set -n "$AZURE_APP_NAME" -g "$AZURE_RESOURCE_GROUP" \
            --docker-custom-image-name "${DOCKER_IMAGE_NAME}:${DOCKER_TAG}" -s "$CI_ENVIRONMENT_SLUG"

        ## In case slot already existed, restart the slot
        - az webapp restart -n "$AZURE_APP_NAME" -g "$AZURE_RESOURCE_GROUP" -s "$CI_ENVIRONMENT_SLUG"

        ## Restart is not immediate, it takes a sec or two, depending on container changes.
        - sleep 20

        ## Store server info as artifact for prosperity
        - curl "$CI_ENVIRONMENT_URL/server-info" -o server-info.json

    artifacts:
        paths:
            - server-info.json


## Run smoketest to check that staging app is responding as expected.
staging smoketest:
    stage: smoketest
    image: ${DOCKER_IMAGE_NAME}:${DOCKER_TAG}
    environment:
        name: staging
        url: https://${AZURE_APP_NAME}-${CI_ENVIRONMENT_SLUG}.azurewebsites.net

    only:
        - main

    script:
        - pip install --disable-pip-version-check -e .[test]
      - pytest -v
      - echo "Test run succesfully!"
        ## Environment url can be inherited from CI_ENVIROMNENT_URL, which is one defined
        ## in the `environment.url:`. Using it here explicitly.
        - echo "Testing againsta deployment address ${CI_ENVIROMNENT_URL}"
        - pytest -v --environment-url="${CI_ENVIROMNENT_URL}" tests/test_smoke.py


## Deploy latest image to the production
deploy:
## Push latest image into registry with the `latest` tag.
docker tag latest:
  stage: deploy
  environment: production

  environment:
    name: production

  image: docker:20.10.23
  only:
    - main
@@ -76,3 +179,26 @@ deploy:
    - mkdir -p ${HOME}/.docker && echo "${DOCKER_AUTH_CONFIG}" > "${HOME}/.docker/config.json"
    ## Add the `latest` tag to the image we have build.
    - docker buildx imagetools create ${DOCKER_IMAGE_NAME}:${DOCKER_TAG} --tag ${DOCKER_IMAGE_NAME}:latest


## Swap the production and staging slots around.
staging to production:
    stage: deploy
    ## Only run this stage when main branch receives changes.
    only:
        - main

    ## Use microsoft provided azure cli image, that contains az cli.
    image: mcr.microsoft.com/azure-cli

    environment:
        name: production
        url: https://${AZURE_APP_NAME}.azurewebsites.net/

    before_script:
        ## Login into azure
        - az login --service-principal -u "${AZURE_SP_NAME}" -p "${AZURE_SP_CERT}" --tenant "jyu.onmicrosoft.com"

    script:
        ## Swap production and staging slots.
        - az webapp deployment slot swap -g "$AZURE_RESOURCE_GROUP" -n "$AZURE_APP_NAME" -s staging --target-slot production
+33 −0
Original line number Diff line number Diff line
## Summary

Briefly describe the issue and its impact on the project concisely.


## Steps to Reproduce

1. List the steps to reproduce the issue
2. Provide any relevant details such as browser, device, version, etc.


## Expected Outcome

What should happen after following the steps?


## Actual Outcome

What actually happens after following the steps?


## Additional Context

- Include any relevant screenshots, logs, or code snippets
- Indicate if the issue occurs only in certain conditions, environments, or browsers


## Possible fixes

If you are familiar with the issue and in a position to help, it would be appreciated!


/label ~bug
+10 −3
Original line number Diff line number Diff line
@@ -21,7 +21,7 @@ WORKDIR /app

## Declare default flask app as environment variable
## https://flask.palletsprojects.com/en/2.2.x/cli/
ARG FLASK_APP=tjts5901.app
ARG FLASK_APP=tjts5901.app:flask_app
ENV FLASK_APP=${FLASK_APP}

## Setup the default port for flask to listen on
@@ -31,13 +31,13 @@ ENV FLASK_RUN_PORT=${FLASK_RUN_PORT}
## Run Flask app when container started, and listen all the interfaces
## Note: CMD doesn't run command in build, but defines an starting command
## when container is started (or arguments for ENTRYPOINT).
CMD flask run --host=0.0.0.0 # --port=${FLASK_RUN_PORT} --app=${FLASK_APP}
#CMD flask run --host=0.0.0.0 # --port=${FLASK_RUN_PORT} --app=${FLASK_APP}
CMD gunicorn --bind "0.0.0.0:${FLASK_RUN_PORT}" "${FLASK_APP}"

## Examples for other commands:
## Run nothing, so that the container can be used as a base image
#CMD ["bash", "-c", "sleep infinity"]
## Run Flask app using Gunicorn, which unlike Flask, doesn't complain about being development thing.
#CMD gunicorn --bind "0.0.0.0:${PORT}"" tjts5901.app:app

## Install requirements using pip. This is done before copying the app, so that
## requirements layer is cached. This way, if app code changes, only app code is
@@ -49,6 +49,10 @@ RUN pip --disable-pip-version-check install -r /tmp/pip-tmp/requirements.txt &&
## Copy app to WORKDIR folder
COPY . .

## Compile translations before installing app. This is done to make sure that
## translations are compiled before app is installed.
RUN pybabel compile -f -d src/tjts5901/translations/

## Install self as editable (`-e`) module. In a long run it would be recommeded
## to remove `COPY` and only install app as a package.
RUN pip --disable-pip-version-check install -v -e .
@@ -58,6 +62,9 @@ RUN pip --disable-pip-version-check install -v -e .
ARG CI_COMMIT_SHA
ENV CI_COMMIT_SHA=${CI_COMMIT_SHA}

## Download the currency exchange rates from European Central Bank
RUN flask update-currency-rates

## Save build date and time
RUN echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> /app/.env

+5 −0
Original line number Diff line number Diff line
@@ -25,3 +25,8 @@ docker run -it -p 5001:5001 -e "FLASK_DEBUG=1" -v "${PWD}:/app" tjts5901
```

Please see the `docs/tjts5901` folder for more complete documentation.


## Reporting issues and bugs

To report bugs, please use [the project "issues" form](https://gitlab.jyu.fi/startuplab/courses/tjts5901-continuous-software-engineering/TJTS5901-K23_template/-/issues/new?issuable_template=Default)

babel.cfg

0 → 100644
+2 −0
Original line number Diff line number Diff line
[python: **.py]
[jinja2: **/templates/**.html]
+4 −0
Original line number Diff line number Diff line
@@ -67,6 +67,10 @@ plugins:
      fallback_to_build_date: true
  - macros:
      module_name: macros
      # Change to something else
      j2_variable_start_string: "{j2{"
      j2_variable_end_string: "}j2}"


extra:
  social:
+3 −0
Original line number Diff line number Diff line
@@ -29,3 +29,6 @@ MONGO_URL=mongodb://mongodb:27017/tjts5901
# Setup sentry environment to separate development issues from production. This variable name is
# maybe set by gitlab pipeline. <https://docs.gitlab.com/ee/ci/environments/>
CI_ENVIRONMENT_NAME=development

# Setup CI environment url to point on localhost for testing purposes.
CI_ENVIRONMENT_URL=http://localhost:5001
+15 −0
Original line number Diff line number Diff line
@@ -29,6 +29,9 @@ dependencies = {file = ["requirements.txt"]}
test = [
  "pytest",
  "coverage",
  "coverage[toml]",
  "requests",
  "faker",
]
docs = [
  "mkdocs",
@@ -45,3 +48,15 @@ build-backend = "setuptools.build_meta"
testpaths = [
    "tests",
]
filterwarnings = [
  ## Silence json encoder warnings for Flask >=2.3
  # "ignore::DeprecationWarning:mongoengine.connection",
  # "ignore::DeprecationWarning:flask_mongoengine.json",
  # "ignore::DeprecationWarning:flask.json.provider",
]

[tool.coverage.run]
branch = true
source_pkgs = [
    "tjts5901",
]
+7 −1
Original line number Diff line number Diff line
@@ -4,9 +4,15 @@ importlib-metadata
# Framework and libraries
flask==2.2.2
python-dotenv
flask-login
flask-babel

flask-mongoengine==1.0

CurrencyConverter

Flask-APScheduler

# Git hooks
pre-commit

@@ -15,4 +21,4 @@ sentry-sdk[flask]
sentry-sdk[pymongo]

# More production-ready web server
#gunicorn
gunicorn
+21 −5
Original line number Diff line number Diff line
@@ -20,8 +20,11 @@ from flask import (
    request,
)

from flask_babel import _

from .utils import get_version
from .db import init_db
from .i18n import init_babel

logger = logging.getLogger(__name__)

@@ -36,7 +39,7 @@ def create_app(config: Optional[Dict] = None) -> Flask:

    flask_app.config.from_mapping(
        SECRET_KEY='dev',
        BRAND="Hill Valley DMC dealership",
        BRAND=_("Hill Valley DMC dealership"),
    )

    # load the instance config, if it exists, when not testing
@@ -55,9 +58,16 @@ def create_app(config: Optional[Dict] = None) -> Flask:
    except OSError:
        pass

    # Initialize the Flask-Babel extension.
    init_babel(flask_app)

    # Initialize the database connection.
    init_db(flask_app)

    # Initialize the scheduler.
    from .scheduler import init_scheduler  # pylint: disable=import-outside-toplevel
    init_scheduler(flask_app)

    @flask_app.route('/debug-sentry')
    def trigger_error():
        division_by_zero = 1 / 0
@@ -65,13 +75,20 @@ def create_app(config: Optional[Dict] = None) -> Flask:
    # a simple page that says hello
    @flask_app.route('/hello')
    def hello():
        return 'Hello, World!'
        return _('Hello, World!')

    from . import auth
    flask_app.register_blueprint(auth.bp)
    from .auth import init_auth
    init_auth(flask_app)

    from .notification import init_notification
    init_notification(flask_app)

    from .currency import init_currency
    init_currency(flask_app)

    from . import items
    flask_app.register_blueprint(items.bp)
    flask_app.register_blueprint(items.api)
    flask_app.add_url_rule('/', endpoint='index')

    return flask_app
@@ -84,7 +101,6 @@ load_dotenv()
# Create the Flask application.
flask_app = create_app()


@flask_app.route("/server-info")
def server_info() -> Response:
    """
+210 −26
Original line number Diff line number Diff line
from datetime import datetime
import functools
import logging

from flask import (
    Blueprint, flash, g, redirect, render_template, request, session, url_for
    Blueprint, flash, redirect, render_template, request, session, url_for, abort
)
from flask_login import (
    LoginManager,
    login_user,
    logout_user,
    login_required,
    current_user,
)
from flask_babel import _
from babel.dates import get_timezone
from werkzeug.security import check_password_hash, generate_password_hash
from sentry_sdk import set_user

from .models import User
from .models import AccessToken, Bid, User, Item

from mongoengine import DoesNotExist
from mongoengine.queryset.visitor import Q

bp = Blueprint('auth', __name__, url_prefix='/auth')
logger = logging.getLogger(__name__)


@bp.before_app_request
def load_logged_in_user():
def init_auth(app):
    """
    If a user id is stored in the session, load the user object from
    Integrate authentication into the application.
    """
    app.register_blueprint(bp)

    user_id = session.get('user_id')
    login_manager = LoginManager()
    login_manager.login_view = 'auth.login'
    login_manager.user_loader(load_logged_in_user)

    if user_id is None:
        g.user = None
        set_user(None)
    app.config['AUTH_HEADER_NAME'] = 'Authorization'
    login_manager.request_loader(load_user_from_request)

    else:
        g.user = User.objects.get(id=user_id)
        set_user({"id": str(g.user.id), "email": g.user.email})
    login_manager.init_app(app)

    logger.debug("Initialized authentication")


def load_user_from_request(request):
    """
    Load a user from the request.

    This function is used by Flask-Login to load a user from the request.
    """
    api_key = request.headers.get("Authorization")

    if api_key:
        api_key = api_key.replace("Bearer ", "", 1)
        try:
            token = AccessToken.objects.get(token=api_key)
            if token.expires and token.expires < datetime.utcnow():
                logger.warning("Token expired: %s", api_key)
                return None
            # User is authenticated

            token.last_used_at = datetime.utcnow()
            token.save()
            logger.debug("User authenticated via token: %r", token.user.email, extra={
                "user": token.user.email,
                "user_id": str(token.user.id),
                "token": token.token,
            })
            return token.user
        except DoesNotExist:
            logger.error("Token not found: %s", api_key)

    return None


def load_logged_in_user(user_id):
    """
    Load a user from the database, given the user's id.
    """
    try:
        user = User.objects.get(id=user_id)
        set_user({"id": str(user.id), "email": user.email})
    except DoesNotExist:
        logger.error("User not found: %s", user_id)
        return None

    return user


def get_user_by_email(email: str) -> User:
    """
    Get a user from the database, given the user's email.

    If the email is 'me', then the current user is returned.

    :param email: The email of the user to get.
    """

    if email is None:
        abort(404)

def login_required(view):
    @functools.wraps(view)
    def wrapped_view(**kwargs):
        if g.user is None:
            return redirect(url_for('auth.login'))
    if email == "me" and current_user.is_authenticated:
        email = current_user.email

        return view(**kwargs)
    try:
        user = User.objects.get_or_404(email=email)
    except DoesNotExist:
        logger.error("User not found: %s", email)
        abort(404)

    return wrapped_view
    return user


@bp.route('/register', methods=('GET', 'POST'))
@@ -59,11 +132,21 @@ def register():
        elif not terms:
            error = 'You must agree to the terms.'

        timezone = None
        try:
            if user_tz := request.form.get('timezone', None):
                timezone = get_timezone(user_tz).zone

        except Exception as exc:
            logger.debug("Error getting timezone from user data: %s", exc)
            timezone = None

        if error is None:
            try:
                user = User(
                    email=email,
                    password=generate_password_hash(password)
                    password=generate_password_hash(password),
                    timezone=timezone,
                )
                user.save()
                flash("You have been registered. Please log in.")
@@ -85,6 +168,7 @@ def login():
        email = request.form['email']
        password = request.form['password']

        user = None
        error = None
        try:
            user = User.objects.get(email=email)
@@ -97,19 +181,119 @@ def login():
            error = 'Incorrect password.'

        if error is None:
            session.clear()
            session['user_id'] = str(user['id'])
            remember_me = bool(request.form.get("remember-me", False))
            if login_user(user, remember=remember_me):

                flash(f"Hello {email}, You have been logged in.")
            return redirect(url_for('items.index'))

        print("Error logging in:", error)
                next = request.args.get('next')
                # Better check that the user actually clicked on a relative link
                # or else they could redirect you to a malicious website!
                if next is None or not next.startswith('/'):
                    next = url_for('index')

                return redirect(next)
            else:
                error = "Error logging in."

        logger.info("Error logging user in: %r: Error: %s", email, error)
        flash(error)

    return render_template('auth/login.html')


@bp.route('/logout')
@login_required
def logout():
    session.clear()
    """
    Log out the current user.

    Also removes the "remember me" cookie.
    """
    logout_user()
    flash("You have been logged out.")
    return redirect(url_for('index'))


@bp.route('/profile', defaults={'email': 'me'})
@bp.route('/profile/<email>')
@login_required
def profile(email):
    """
    Show the user's profile page for the given email.

    If the email is 'me', then the current user's profile is shown.
    """

    user: User = get_user_by_email(email)

    # List the items user has created
    items = Item.objects(seller=user).all()

    # List the items user has won
    # TODO: Could be done smarter with a join
    bids = Bid.objects(bidder=user).only("id").all()
    won_items = Item.objects(winning_bid__in=bids).all()

    return render_template('auth/profile.html', user=user, items=items, won_items=won_items)


@bp.route('/profile/<email>/token', methods=('GET', 'POST'), defaults={'email': 'me'})
@login_required
def user_access_tokens(email):
    """
    Show the user's tokens page for the given email.
    """

    user: User = get_user_by_email(email)
    # Fetch all the user tokens that are active or have no expire date
    tokens = AccessToken.objects(Q(expires__gte=datetime.now()) | Q(expires=None), user=user).all()

    token = None
    if request.method == 'POST':
        try:
            name = request.form['name']

            if expires := request.form.get('expires'):
                expires = datetime.fromisoformat(expires)
            else:
                expires = None

            token = AccessToken(
                user=user,
                name=name,
                expires=expires,
            )
            token.save()
        except KeyError as exc:
            logger.debug("Missing required field: %s", exc)
            flash(_("Required field missing"))
        except Exception as exc:
            logger.exception("Error creating token: %s", exc)
            flash(_("Error creating token: %s") % exc)
        else:
            flash(_("Created token: %s") % token.name)

    return render_template('auth/tokens.html', user=user, tokens=tokens, token=token)


@bp.route('/profile/<email>/token/<id>', methods=('POST',))
def delete_user_access_token(email, id):
    """
    Delete an access token.
    """
    user = get_user_by_email(email)
    token = AccessToken.objects.get_or_404(id=id)

    if token.user != user:
        logger.warning("User %s tried to delete token %s", user.email, token.name, extra={
            "user": user.email,
            "token": str(token.id),
            "token_user": token.user.email,
        })
        abort(403)

    token.delete()

    flash(f"Deleted token {token.name}")
    return redirect(url_for('auth.user_access_tokens', email=token.user.email))
+249 −0
Original line number Diff line number Diff line
"""
Currency module.

This module contains the currency module, which is used to convert currencies.

Uses the ECB (European Central Bank) as the source of currency conversion rates.

To update the currency conversion rates, run the following command:
    $ flask update-currency-rates

"""

from decimal import Decimal
import logging
import os
from pathlib import Path
from zipfile import ZipFile
import urllib.request
import click
from currency_converter import (
    SINGLE_DAY_ECB_URL,
    CurrencyConverter,
)

from flask_babel import (
    get_locale,
    format_currency,
)

from babel.numbers import get_territory_currencies, parse_decimal

from flask import (
    Flask,
    current_app,
    render_template,
)

from markupsafe import Markup

from .auth import current_user


REF_CURRENCY = 'EUR'
"Reference currency for the currency converter."


logger = logging.getLogger(__name__)


class CurrencyProxy:
    """
    Proxy for the currency converter.

    This class is used to proxy the currency converter instance. This is to
    ensure that the currency converter is only initialized when it is actually
    used, and the used conversion list is the most up-to-date.
    """

    def __init__(self, app: Flask):
        self._converter = None
        self._app = app
        self._converter_updated = 0
        self._dataset_updated = 0

    def get_currency_converter(self) -> CurrencyConverter:
        """
        Get a currency converter instance.

        Automatically updates the currency converter if the dataset has been
        updated.

        Exceptions:
            RuntimeError: If the currency file is not configured.
            FileNotFoundError: If the currency file does not exist.

        :return: A currency converter instance.
        """

        if not (conversion_file := self._app.config.get('CURRENCY_FILE')):
            raise RuntimeError('Currency file not configured.')

        # Initialize the currency converter if it has not been initialized yet,
        # or if the dataset has been updated.
        self._dataset_updated = Path(conversion_file).stat().st_mtime
        if self._converter is None or self._dataset_updated > self._converter_updated:
            logger.info("Initializing currency converter with file %s.", conversion_file)
            self._converter = CurrencyConverter(
                currency_file=conversion_file,
                ref_currency=REF_CURRENCY,
            )
            self._converter_updated = self._dataset_updated

        return self._converter

    def __getattr__(self, name):
        """
        Proxy all other attributes to the currency converter.
        """
        return getattr(self.get_currency_converter(), name)


def init_currency(app: Flask):
    """
    Initialize the currency module.

    This function initializes the currency module, and registers the currency
    converter as an extension.

    :param app: The Flask application.
    :return: None
    """

    # Set default currency file path
    app.config.setdefault('CURRENCY_FILE', app.instance_path + '/currency.csv')

    # Register the currency converter as an extension
    app.extensions['currency_converter'] = CurrencyProxy(app)

    # Register the currency converter as a template filter
    app.add_template_filter(format_converted_currency, name='localcurrency')

    app.cli.add_command(update_currency_rates)


def format_converted_currency(value, currency=None, **kwargs):
    """
    Render a currency value in the preferred currency.

    This function renders a currency value in the preferred currency for the
    current locale. If the preferred currency is not the reference currency,
    the value is converted to the preferred currency.
    """

    if currency is None:
        currency = get_preferred_currency()

    # Convert the value to the preferred currency
    local_value = convert_currency(value, currency)

    # Format the value
    html = render_template("money-tag.html",
                           base_amount=format_currency(value, currency=REF_CURRENCY, format_type='name', **kwargs),
                           local_amount=format_currency(local_value, currency=currency, **kwargs))
    return Markup(html)


def convert_currency(value, currency=None, from_currency=REF_CURRENCY):
    """
    Convert a currency value to the preferred currency.

    This function converts a currency value to the preferred currency for the
    current locale. If the preferred currency is not the reference currency,
    the value is converted to the preferred currency.
    """

    if currency != REF_CURRENCY:
        return current_app.extensions['currency_converter'].convert(value, from_currency, currency)

    return value


def convert_from_currency(value, currency) -> Decimal:
    """
    Parses the localized currency value and converts it to the reference currency.
    """

    locale = get_locale()
    amount = parse_decimal(value, locale=locale)

    if currency != REF_CURRENCY:
        amount = Decimal(current_app.extensions['currency_converter'].convert(amount, currency, REF_CURRENCY))

    return amount


def get_currencies():
    """
    Get the list of supported currencies.
    """

    return current_app.extensions['currency_converter'].currencies


def get_preferred_currency():
    """
    Get the preferred currency.

    This function returns the preferred currency for the current locale.

    :return: The preferred currency.
    """

    if current_user.is_authenticated and current_user.currency:
        return str(current_user.currency)

    # Fall back to the default currency for the locale
    if territory := get_locale().territory:
        currency = get_territory_currencies(territory)[0]
        if currency in get_currencies():
            return currency
        else:
            logger.warning("Default currency %s is not supported, falling back to %s.", currency, REF_CURRENCY)

    return REF_CURRENCY


@click.command()
def update_currency_rates():
    """
    Update currency file from the European Central Bank.

    This command is meant to be run from the command line, and is not meant to be
    used in the application:
        $ flask update-currency-rates

    :return: None
    """
    click.echo('Updating currency file from the European Central Bank...')
    fetch_currency_file()
    click.echo('Done.')


def fetch_currency_file():
    """
    Fetch the currency file from the European Central Bank.

    This function fetches the currency file from the European Central Bank, and
    stores it in the configured currency file path.
    """

    from tempfile import NamedTemporaryFile  # pylint: disable=import-outside-toplevel

    fd, _ = urllib.request.urlretrieve(SINGLE_DAY_ECB_URL)
    with ZipFile(fd) as zf:
        file_name = zf.namelist().pop()

        # Create a temporary file to store the currency file, to avoid corrupting
        # the existing file if the download fails, or while writing the file.

        file_path = os.path.dirname(current_app.config['CURRENCY_FILE'])
        if not os.path.exists(file_path):
            os.makedirs(file_path)

        with NamedTemporaryFile(dir=file_path, delete=False) as f:
            f.write(zf.read(file_name))
            f.flush()

            # Move the temporary file to the configured currency file path
            os.rename(f.name, current_app.config['CURRENCY_FILE'])

src/tjts5901/i18n.py

0 → 100644
+247 −0
Original line number Diff line number Diff line
"""
Internationalisation and localisation support for the application.
"""
from enum import Enum
import os
from typing import List
from flask_babel import Babel, get_locale as get_babel_locale
from babel import Locale
from babel import __version__ as babel_version
from flask import (
    Flask,
    g,
    request,
    session,
)

from werkzeug.datastructures import LanguageAccept

from flask_login import current_user

import logging

logger = logging.getLogger(__name__)


class SupportedLocales(Enum):
    """
    Supported locales for the application.

    The values are the locale identifiers used by the Babel library.
    Order here determines the order in which the locales are presented to the
    user, and the order in which the locales are tried when the user does not
    specify a preferred locale.
    """

    FI = "fi_FI.UTF-8"
    "Finnish (Finland)"

    SV = "sv_SE.UTF-8"
    "Swedish (Sweden)"

    EN = "en_GB.UTF-8"
    "English (United Kingdom)"

    # EN_US = "en_US.UTF-8"
    # "English (United States)"

    TLH = "tlh"
    "Klingon"


TIMEZONES = {
    """
    Timezones for supported locales.

    The values are the timezone identifiers used by the Babel library.
    This approach doesnt work for countries that have multiple timezones, like
    the US.
    """
    "fi_FI": "Europe/Helsinki",
    "sv_SE": "Europe/Stockholm",
    "en_GB": "Europe/London",
    "tlh": "America/New_York",
}

def init_babel(flask_app: Flask):
    """
    Initialize the Flask-Babel extension.
    """

    # Monkeypatch klingon support into babel
    # Klingon reverts to English
    hack_babel_core_to_support_custom_locales({"tlh": "en"})

    # Configure the Flask-Babel extension.
    # Try setting the default locale from underlying OS. Falls back into English.
    system_language = Locale.default().language
    translation_dir = os.path.join(os.path.dirname(__file__), "translations")
    flask_app.config.setdefault("BABEL_TRANSLATION_DIRECTORIES", translation_dir)
    flask_app.config.setdefault("BABEL_DEFAULT_LOCALE", system_language)

    babel = Babel(flask_app, locale_selector=get_locale, timezone_selector=get_timezone)

    # Register `locales` as jinja variable to be used in templates. Uses the
    # `Locale` class from the Babel library, so that the locale names can be
    # translated.
    locales = {}
    for locale in SupportedLocales:
        locales[locale.value] = Locale.parse(locale.value)

    flask_app.jinja_env.globals.update(locales=locales)
    # Register `get_locale` as jinja function to be used in templates
    flask_app.jinja_env.globals.update(get_locale=get_babel_locale)

    # If url contains locale parameter, set it as default in session
    @flask_app.before_request
    def set_locale():
        if request.endpoint != "static":
            if locale := request.args.get('locale'):
                if locale in (str(l) for l in locales.values()):
                    logger.debug("Setting locale %s from URL.", locale)
                    session['locale'] = locale
                else:
                    logger.warning("Locale %s not supported.", locale)

    logger.info("Initialized Flask-Babel extension %s.", babel_version,
                extra=flask_app.config.get_namespace("BABEL_"))

    return babel


def hack_babel_core_to_support_custom_locales(custom_locales: dict):
    """ Hack Babel core to make it support custom locale names

    Based on : https://github.com/python-babel/babel/issues/454

    Patch mechanism provided by @kolypto

    Args:
        custom_locales: Mapping from { custom name => ordinary name }
    """
    from babel.core import get_global

    # In order for Babel to know "en_CUSTOM", we have to hack its database and put our custom
    # locale names there.
    # This database is pickle-loaded from a .dat file and cached, so we only have to do it once.
    db = get_global('likely_subtags')
    for custom_name in custom_locales:
        db[custom_name] = custom_name

    # Also, monkey-patch the exists() and load() functions that load locale data from 'babel/locale-data'
    import babel.localedata

    # Originals
    o_exists, o_load, o_parse_locale = babel.localedata.exists, babel.localedata.load, babel.core.parse_locale

    # Definitions
    def exists(name):
        # Convert custom names to normalized names
        name = custom_locales.get(name, name)
        return o_exists(name)

    def load(name, merge_inherited=True):
        # Convert custom names to normalized names
        original_name = custom_locales.get(name, name)
        l_data = o_load(original_name, merge_inherited)
        l_data['languages']['tlh'] = 'Klingon'

        l_data.update({
            'locale_id': name,
        })
        return l_data

    # Definitions
    def parse_locale(name, sep='_'):
        # Convert custom names to normalized names
        name = custom_locales.get(name, name)
        l_data = o_parse_locale(name, sep)
        return l_data

    # Make sure we do not patch twice
    if o_exists.__module__ != __name__:
        babel.localedata.exists = exists
        babel.localedata.load = load

    # if o_parse_locale.__module__ != __name__:
    #    babel.core.parse_locale = parse_locale

    # See that they actually exist
    for normalized_name in custom_locales.values():
        assert o_exists(normalized_name)


def get_locale():
    """
    Get the locale for user.

    Looks at the user model for the user's preferred locale. If the user has not
    set a preferred locale, check the browser's Accept-Language header. If the
    browser does not specify a preferred locale, use the default locale.

    todo: What happens if the user's preferred locale support is dropped from
    todo: the application?

    :return: Suitable locale for the user.
    """

    # if a locale was stored in the session, use that
    if locale := session.get('locale'):
        logger.debug("Setting locale %s from session.", locale)
        return locale

    # if a user is logged in, use the locale from the user settings
    if current_user.is_authenticated and current_user.locale:
        logger.debug("Using locale %s from user settings.", current_user.locale)
        return current_user.locale

    # otherwise try to guess the language from the user accept header the
    # browser transmits.

    # The Accept-Language header is a list of languages the user prefers,
    # ordered by preference. The first language is the most preferred.
    # The language is specified as a language tag, which is a combination of
    # a language code and a country code, separated by a hyphen.
    # For example, en-GB is English (United Kingdom).
    # The language code is a two-letter code, and the country code is a
    # two-letter code, or a three-digit number. The country code is optional.
    # For example, en is English (no country specified), and en-US is English

    # Convert the Enum of supported locales into a list of language tags.
    # Fancy way: locales_to_try = [locale.value for locale in SupportedLocales]
    locales_to_try: List[str] = list()
    for locale in SupportedLocales:
        locales_to_try.append(str(locale.value))

    # Get the best match for the Accept-Language header.
    locale = request.accept_languages.best_match(locales_to_try)

    logger.debug("Best match for Accept-Language header (%s) is %s.",
                 request.accept_languages, locale)

    return locale


def get_timezone():
    """
    Get the timezone for user.

    Looks at the user model for the user's preferred timezone. If the user has
    not set a preferred timezone, use the default timezone.
    """

    # if a user is logged in, use the timezone from the user settings
    if current_user.is_authenticated and current_user.timezone:
        logger.debug("Using locale %s from user settings.", current_user.timezone)
        return current_user.timezone

    # Try detecting the timezone from the user's locale.
    locale = get_locale()
    choises = [(k, 1) for k in TIMEZONES.keys()]

    # Use the best_match method from the LanguageAccept class to get the best
    # match for the user's locale.
    best_match = LanguageAccept(choises).best_match([locale])
    if best_match:
        logger.debug("Guessing timezone %s from locale %s.", TIMEZONES[best_match], locale)
        return TIMEZONES[best_match]
+345 −21
Original line number Diff line number Diff line
from datetime import datetime, timedelta
import logging
from typing import Optional
from flask import (
    Blueprint, flash, g, redirect, render_template, request, url_for
    Blueprint, flash, redirect, render_template, request, url_for, jsonify, current_app
)
from flask_babel import _, get_locale, lazy_gettext
from werkzeug.exceptions import abort

from .auth import login_required
from .models import Item
from markupsafe import Markup

from .auth import login_required, current_user
from .models import Bid, Item
from .currency import (
    convert_currency,
    format_converted_currency,
    convert_from_currency,
    get_currencies,
    get_preferred_currency,
    REF_CURRENCY,
)
from .notification import send_notification

bp = Blueprint('items', __name__)
api = Blueprint('api_items', __name__, url_prefix='/api/items')

logger = logging.getLogger(__name__)

MIN_BID_INCREMENT = 1


def get_item(id):
@@ -17,11 +36,117 @@ def get_item(id):
        print("Error getting item:", exc)
        abort(404)

    if item.seller == g.user:
    if item.seller == current_user:
        return item
    
    abort(403)


def get_winning_bid(item: Item) -> Optional[Bid]:
    """
    Return the (currently) winning bid for the given item.

    If there are no bids, or the item is not yet closed, return None.

    :param item: The item to get the winning bid for.
    :return: The winning bid, or None.
    """

    winning_bid = None

    # If the item is closed, return the winning bid
    if item.closed and item.winning_bid:
        return item.winning_bid

    # Sanity check: if the item is not closed, it should not have a winning bid
    assert not item.closed or not (not item.closed and winning_bid), "Item is not closed, but has a winning bid"

    try:
        # Get the highest bid that was placed before the item closed
        winning_bid = Bid.objects(item=item) \
            .filter(created_at__lt=item.closes_at) \
            .order_by('-amount') \
            .first()
    except Exception as exc:
        logger.warning("Error getting winning bid: %s", exc, exc_info=True, extra={
            'item_id': item.id,
        })

    return winning_bid


def get_item_price(item: Item) -> int:
    """
    Return the current price of the given item.

    If there are no bids, return the starting bid.

    :param item: The item to get the price for.
    :return: The current price.
    """

    winning_bid = get_winning_bid(item)
    if winning_bid:
        return winning_bid.amount + MIN_BID_INCREMENT
    else:
        return item.starting_bid


def handle_item_closing(item):
    """
    Handle the closing of an item.

    Checks if the item is not closed yet, but should be closed now. If so, 
    closes the item, and send notifications to the seller and the buyer.

    :param item: The item to handle.
    """
    # Handle the closing of an item
    if not item.is_open and not item.closed:
        logger.info("Closing item %r (%s)", item.title, item.id, extra={
            'item_id': item.id,
            'item_title': item.title,
            'item_closes_at': item.closes_at,
        })

        # Get the winning bid
        winning_bid = get_winning_bid(item)
        if winning_bid:
            item.winning_bid = winning_bid

            # Send a notifications to the seller and the buyer
            # lazy_gettext() is used to delay the translation until the message is sent
            # Markup.escape() is used to escape strings, to prevent XSS attacks
            send_notification(
                item.seller,
                title=lazy_gettext("Your item was sold"),
                message=lazy_gettext("Your item <em>%(title)s</em> was sold to %(buyer)s for %(price)s.",
                                     title=Markup.escape(item.title),
                                     buyer=Markup.escape(winning_bid.bidder.email),
                                     price=Markup.escape(winning_bid.amount)),
            )
            send_notification(
                winning_bid.bidder,
                title=lazy_gettext("You won an item"),
                message=lazy_gettext("You won the item <em>%(title)s</em> for %(price)s.",
                                     title=Markup.escape(item.title),
                                     price=Markup.escape(winning_bid.amount)),
            )

        else:
            # If there is no winning bid, send a notification to the seller
            send_notification(
                item.seller,
                title=lazy_gettext("Your item was not sold"),
                message=lazy_gettext("Your item <em>%(title)s</em> was not sold.",
                                     title=Markup.escape(item.title)),
            )

        # Close the item
        item.closed = True
        item.save()


@bp.route("/", defaults={'page': 1})
@bp.route("/items/<int:page>")
def index(page=1):
@@ -50,34 +175,90 @@ def sell():
    if request.method == 'POST':
        title = request.form['title']
        description = request.form['description']
        starting_bid = int(request.form['starting_bid'])
        
        currency = request.form.get('currency', REF_CURRENCY)
        starting_bid = convert_from_currency(request.form['starting_bid'], currency)

        error = None

        if not title:
            error = 'Title is required.'
        if not starting_bid or starting_bid < 1:
            error = 'Starting bid must be greater than 0.'
            error = Markup(_("Starting bid must be greater than %(amount)s.", amount=format_converted_currency(1, currency)))

        if error is None:
            try:
                sale_length = timedelta(days=1)
                if current_app.config['DEBUG'] and request.form.get("flash-sale"):
                    sale_length = timedelta(seconds=20)

                item = Item(
                    title=title,
                    description=description,
                    starting_bid=starting_bid,
                    seller=g.user,
                    closes_at=datetime.utcnow() + timedelta(days=1)

                    seller=current_user,
                    closes_at=datetime.utcnow() + sale_length,
                )
                item.save()
                flash(_('Item listed successfully!'))

            except Exception as exc:
                error = f"Error creating item: {exc!s}"
                error = _("Error creating item: %(exc)s", exc=exc)
                logger.warning("Error creating item: %s", exc, exc_info=True, extra={
                    'title': title,
                    'description': description,
                    'starting_bid': starting_bid,
                })
            else:
                return redirect(url_for('items.index'))

        print(error)
            flash(error)
        flash(error, category='error')

    # Get the list of currencies, and map them to their localized names
    currencies = {}
    names = get_locale().currencies
    for currency in get_currencies():
        currencies[currency] = names.get(currency, currency)

    return render_template('items/sell.html', currencies=currencies, default_currency=get_preferred_currency())


@bp.route('/item/<id>')
def view(id):
    """
    Item view page.

    Displays the item details, and a form to place a bid.
    """

    item = Item.objects.get_or_404(id=id)

    return render_template('items/sell.html')
    # !!! This is disabled as it might cause race conditions
    # !!! if multiple users are accessing the same item at the same time
    # Check if the item is closed, and handle it if so.
    #handle_item_closing(item)

    # Set the minumum price for the bid form from the current winning bid
    winning_bid = get_winning_bid(item)
    min_bid = get_item_price(item)

    local_currency = get_preferred_currency()
    local_min_bid = convert_currency(min_bid, local_currency)

    if item.closes_at < datetime.utcnow():
        if winning_bid and winning_bid.bidder == current_user:
            flash(_("Congratulations! You won the auction!"), "success")
        else:
            flash(_("This item is no longer on sale."))
    elif item.closes_at < datetime.utcnow() + timedelta(hours=1):
        # Dark pattern to show enticing message to user
        flash(_("This item is closing soon! Act now! Now! Now!"))

    return render_template('items/view.html',
                           item=item, min_bid=min_bid,
                           local_min_bid=local_min_bid,
                           local_currency=local_currency)


@bp.route('/item/<id>/update', methods=('GET', 'POST'))
@@ -91,20 +272,23 @@ def update(id):
        error = None

        if not title:
            error = 'Title is required.'
            error = _('Title is required.')

        try:
            item.title = title
            item.description = description
            item.save()
        except Exception as exc:
            error = f"Error updating item: {exc!s}"
            error = _("Error updating item: %(exc)s", exc=exc)
            logger.warning("Error updating item: %s", exc, exc_info=True, extra={
                'item_id': item.id,
            })
        else:
            flash("Item updated successfully!")
            flash(_("Item updated successfully!"))
            return redirect(url_for('items.index'))

        print(error)
        flash(error)
        flash(error, category='error')

    return render_template('items/update.html', item=item)

@@ -116,9 +300,149 @@ def delete(id):
    try:
        item.delete()
    except Exception as exc:
        error = f"Error deleting item: {exc!s}"
        print(error)
        flash(error)
        logger.warning("Error deleting item: %s", exc, exc_info=True, extra={
            'item_id': item.id,
        })
        flash(_("Error deleting item: %(exc)s", exc=exc), category='error')
    else:
        flash("Item deleted successfully!")
        flash(_("Item deleted successfully!"))
    return redirect(url_for('items.index'))


@bp.route('/item/<id>/bid', methods=('POST',))
@login_required
def bid(id):
    """
    Bid on an item.

    If the bid is valid, create a new bid and redirect to the item view page.
    Otherwise, display an error message and redirect back to the item view page.

    :param id: The id of the item to bid on.
    :return: A redirect to the item view page.
    """

    item = Item.objects.get_or_404(id=id)
    min_amount = get_item_price(item)

    local_amount = request.form['amount']
    currency = request.form.get('currency', REF_CURRENCY)

    amount = convert_from_currency(local_amount, currency)

    if amount < min_amount:
        flash(_("Bid must be at least %(min_amount)s", min_amount=format_converted_currency(min_amount)))
        return redirect(url_for('items.view', id=id))

    if item.closes_at < datetime.utcnow():
        flash("This item is no longer on sale.")
        return redirect(url_for('items.view', id=id))

    try:
        # Notice: if you have integrated the flask-login extension, use current_user
        # instead of g.user
        bid = Bid(
            item=item,
            bidder=current_user,
            amount=amount,
        )
        bid.save()
    except Exception as exc:
        flash(_("Error placing bid: %(exc)s", exc=exc))
    else:
        flash(_("Bid placed successfully!"))

    return redirect(url_for('items.view', id=id))


@api.route('<id>/bids', methods=('GET',))
@login_required
def api_item_bids(id):
    """
    Get the bids for an item.

    :param id: The id of the item to get bids for.
    :return: A JSON response containing the bids.
    """

    item = Item.objects.get_or_404(id=id)
    bids = []
    for bid in Bid.objects(item=item).order_by('-amount'):
        bids.append(bid.to_json())

    return jsonify({
        'success': True,
        'bids': bids
    })

@api.route('<id>/bids', methods=('POST',))
@login_required
def api_item_place_bid(id):
    """
    Place a bid on an item.

    If the bid is valid, create a new bid and return the bid.
    Otherwise, return an error message.
    
    Only accepts `REF_CURRENCY` bids.

    :param id: The id of the item to bid on.
    :return: A JSON response containing the bid.
    """

    item = Item.objects.get_or_404(id=id)
    min_amount = get_item_price(item)

    try:
        amount = int(request.form['amount'])
    except KeyError:
        return jsonify({
            'success': False,
            'error': _("Missing required argument %(argname)s", argname='amount')
        })
    except ValueError:
        return jsonify({
            'success': False,
            'error': _("Invalid value for argument %(argname)s", argname='amount')
        })
    except Exception as exc:
        return jsonify({
            'success': False,
            'error': _("Error parsing argument %(argname)s: %(exc)s", argname='amount', exc=exc)
        })

    if amount < min_amount:
        return jsonify({
            'success': False,
            'error': _("Bid must be at least %(min_amount)s", min_amount=min_amount)
        })

    if item.closes_at < datetime.utcnow():
        return jsonify({
            'success': False,
            'error': _("This item is no longer on sale.")
        })

    try:
        bid = Bid(
            item=item,
            bidder=current_user,
            amount=amount,
        )
        bid.save()
    except Exception as exc:
        logger.error("Error placing bid: %s", exc, exc_info=True, extra={
            'item_id': item.id,
            'bidder_id': current_user.id,
            'amount': amount,
        })

        return jsonify({
            'success': False,
            'error': _("Error placing bid: %(exc)s", exc=exc)
        })

    return jsonify({
        'success': True,
        'bid': bid.to_mongo().to_dict()
    })
+1 −1
Original line number Diff line number Diff line
@@ -76,7 +76,7 @@ def init_logging(app: Flask):
    # Setup sentry logging
    sentry_dsn = app.config.get("SENTRY_DSN")
    release = app.config.get("CI_COMMIT_SHA", get_version() or "dev")
    enviroment = app.config.get("CI_ENVIRONMENT_NAME")
    enviroment = app.config.get("SENTRY_ENVIRONMENT", "production")

    if sentry_dsn:
        sentry = sentry_sdk.init(
+161 −1
Original line number Diff line number Diff line
from datetime import datetime
from secrets import token_urlsafe
from urllib.parse import urlencode
from flask import url_for
from markupsafe import Markup

from .db import db

from mongoengine import (
@@ -7,21 +12,78 @@ from mongoengine import (
    ReferenceField,
    DateTimeField,
    EmailField,
    BooleanField,
    EnumField,
)

from mongoengine.queryset import CASCADE

from flask_login import UserMixin
from bson import ObjectId

from .i18n import SupportedLocales

class User(db.Document):

class User(UserMixin, db.Document):
    """
    Model representing a user of the auction site.
    """
    id: ObjectId

    email = EmailField(required=True, unique=True)
    "The user's email address."

    password = StringField(required=True)

    locale = StringField(default=SupportedLocales.EN.value)
    currency = StringField(max_length=3)
    "The user's preferred currency."

    timezone = StringField(max_length=50)

    created_at = DateTimeField(required=True, default=datetime.utcnow)

    is_disabled = BooleanField(default=False)
    "Whether the user is disabled."

    @property
    def image_url(self) -> str:
        """
        Return the URL of the user's avatar.
        """
        import hashlib

        digest = hashlib.md5(self.email.lower().encode("utf-8")).hexdigest()
        default = url_for("static", filename="img/default-profile.png", _external=True)
        params = urlencode({"s": 200, "d": default})
        return f"https://www.gravatar.com/avatar/{digest}?{params}"

    @property
    def is_active(self) -> bool:
        """
        Return whether the user is active.

        This is used by Flask-Login to determine whether the user is
        allowed to log in.
        """
        return not self.is_disabled

    def get_id(self) -> str:
        """
        Return the user's id as a string.
        """
        return str(self.id)

    def __html__(self) -> str:
        """
        Return a html representation of the user.
        """

        url = url_for("auth.profile", email=self.email)
        username = self.email.split("@")[0]
        safe_username = Markup.escape(username)
        return f"<a href=\"{url}\" class=\"profile-link\">@{safe_username}</a>"


class Item(db.Document):
    """
@@ -41,6 +103,104 @@ class Item(db.Document):
    starting_bid = IntField(required=True, min_value=0)

    seller = ReferenceField(User, required=True)
    winning_bid = ReferenceField("Bid")
    closed = BooleanField(default=False)
    "Whether the item has been closed."

    created_at = DateTimeField(required=True, default=datetime.utcnow)
    closes_at = DateTimeField()

    @property
    def is_open(self) -> bool:
        """
        Return whether the item is open for bidding.
        """
        if self.closed:
            return False
        return self.closes_at > datetime.utcnow()


class Bid(db.Document):
    """
    A model for bids on items.
    """

    meta = {"indexes": [
        {"fields": [
            "amount",
            "item",
            "created_at",
        ]}
    ]}

    amount = IntField(required=True, min_value=0)
    "Indicates the value of the bid."

    bidder = ReferenceField(User, required=True)
    "User who placed the bid."

    item = ReferenceField(Item, required=True)
    "Item that the bid is for."

    created_at = DateTimeField(required=True, default=datetime.utcnow)
    "Date and time that the bid was placed."


class AccessToken(db.Document):
    """
    Access token for a user.

    This is used to authenticate API requests.
    """

    meta = {"indexes": [
        {"fields": [
            "token",
            "user",
            "expires",
        ]}
    ]}

    name = StringField(max_length=100, required=True)
    "Human-readable name for the token."

    user = ReferenceField(User, required=True, reverse_delete_rule=CASCADE)
    "User that the token is for."

    token = StringField(required=True, unique=True, default=token_urlsafe)
    "The token string."

    last_used_at = DateTimeField(required=False)
    "Date and time that the token was last used."

    created_at = DateTimeField(required=True, default=datetime.utcnow)
    "Date and time that the token was created."

    expires = DateTimeField(required=False)
    "Date and time that the token expires."


class Notification(db.Document):
    """
    Represents a message between two users, or a message to a user from
    the system.
    """

    meta = {"indexes": [
        {"fields": [
            "user",
            "read_at",
            "created_at",
        ]}
    ]}

    id: ObjectId

    user = ReferenceField(User, required=True, reverse_delete_rule=CASCADE)

    category = StringField(max_length=100, default="message")
    message = StringField(required=True)
    title = StringField(max_length=120)

    created_at = DateTimeField(required=True, default=datetime.utcnow)
    read_at = DateTimeField(required=False)
+113 −0
Original line number Diff line number Diff line
"""
This module provides a way to send notifications to users.
"""

import dataclasses
from datetime import datetime
import logging

from flask import get_flashed_messages, jsonify, Blueprint
from flask_login import current_user, login_required
from flask_babel import force_locale, lazy_gettext

from .models import Notification, User

bp = Blueprint('notification', __name__, url_prefix='/')

logger = logging.getLogger(__name__)

@dataclasses.dataclass
class Message:
    """
    Represents a message to be displayed to the user.
    """
    message: str
    category: str

    # Additional field for the template
    title: str = lazy_gettext("Message")

    created_at: datetime = dataclasses.field(default_factory=datetime.utcnow)


def init_notification(app):
    """
    Initialize the notifications module.
    """
    app.register_blueprint(bp)
    app.jinja_env.globals.update(get_notifications=get_notifications)


def send_notification(user, message, category="message", title=None):
    """
    Send a notification to the given user.

    :param user: The user to send the message to.
    :param subject: The subject of the message.
    :param message: The message to send.
    """

    # Change the locale to the message recipient locale.
    with force_locale(user.locale):
        notification = Notification(
            user=user,
            message=str(message),
            category=category,
            title=str(title),
        )
        notification.save()


def get_notifications(user: User = current_user) -> list[Message]:
    """
    Get the messages for the given user.

    Flash messages are returned first, followed by database messages.
    Messages are marked as read when they are retrieved.

    Notice: the listing query might not return recently added messages.

    :param user: The user to get the messages for.
    :return: A list of messages.
    """

    messages = []

    # Get the flash messages first
    for category, message in get_flashed_messages(with_categories=True):
        messages.append(Message(message, category))

    if user is None or not user.is_authenticated:
        logger.debug("User is not authenticated, returning flash messages.")
        return messages

    # Get the database messages
    notifications = Notification.objects(user=user, read_at=None).order_by('-created_at').all()

    for notification in notifications:
        messages.append(Message(notification.message, notification.category, notification.title))

    # Mark the messages as read
    notifications.update(read_at=datetime.utcnow())

    return messages


@bp.route('/notifications.json', methods=('GET',))
@login_required
def user_notifications():
    """
    Show the form to send a message to the given user.
    """

    user = current_user

    # Convert the notifications to a list of dictionaries
    notifications = []
    for notification in get_notifications(user):
        notifications.append(dataclasses.asdict(notification))

    return jsonify({
        "success": True,
        "notifications": notifications,
    })
+153 −0
Original line number Diff line number Diff line
"""
This module contains the APScheduler extension.

This extension is used to schedule background tasks.
"""

from datetime import datetime, timedelta
import logging
from random import randint

from flask_apscheduler import APScheduler
from apscheduler.schedulers import SchedulerAlreadyRunningError
from mongoengine import signals, Q

from .models import Item
from .items import handle_item_closing

logger = logging.getLogger(__name__)

# Having the scheduler as a global variable is not ideal, but it's the easiest
# way to make it accessible.
scheduler = APScheduler()


def init_scheduler(app):
    """
    Initialize the APScheduler extension.

    This function is meant to be called from the create_app() function.
    """

    try:

        scheduler.init_app(app)

        # Due to the scheduler being utilised as global variable, check if
        # the scheduler is already running. If it is, then it means that the
        # scheduler has already been initialised.
        if not scheduler.running and not app.config.get('TESTING'):

            # Add a signal handler to schedule a task to close the item when the auction
            # ends.
            signals.post_save.connect(_schedule_item_closing_task, sender=Item)

            # Add a batch task to close expired bids every 15 minutes. This is to ensure
            # that the bids are closed even if the server is restarted.
            scheduler.add_job(trigger='interval', minutes=15,
                            func=_close_items,
                            id='close-items')

            # Add a task to update the currency rates from the European Central Bank every
            # day at random time between 5:00 and 5:59.
            scheduler.add_job(trigger='cron', hour=5, minute=randint(0, 59),
                            func=_update_currency_rates,
                            id='update-currency-rates')

            with app.app_context():
                scheduler.start()
                logger.debug('APScheduler started')

    except SchedulerAlreadyRunningError:
        logger.debug('APScheduler already running')

    except Exception as exc:
        logger.exception("Failed to initialize APScheduler: %s", exc)
    return app


def _handle_item_closing(item_id):
    """
    Handle the closing of an item.

    This function is meant to be run by the APScheduler, and is not meant to be
    called directly.
    """

    with scheduler.app.app_context():
        item = Item.objects.get(id=item_id)
        handle_item_closing(item)


# Even as this is named function, it's used as a closure, so it can access
# the scheduler variable.
def _schedule_item_closing_task(sender, document, **kwargs):  # pylint: disable=unused-argument
    """
    Schedule a task to close the item when the auction ends.

    This function is meant to be connected to the post_save signal of the Item
    model.
    """

    if not document.closes_at:
        # The item does not have an auction end time, so there is no need to
        # schedule a task to close it.
        logger.debug("Not scheduling closing, as item %s does not have an auction end time", document.id)
        return

    if document.closed:
        # The item is already closed, so there is no need to schedule a task to
        # close it.
        return

    logger.debug('Scheduling task to close item %s', document.id)
    scheduler.add_job(
        func=_handle_item_closing,
        args=(document.id,),
        trigger='date',
        run_date=document.closes_at + timedelta(seconds=1),
        id=f'close-item-{document.id}',
    )


def _close_items():
    """
    Close expired bids.

    This function is meant to be run by the APScheduler, and is not meant to be
    called directly.
    """
    with scheduler.app.app_context():
        logger.info("Running scheduled task 'close-items'")

        # Get items that are past the closing date, and are not already closed
        closes_before = datetime.utcnow() + timedelta(seconds=2)
        items = Item.objects(Q(closed=None) | Q(closed=False), closes_at__lt=closes_before).all()
        logger.debug("Closing %d items", len(items))

        # Close each item
        for item in items:
            try:
                # Make sure item is not already closed
                if item.closed:
                    continue

                handle_item_closing(item)

            except Exception as exc:
                logger.error("Error closing items: %s", exc, exc_info=True, extra={
                    'item_id': item.id,
                })


def _update_currency_rates():
    """
    Update the currency rates from the European Central Bank.

    This function is meant to be run by the APScheduler, and is not meant to be
    called directly.
    """
    from .currency import fetch_currency_file
    with scheduler.app.app_context():
        logger.debug("Running scheduled task 'update-currency-rates'")
        fetch_currency_file()
+9.91 KiB

9.91 KiB

+5.77 KiB

5.77 KiB

+6.34 KiB

6.34 KiB

+7.75 KiB

7.75 KiB

+9.7 KiB

File added.

No diff preview for this file type.

+77.1 KiB

77.07 KiB

+66 −0
Original line number Diff line number Diff line
/**
 * Display message
 */

function showMessage(message, category="message", created_at=Date.now()) {
    // Insert new toast
    const html = document.querySelector("#message-toast").content.cloneNode(true);
    html.classList += " " + category;
    html.querySelector(".message").innerHTML = message;
    html.querySelector("time.created-at").setAttribute("datetime", created_at);
    ago = moment(created_at).fromNow();
    html.querySelector("time.created-at").append(ago);
    document.querySelector("#messages").append(html);

    // Get the last inserted toast - the one we just appended
    // and show it with bootsrap api
    const toasts = document.querySelectorAll("#messages .toast");
    const element = toasts[toasts.length-1];

    let toast_options = {
        'delay': 10000,
        'autohide': false,
    };
    // Handle toast differenlty depending on category
    switch(category) {
        case "error":
            element.classList += " bg-danger text-white"
            toast_options['autohide'] = false;
            break;
        case "success":
            element.classList += " bg-success text-white"
            toast_options['autohide'] = true;
        default:
            break;
    }

    const toast = new bootstrap.Toast(element, toast_options);
    toast.show();
}

/**
 * When page is loaded, display notifications.
 */
 window.addEventListener('load', function() {
    // Populate notifications from the page first
    let delay = 0;
    notifications.forEach(msg => {
        // Use delay as timeout to make them appear neatly.
        setTimeout(() => showMessage(msg.message, msg.category, msg.created_at), delay += 150);
    });

    // Start timed loop to fetch new notifications from backend
    setInterval(() => {
        delay = 0;
        // Fetch notifications from backend
        fetch(NOTIFICATION_URL)
            .then(response => response.json())
            .then(data => {
                data['notifications'].forEach(msg => {
                    setTimeout(() => showMessage(msg.message, msg.category, msg.created_at), delay += 150);
                });
            });
    }, NOTIFICATION_WAIT_TIME);

})
+29 −0
Original line number Diff line number Diff line
/** Add style here **/
@font-face {
    font-family: "Klingon";
    src: url("fonts/klingon.ttf");
}

#locale-selector a {
    background-repeat: no-repeat;
    background-position: 2px 50%;
    background-size: 18px 12px;
    padding-left: 24px;
    image-rendering: crisp-edges;
}

#locale-selector a[href$="locale=en_GB"] {
    background-image: url('flags/en_GB.png');
}
#locale-selector a[href$="locale=fi_FI"] {
    background-image: url('flags/fi_FI.png');
}
#locale-selector a[href$="locale=sv_SE"] {
    background-image: url('flags/sv_SE.png');
}
#locale-selector a[href$="locale=tlh"] {
    background-image: url('flags/tlh.png');
}

html[lang="tlh"] *, *[lang="tlh"] {
    font-family: "Klingon", sans-serif !important;
}
+48 −14
Original line number Diff line number Diff line
@@ -18,48 +18,82 @@
    
<section class="min-vh-100 d-flex align-items-center bg-secondary">
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-12 d-flex align-items-center justify-content-center">
                <div class="card card-tertiary w-100 fmxw-400">
        <div class="row">
            <div class="col-3">
                <h5>Testing?</h5>
                <hr />
                <p class="lead">
                    <style>
                        @keyframes eighty-eight-mph {
                            0% {
                                margin-right: -90%;
                            }
                            100% {
                                margin-right: 90%;
                            }
                        }
                        #test-login::after {
                            content: "🚗";
                            font-size: 2em;
                            margin-right: -90%;
                            right: 0;
                            animation-duration: 2s;
                        }
                        #test-login:active::after,
                        #test-login:focus::after {
                            animation-name: eighty-eight-mph;
                            margin-right: 90%;
                        }
                    </style>
                    Use <code class="bg-white lead">doc@ebrownindustries.org</code> and <code class="bg-white lead">greatscott</code> to log in with account that has performed purchases and sales.
                    <form action="{{ url_for('auth.login') }}" class="mt-4" method="POST">
                        <input type="hidden" name="email" value="doc@ebrownindustries.org">
                        <input type="hidden" name="password" value="greatscott">
                        <button id="test-login" type="submit" class="btn btn-block btn-primary" title="It's back to the future reference, not nazi reference. Fucking nazis.">Hit 88 mph</button>
                    </form>
                </p>
            </div>
            <div class="col-6">
                <div class="card card-tertiary w-100">
                    <div class="card-header text-center">
                        <span>Sign in to our platform</span>
                        <span>{{ _("Sign in to our platform") }}</span>
                    </div>
                    <div class="login-dialog-img"></div>
                    <div class="card-body">
                        <form action="{{ url_for('auth.login') }}" class="mt-4" method="POST">

                            <div class="form-group">
                                <label for="email" class="mb-2">Email</label>
                                <input name="email" id="email" type="email" class="form-control" placeholder="Your email"
                                <label for="email" class="mb-2">{{ _("Email") }}</label>
                                <input name="email" id="email" type="email" class="form-control" placeholder="{{_("Your email")}}"
                                    required="">
                            </div>

                            <div class="form-group">

                                <div class="form-group">
                                    <label for="password" class="mb-2">Password</label>
                                    <label for="password" class="mb-2">{{ _("Password") }}</label>
                                    <input name="password" id="password" type="password" class="form-control"
                                        placeholder="Your password" required="">
                                        placeholder="{{ _("Your password") }}" required="">
                                </div>

                                <div class="d-flex justify-content-between align-items-center mb-4">
                                    <div class="form-check">
                                        <label class="form-check-label">
                                            <input class="form-check-input" type="checkbox">
                                            <input name="remember-me" class="form-check-input" type="checkbox">
                                            <span class="form-check-x"></span>
                                            <span class="form-check-sign"></span>
                                            Remember me
                                            {{ _("Remember me") }}
                                        </label>
                                    </div>
                                    <p class="m-0"><a href="#" class="text-right">Lost password?</a></p>
                                    <p class="m-0"><a href="#" class="text-right">{{_("Lost password?")}}</a></p>
                                </div>
                            </div>
                            <button type="submit" class="btn btn-block btn-primary">Login</button>
                            <button type="submit" class="btn btn-block btn-primary">{{_("Login")}}</button>
                        </form>
                        <div class="d-block d-sm-flex justify-content-center align-items-center mt-4">
                            <p class="font-weight-normal">
                                Not registered?
                                <a href="{{ url_for('auth.register') }}" class="font-weight-bold">Create an account</a>
                                {{_("Not registered?") }}
                                <a href="{{ url_for('auth.register') }}" class="font-weight-bold">{{ _("Create an account") }}</a>
                            </p>
                        </div>
                    </div>
+130 −0
Original line number Diff line number Diff line
{% extends 'base.html' %}

{% block header %}
    <div class="container">
        <h1>{% block title %}{{user|striptags}}'s' Profile Page{% endblock %}</h1>
    </div>
{% endblock %}

{% block content %}
<div class="container">
  <div class="row">
        <div class="col-sm-2 offset-sm-2">
            <img src="{{ user.image_url }}" class="img-fluid" alt="{{_("Profile image for %(email)s", email=user.email)|e}}" style="max-width: 100%">
        </div>
        <div class="col-sm-6 profile-section">
            <div class="card card-secondary">
                <div class="card-header text-center">
                    {{ user }}
                </div>
                <div class="card-body">
                    <div class="form-group d-flex align-items-center justify-content-between">
                        <label for="default" class="mr-3">Email:</label>
                        <input id="default" type="text" class="form-control w-75" value="{{ user.email }}" readonly>
                    </div>
                </div>
                <div class="card-footer">
                    {% if current_user == user %}
                    <div class="btn-group" role="group">
                        <ahref="{{ url_for('items.sell') }}" class="btn btn-primary">{{ _("Sell an Item") }}</a>
                    </div>
                    <div class="btn-group" role="group">
                        {# Notice: I have omited the email -attribute here, and made "me" as default in auth.py for tokens page. #}
                        <a href="{{ url_for('auth.user_access_tokens') }}" class="btn btn-primary">{{ _("Tokens") }}</a>
                    </div>
                    <div class="btn-group" role="group">
                        <a href="{{ url_for('auth.logout') }}" class="btn btn-danger">{{ _("Logout") }} </a>
                    </div>
                    {% endif %}
                </div>
            </div>
        </div>
  </div>

  <section class="row">
    <div class="col-md-10 offset-md-1 purchases">
        <h3> {{ _("My items") }} </h3>

        <div class="container">
            <div class="card">
                {% for item in won_items %}
                    <article>
                        <header class="card-header">
                            <div class="row">
                            <div class="col-sm-3">
                                <h6>{{ _("Purchase date") }}</h5>
                                <p>{{ item.winning_bid.created_at|dateformat }}</p>
                            </div>

                            <div class="col-sm-3">
                                <h6>{{ _("Item ID") }}</h5>
                                <p class="text-truncate">{{ item.id }}</p>
                            </div>
                            <div class="col-md-2">
                                <h6>{{ _("Seller") }}</h5>
                                <p><a href="{{ url_for('auth.profile', email=item.seller.email) }}">{{ item.seller }}</a></p>
                            </div>
                        </header>
                        <main class="card-body">
                            <div class="row">
                                <div class="col-sm-2">
                                    <img src="{{ item.image_url }}" class="img-fluid" alt="{{_("Product image for %(title)s", title=item.title)|e}}">
                                </div>
                                <div class="col-sm-6">
                                    <h5>{{ item.title }}</h5>
                                    <p>{{ item.description|truncate(1000) }}</p>
                                </div>
                                <div class="col-sm-1 text-nowrap ">
                                    <h6>{{ _("Price") }}</h5>
                                    <p>${{ item.winning_bid.amount|localcurrency }}</p>
                                </div>
                                <div class="col-sm-3 actions">
                                    <form>
                                        <div>
                                            <button type="submit" class="btn btn-primary border-dark btn-block">{{_("Pay")}}</button>
                                        </div>
                                        <div>
                                            <a href="{{ url_for('items.view', id=item.id) }}" class="btn btn-outline btn-block">{{_("View item")}}</a>
                                        </div>
                                    </form>
                            </div>
                        </main>
                    </article>
                {% endfor %}
            </div>
        </div>

    </div>
  </section>

  <div class="row">
    <div class="col-sm-3"></div>
    <div class="col-sm-6 auction-section">
      <h3>Recent Auctions</h3>
      <div class="card-deck">
        {% for auction in items %}
        <div class="card">
            <img src="..." class="card-img-top" alt="...">
            <div class="card-body">
                <h5 class="card-title">{{ auction.title }}</h5>
                <p class="card-text">{{ auction.description }}</p>
                <p class="card-text">Starting Bid: ${{ auction.starting_bid }}</p>
            </div>
            <div class="card-footer">
                {% if auction.is_open %}
                    {% if current_user == auction.seller %}
                        <a href="{{ url_for('items.update', id=auction.id) }}" class="btn btn-primary">Update</a>
                    {% else %}
                        <a href="{{ url_for('items.bid', id=auction.id) }}" class="btn btn-primary">Place a bid</a>
                    {% endif %}
                {% else %}
                    <div class="text-muted">Auction is Closed</div>
                {% endif %}
            </div>
        </div>
        {% endfor %}
    </div>
    <div class="col-sm-3"></div>
  </div>
</div>
{% endblock %}
 No newline at end of file
+17 −8
Original line number Diff line number Diff line
@@ -21,25 +21,34 @@
      <div class="col-12 d-flex align-items-center justify-content-center">
        <div class="card card-tertiary w-100 fmxw-400">
          <div class="card-header text-center">
            <span>Register on our platform</span>
            <span>{{ _("Register on our platform") }}</span>
          </div>
          <div class="login-dialog-img"></div>
          <div class="card-body">
            <form action="{{ url_for('auth.register') }}" method="POST" class="mt-4">

              <input type="hidden" name="timezone" id="timezone">
              <script>
                function getTimezone() {
                  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
                  document.getElementById('timezone').value = tz;
                }
                getTimezone();
              </script>

              <div class="form-group">
                <label for="email" class="mb-2">Email</label>
                <label for="email" class="mb-2">{{ _("Email") }}</label>
                <input name="email" id="email" type="email" class="form-control" placeholder="Your email" required="">
              </div>

              <div class="form-group">

                <div class="form-group">
                  <label for="password" class="mb-2">Password</label>
                  <label for="password" class="mb-2">{{ _("Password") }}</label>
                  <input name="password" id="password" type="password" class="form-control" placeholder="Your password" required="">
                </div>
                <div class="form-group">
                  <label for="confirmPassword" class="mb-2">Confirm password</label>
                  <label for="confirmPassword" class="mb-2">{{ _("Confirm password") }}</label>
                  <input name="password2" id="confirmPassword" type="password" class="form-control" placeholder="Confirm password"
                    required="">
                </div>
@@ -50,17 +59,17 @@
                      <input name="terms" class="form-check-input" type="checkbox">
                      <span class="form-check-x"></span>
                      <span class="form-check-sign"></span>
                      I agree to the <a href="#">terms and conditions</a>
                      {{ _("I agree to the %(terms)s", terms="<a href=\"#\">"|safe+_("terms and conditions")+"</a>"|safe) }}.
                    </label>
                  </div>
                </div>
              </div>
              <button type="submit" class="btn btn-block btn-primary">Register account</button>
              <button type="submit" class="btn btn-block btn-primary">{{ _("Register account") }}</button>
            </form>
            <div class="d-block d-sm-flex justify-content-center align-items-center mt-4">
              <p class="font-weight-normal">
                Already have an account?
                <a href="./login.html" class="font-weight-bold">Login here</a>
                {{ _("Already have an account?") }}
                <a href="./login.html" class="font-weight-bold">{{ _("Login here") }}</a>
              </p>
            </div>
          </div>
+106 −0
Original line number Diff line number Diff line
{% extends 'base.html' %}

{% block header %}
  <h1>{% block title %}Access tokens{% endblock %}</h1>
{% endblock %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-md-4">
            <h4>{{_("Personal Access Tokens")}}</h4>
            <p>
                {%trans%}Personal access tokens allow third-party services to authenticate with our application on your behalf.{%endtrans%}
            </p>
        </div>
        <div class="col-md-8">
            {% if token %}
            <div class="alert alert-success" role="alert">
                <h4 class="alert-heading">{{_("Your new personal access token")}}</h4>
                <p>
                    {%trans%}Your new personal access token is shown below. You may now use this token to make API requests.{%endtrans%}
                </p>
                <div class="input-group mb-3">
                    <input type="text" class="form-control" id="token" value="{{ token.token }}" readonly>
                    <button class="btn btn-outline-secondary" type="button" id="copy-token" onclick="copyToken()">{{_("Copy")}}</button>
                    <script>
                        function copyToken() {
                            var copyText = document.getElementById("token");
                            copyText.select();
                            copyText.setSelectionRange(0, 99999);
                            document.execCommand("copy");
                        }
                    </script>
                </div>
                <small class="form-text text-muted">{{ _("Make sure to copy your new token now. You won't be able to see it again!") }}</small>
                <hr>
            </div>
            {% endif %}
            <div class="card">
                <div class="card-header">
                    <div class="text-center">{{ _("Create access token") }}</div>
                </div>
                <form action="{{url_for('auth.user_access_tokens', email='me')}}" method="post" class="card-body">
                    <div class="form-group">
                        <label for="name">{{ _("Name") }}</label>
                        <input type="text" class="form-control" name="name" id="name" placeholder="{{ _(" Enter token name") }}">
                        <div class="form-text text-muted">{{ _("Give your token a descriptive name so you can easily identify it in the future.") }}</div>
                    </div>
                    <div class="form-group">
                        <label class="form-check-label" for="expires">{{ _("Expires at") }}</label>
                        <input type="date" class="form-control" name="expires" id="expires">
                        <div class="form-text text-muted">{{ _("Leave blank to never expire.") }}</div>
                    </div>
                    <button type="submit" class="btn btn-primary">{{ _("Create access token") }}</button>
                </form>
            </div>
        </div>
    </div>
</div>

<div class="container">
    <div class="row">
        <div class="col-md-8 offset-md-4">
            <h2 class="text-center mb-4">{{ _("Active Access Tokens") }}</h2>
            <table class="table table-striped">
                <thead>
                    <tr>
                        <th>{{ _("Token name") }}</th>
                        <th>{{ _("Created") }}</th>
                        <th>{{ _("Last used") }}</th>
                        <th>{{ _("Expires") }}</th>
                        <th>{{ _("Actions") }}</th>
                    </tr>
                </thead>
                <tbody>
                    {% for access_token in tokens %}
                    <tr>
                        <td>{{ access_token.name }}</td>
                        <td>{{ access_token.created_at|dateformat }}</td>
                        <td>
                            {% if access_token.last_used_at %}
                                {{ access_token.last_used_at|dateformat }}
                            {% else %}{{ _("Never") }}
                            {% endif %}
                        </td>
                        <td>
                            {% if access_token.expires_at %}
                                {{ access_token.expires_at|dateformat }}
                            {% else %}{{ _("Never") }}
                            {% endif %}
                        </td>
                        <td>
                            <form action="{{ url_for('auth.delete_user_access_token', email='me', id=access_token.id) }}"
                                method="post">
                                <button type="submit" class="btn btn-danger">{{ _("Delete") }}</button>
                            </form>
                        </td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
        </div>
    </div>
</div>

{% endblock %}
 No newline at end of file
+55 −16
Original line number Diff line number Diff line
<!doctype html>
<html lang="en">
<html lang="{{get_locale()}}">
  <head>
    <title>{% block title %}{% endblock %} - {{ config['BRAND'] }}</title>
    <!-- Required meta tags -->
@@ -45,27 +45,39 @@
      <div class="collapse navbar-collapse" id="navbarTogglerDemo03">
        <ul class="navbar-nav mr-auto">
          <li class="nav-item active">
            <a class="nav-link" href="{{ url_for('items.index') }}">Home</a>
            <a class="nav-link" href="{{ url_for('items.index') }}">{{_("Home")}}</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="{{ url_for('items.sell') }}">Sell</a>
            <a class="nav-link" href="{{ url_for('items.sell') }}">{{_("Sell")}}</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="#">About</a>
            <a class="nav-link" href="#">{{_("About")}}</a>
          </li>
        </ul>
        <ul class="navbar-nav">
          {% if g.user %}
          <li class="nav-item"><span>{{ g.user['username'] }}</span></li>
          <li class="nav-item"><a href="{{ url_for('auth.logout') }}" class="nav-link">Log Out</a></li>
          <li class="nav-item">
            <div class="dropdown">
              <button class="btn nav-link dropdown-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
                {{_("Language")}}
              </button>
              <div class="dropdown-menu" id="locale-selector">
                {% for _, lang in locales.items() %}
                  <a class="dropdown-item {% if lang|lower == get_locale()|lower %}active{% endif %}" href="{{ url_for(request.endpoint, **dict(request.view_args, locale=lang)) }}">{{lang.display_name}}</a>
                {% endfor %}
              </div>
            </div>
          </li>
          {% if current_user.is_authenticated %}
          <li class="nav-item"><a href="{{ url_for('auth.profile', email="me") }}" class="nav-link">{{ current_user['email'] }}</a></li>
          <li class="nav-item"><a href="{{ url_for('auth.logout') }}" class="nav-link">{{_("Log Out")}}</a></li>
          {% else %}
          <li class="nav-item"><a href="{{ url_for('auth.register') }}" class="nav-link">Register</a>
          <li class="nav-item"><a href="{{ url_for('auth.login') }}" class="nav-link">Log In</a>
          <li class="nav-item"><a href="{{ url_for('auth.register') }}" class="nav-link">{{_("Register")}}</a>
          <li class="nav-item"><a href="{{ url_for('auth.login') }}" class="nav-link">{{_("Log In")}}</a>
            {% endif %}
        </ul>
        <form class="form-inline">
          <input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
          <button class="btn btn-outline-info my-2 my-sm-0" type="submit">Search</button>
          <button class="btn btn-outline-info my-2 my-sm-0" type="submit">{{_("Search")}}</button>
        </form>
      </div>
    </nav>
@@ -76,17 +88,44 @@
        {% endblock %}
      </header>
      <main class="content">
        {% for message in get_flashed_messages() %}
          <div class="container">
            <div class="flash alert alert-primary">{{ message }}</div>
          </div>
        {% endfor %}

        {% block content %}
          <!-- MAIN CONTENT BLOCK MISSING -->
        {% endblock %}
      </main>

      {# Flash messages #}
      <div class="position-fixed top-0 right-0 p-3 m-5">
        <div class="container toast-container" id="messages">
        <template id="message-toast">
          <!-- Template for flahed messages -->
          <div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
            <div class="toast-header">
              <strong class="mr-auto">{{ _("Message") }}</strong>
              <small><time class="created-at"></time></small>
              <button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="{{_("Close")}}">
                <span aria-hidden="true">&times;</span>
              </button>
            </div>
            <div class="toast-body">
              <span class="message"></span>
            </div>
          </div>
          <div class="clearfix"></div>
        </template>
        </div>
      </div>
      <script>
        // Timout is in milliseconds
        const NOTIFICATION_WAIT_TIME = 30 * 1000;
        const NOTIFICATION_URL = {{ url_for('notification.user_notifications')|tojson }};

        var notifications = {{ get_notifications()|tojson }};
      </script>
      <script src="{{url_for('static', filename='notifications.js')}}"></script>

    <!-- Moment library -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

    <!-- Option 1: jQuery and Bootstrap Bundle (includes Popper) -->
    <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-Fy6S3B9q64WdZWQUiU+q4/2Lc9npb8tCaSX9FK7E8HnRr0Jz8D6OP9dO5Vg3Q9ct" crossorigin="anonymous"></script>
+13 −13
Original line number Diff line number Diff line
@@ -17,7 +17,7 @@
}
</style>
<div id="header-img" class="d-flex container-flex align-items-center justify-content-center">
  <h1>{% block title %}Items on sale{% endblock %}</h1>
  <h1>{% block title %}{{_("Items on sale")}}{% endblock %}</h1>
</div>
{% endblock %}

@@ -28,28 +28,28 @@
      <table class="table">
          <thead class="thead-light">
            <tr>
              <th>Title</th>
              <th>Description</th>
              <th>Starting Bid</th>
              <th>Seller</th>
              <th>Created At</th>
              <th>Closes At</th>
              <th>{{ _("Title") }}</th>
              <th>{{ _("Description") }}</th>
              <th>{{ _("Starting Bid") }}</th>
              <th>{{ _("Seller") }}</th>
              <th>{{ _("Created At") }}</th>
              <th>{{ _("Closes At") }}</th>
            </tr>
          </thead>
          <tbody>
            {% for item in items.items %}
            <tr>
              <td>
                  {{ item.title }}
                  {% if g.user == item.seller %}
                  <a href="{{ url_for('items.view', id=item.id)}}">{{ item.title }}</a>
                  {% if current_user == item.seller %}
                      <a class="action btn btn-primary" href="{{ url_for('items.update', id=item['id']) }}">Edit</a>
                  {% endif %}
              </td>
              <td>{{ item.description }}</td>
              <td>{{ item.starting_bid }}</td>
              <td>{{ item.starting_bid|localcurrency }}</td>
              <td>{{ item.seller.email }}</td>
              <td>{{ item.created_at }}</td>
              <td>{{ item.closes_at }}</td>
              <td>{{ item.created_at|datetimeformat }}</td>
              <td>{{ item.closes_at|datetimeformat }}</td>
            </tr>
            {% endfor %}
          </tbody>
+26 −7
Original line number Diff line number Diff line
{% extends 'base.html' %}

{% block header %}
  <h1>{% block title %}Sell an Item{% endblock %}</h1>
  <h1>{% block title %}{{ _("Sell an Item")}}{% endblock %}</h1>
{% endblock %}

{% block content %}
@@ -9,22 +9,41 @@
    <div class="row">
        <div class="col-md-6 offset-md-3">
            <div class="card p-4">
                <h3 class="text-center mb-4">Add Item</h3>
                <h3 class="text-center mb-4">{{ _("Add Item") }}</h3>
                <form method="post" action="{{ url_for('items.sell') }}">
                    <div class="form-group">
                        <label for="title">Title</label>
                        <label for="title">{{ _("Title") }}</label>
                        <input type="text" name="title" id="title" class="form-control" value="{{ request.form['title'] }}">
                    </div>
                    <div class="form-group">
                        <label for="description">Description</label>
                        <label for="description">{{ _("Description") }}</label>
                        <textarea name="description" id="description" class="form-control">{{ request.form['body'] }}</textarea>
                    </div>
                    <div class="form-group">
                        <label for="starting_bid">Starting Bid</label>
                    <div class="row">
                        <div class="form-group col-8">
                            <label for="starting_bid">{{ _("Starting Bid") }}</label>
                            <input type="number" name="starting_bid" id="starting_bid" min="0" class="form-control">
                        </div>
                        <div class="form-group col-4">
                            <label for="starting_bid">{{ _("Currency") }}</label>
                            <select name="currency" id="currency" class="form-control">
                                {% for code, name in currencies.items() %}
                                    <option value="{{ code }}" {% if code == default_currency %}selected{% endif %}>{{ name }}</option>
                                {% endfor %}
                            </select>
                        </div>
                    </div>
                    {% if config['DEBUG'] %}
                    <div class="form-group">
                        <input name="flash-sale" type="checkbox" id="flash-sale">
                        <label class="form-check-label" for="flash-sale">
                        {{_("Flash sale!")}}
                        </label>
                    </div>
                    {% endif %}

                    <div class="form-group">
                        <button class="btn btn-primary btn-block">Add to listing</button>
                        <button class="btn btn-primary btn-block">{{ _("Add to listing") }}</button>
                    </div>
                </form>
            </div>
+5 −5
Original line number Diff line number Diff line
@@ -14,21 +14,21 @@
                    <form method="post" id="delete-form" action="{{ url_for('items.delete', id=item.id) }}"></form>
                    <form method="post" id="update-form" action="{{ url_for('items.update', id=item.id) }}">
                        <div class="form-group">
                            <label for="title">Title</label>
                            <label for="title">{{ _("Title") }}</label>
                            <input type="text" name="title" id="title" class="form-control" value="{{item.title}}">
                        </div>
                        <div class="form-group">
                            <label for="description">Description</label>
                            <label for="description">{{ _("Description") }}</label>
                            <textarea name="description" id="description" class="form-control">{{item.description}}</textarea>
                        </div>
                        <div class="form-group">
                            <label for="starting_price">Starting Price</label>
                            <label for="starting_price">{{ _("Starting Price") }}</label>
                            <input type="number" name="starting_bid" readonly id="starting_bid" min="0" class="form-control" value={{item.starting_bid}}>
                        </div>
                        <div class="form-group">
                            <div class="d-flex justify-content-end">
                                <button name="action" value="update" class="btn btn-primary border-dark">Update listing</button>
                                <button name="action" form="delete-form" value="delete" class="btn btn-danger" onclick="return confirm('Are you sure?');">Delete</button>
                                <button name="action" value="update" class="btn btn-primary border-dark">{{ _("Update listing") }}</button>
                                <button name="action" form="delete-form" value="delete" class="btn btn-danger" onclick="return confirm({{ _("Are you sure?")|tojson }});">{{ _("Delete") }}</button>
                            </div>
                        </div>
                    </form>
+135 −0
Original line number Diff line number Diff line
{% extends 'base.html' %}

{% block header %}
    <div class="container">
        <div class="d-flex align-items-center">
            <h1 class="mr-auto">{% block title %}{{item.title}}{% endblock %}</h1>
        </div>
    </div>
{% endblock %}

{% block content %}
<div class="container">
    <div class="card mb-3">
        <div class="row no-gutters">
            <div class="col-md-4">
                {# Maybe on future we'll have image support. Feels like an important feature to have. #}
                <img src="#" alt="...">
            </div>
            <div class="col-md-8">
                <div class="card-body border-0">
                    <h5 class="card-title">{{ _("%(item)s by %(seller)s", item=item.title, seller=item.seller.email)}}</h5>
                    <p class="card-text">
                        {% if item.is_open %}
                            <div class="form-group row">
                                <div class="col-sm-4 col-form-label">
                                    {# TODO: Follow dark patterns and change this to "time left" #}
                                    {{ _("Closes at") }}
                                </div>
                                <div class="col-sm-8">
                                    <time datetime="{{ item.closes_at.isoformat() }}">{{ item.closes_at|datetimeformat }}</time>
                                </div>
                            </div>
                            <div class="form-group row">
                                <div class="col-sm-4 col-form-label">
                                    {{ _("Added at") }}
                                </div>
                                <div class="col-sm-8">
                                    <time datetime="{{ item.created_at.isoformat() }}">{{ item.created_at|datetimeformat }}</time>
                                </div>
                            </div>
                            <hr /><!-- ------------------------------------------------ -->
                            <div class="form-group row">
                                <div class="col-sm-4 col-form-label">
                                    {# TODO: Follow dark patterns and change this to "time left" #}
                                    {{ _("Starting bid") }}
                                </div>
                                <div class="col-sm-8">
                                    <strong>{{ min_bid|localcurrency }}</strong>
                                </div>
                            </div>
                            <form action="{{ url_for('items.bid', id=item.id)}}" method="post">
                                <input type="hidden" name="currency" value="{{ local_currency }}">
                                <div class="form-group row">
                                    <label for="bid" class="col-sm-3 col-form-label">Your bid</label>
                                    <div class="col-sm-6">
                                        <label class="sr-only" for="bid">{{_("Bid amount")}}</label>
                                        <div class="input-group">
                                            <input type="number" name="amount" class="" id="bid" placeholder="Bid amount" required step="0.01">
                                        </div>

                                        <small class="form-text text-muted">
                                            {{ _("Minimum bid is %(min_bid)s", min_bid=min_bid|localcurrency) }}
                                        </small>
                                    </div>
                                    <div class="col-sm-3">
                                        <button type="submit" class="btn btn-info btn-lg btn-block border-dark">{{_("Bid")}}</button>
                                    </div>
                                </div>
                            </form>
                        {% elif item.closed and item.winning_bid.bidder == current_user %}
                            <div class="alert alert-success">
                                <strong>{{_("Congratulations!")}}</strong> {{ _("You won the bid!")}}
                            </div>
                            <form action="#" method="post">
                                <input type="hidden" name="id" value="{{ item.id }}">
                                
                                <div class="row">
                                    <div class="col-sm-6">
                                        <div class="">
                                            <h5 class="lead">{{_("Final price")}}</h5>
                                            <p class="h4">{{ item.winning_bid.amount|localcurrency }}</p>
                                            <div>
                                                <small class="form-text text-muted">
                                                    {{ _("You won this item at %(closes_at)s", closes_at=item.winning_bid.created_at|datetimeformat) }}
                                                </small>
                                            </div>
                                        </div>
                                    </div>
                                    <div class="col-sm-3">
                                        <button type="submit" class="btn btn-info btn-lg btn-block border-dark">{{_("Pay")}}</button>
                                    </div>
                                </div>
                            </form>

                        {% else %}
                            <div class="alert alert-info">
                                <strong>{{_("Item is no longer at sale.")}}</strong> {{ _("Next time, act faster!")}}
                            </div>
                        {% endif %}
                    </p>
                    <hr />
                    {# Contact seller buttons #}
                    <div class="row">
                        <div class="col">
                            <a href="mailto:{{item.seller.email}}" class="btn btn-primary btn-sm">{{_("%(icon)s Contact seller", icon="💌") }}</a>
                            <a href="tel:{{item.seller.phone}}" class="btn btn-primary btn-sm">{{_("%(icon)s Call seller", icon="☎️")}}</a>
                        </div>
                </div>
            </div>
        </div>
        <div clas="row">
            <div class="col-md-12">
                <p>{{item.description}}</p>
            </div>
        </div>
    </div>
    <div class="row justify-content-md-center">
        <div class="col-md-auto">
            <a href="https://twitter.com/intent/tweet?url={{ url_for('items.view', id=item.id, _external=True) | urlencode }}&text={{ "Check out this awesome and cheap item." | urlencode }}" class="btn btn-twitter" target="_blank" style="background-color: #1DA1F2; color:#fff;">
                🐦
                {{ _("Elon-senpai notice me!") }}
            </a>
            <a href="https://www.facebook.com/sharer.php?u={{ url_for('items.view', id=item.id, _external=True) | urlencode }}" target="_blank" class="btn btn-twitter" target="_blank" style="background-color: #4267B2; color:#fff;">
                🌙
                {{ _("Zuckdaddy, you so alpha!") }}
            </a>
            <a href="mailto:?subject={{ item.title | urlencode }}&body={{ url_for('items.view', id=item.id, _external=True) | urlencode }}" target="_blank" class="btn btn-primary" target="_blank">
                📧
                {{ _("Email to a friend") }}
            </a>
        </div>
    </div>
</div>

{% endblock %}
 No newline at end of file
+1 −0
Original line number Diff line number Diff line
<span title="{{ base_amount|e }}">{{ local_amount }}</span>
+491 −0
Original line number Diff line number Diff line

msgid ""
msgstr ""
"Project-Id-Version:  TJTS5901-K23\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2023-02-19 12:49+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: fi\n"
"Language-Team: fi <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"

#: src/tjts5901/app.py:42
msgid "Hill Valley DMC dealership"
msgstr "Hill Valley DMC -liike"

#: src/tjts5901/app.py:78 tests/test_i18n.py:79 tests/test_i18n.py:103
msgid "Hello, World!"
msgstr "Hei Maailma!"

#: src/tjts5901/auth.py:255
msgid "Required field missing"
msgstr "Pakollinen kenttä puuttuu"

#: src/tjts5901/auth.py:258
#, python-format
msgid "Error creating token: %s"
msgstr "Virhe luotaessa tunnistetta: %s"

#: src/tjts5901/auth.py:260
#, python-format
msgid "Created token: %s"
msgstr "Luotu tunniste: %s"

#: src/tjts5901/items.py:187
#, python-format
msgid "Starting bid must be greater than %(amount)s."
msgstr "Huudon tulee olla vähintään %(amount)s"

#: src/tjts5901/items.py:203
msgid "Item listed successfully!"
msgstr "Tuote lisätty onnistuneesti!"

#: src/tjts5901/items.py:206
#, python-format
msgid "Error creating item: %(exc)s"
msgstr "Virhe luotaessa tuotetta: %(exc)s"

#: src/tjts5901/items.py:251
msgid "Congratulations! You won the auction!"
msgstr "Onnittelut! Voitit huutokaupan!"

#: src/tjts5901/items.py:253 src/tjts5901/items.py:423
msgid "This item is no longer on sale."
msgstr "Tämä tuote ei ole enää myynnissä"

#: src/tjts5901/items.py:256
msgid "This item is closing soon! Act now! Now! Now!"
msgstr "Tämä tuote sulkeutuu pian! Toimi nyt! Heti! Nyt!"

#: src/tjts5901/items.py:275
msgid "Title is required."
msgstr "Otsikko on pakollinen."

#: src/tjts5901/items.py:282
#, python-format
msgid "Error updating item: %(exc)s"
msgstr "Virhe päivitettäessä tuotetta: %(exc)s"

#: src/tjts5901/items.py:287
msgid "Item updated successfully!"
msgstr "Tuote päivitetty onnistuneesti!"

#: src/tjts5901/items.py:306
#, python-format
msgid "Error deleting item: %(exc)s"
msgstr "Virhe poistaessa tuotetta: %(exc)s"

#: src/tjts5901/items.py:308
msgid "Item deleted successfully!"
msgstr "Tuote poistettu onnistuneesti!"

#: src/tjts5901/items.py:334 src/tjts5901/items.py:417
#, python-format
msgid "Bid must be at least %(min_amount)s"
msgstr "Huudon tulee olla vähintään %(min_amount)s"

#: src/tjts5901/items.py:351 src/tjts5901/items.py:442
#, python-format
msgid "Error placing bid: %(exc)s"
msgstr "Virhe asettaessa huutoa: %(exc)s"

#: src/tjts5901/items.py:353
msgid "Bid placed successfully!"
msgstr "Huuto asetettu onnistuneesti!"

#: src/tjts5901/items.py:401
#, python-format
msgid "Missing required argument %(argname)s"
msgstr "Tarvittava argumentti %(argname)s puuttuu"

#: src/tjts5901/items.py:406
#, python-format
msgid "Invalid value for argument %(argname)s"
msgstr "Virheellinen arvo argumentille %(argname)s"

#: src/tjts5901/items.py:411
#, python-format
msgid "Error parsing argument %(argname)s: %(exc)s"
msgstr "Virhe käsiteltäessä argumenttia %(argname)s: %(exc)s"

#: src/tjts5901/templates/base.html:48
msgid "Home"
msgstr "Etusivu"

#: src/tjts5901/templates/base.html:51
msgid "Sell"
msgstr "Myy"

#: src/tjts5901/templates/base.html:54
msgid "About"
msgstr "Tietoa"

#: src/tjts5901/templates/base.html:61
msgid "Language"
msgstr "Kieli"

#: src/tjts5901/templates/base.html:72
msgid "Log Out"
msgstr "Kirjaudu ulos"

#: src/tjts5901/templates/base.html:74
msgid "Register"
msgstr "Rekisteröidy"

#: src/tjts5901/templates/base.html:75
msgid "Log In"
msgstr "Kirjaudu sisään"

#: src/tjts5901/templates/base.html:80
msgid "Search"
msgstr "Etsi"

#: src/tjts5901/templates/base.html:103
msgid "Message"
msgstr "Viesti"

#: src/tjts5901/templates/base.html:105
msgid "Close"
msgstr "Sulje"

#: src/tjts5901/templates/auth/login.html:25
msgid "Sign in to our platform"
msgstr "Kirjaudu palveluun"

#: src/tjts5901/templates/auth/login.html:32
#: src/tjts5901/templates/auth/register.html:31
msgid "Email"
msgstr "Sähköposti"

#: src/tjts5901/templates/auth/login.html:33
msgid "Your email"
msgstr "Sähköpostisi"

#: src/tjts5901/templates/auth/login.html:40
#: src/tjts5901/templates/auth/register.html:38
msgid "Password"
msgstr "Salasana"

#: src/tjts5901/templates/auth/login.html:42
msgid "Your password"
msgstr "Salasanasi"

#: src/tjts5901/templates/auth/login.html:51
msgid "Remember me"
msgstr "Muista minut"

#: src/tjts5901/templates/auth/login.html:54
msgid "Lost password?"
msgstr "Salasana unohtunut?"

#: src/tjts5901/templates/auth/login.html:57
msgid "Login"
msgstr "Kirjaudu"

#: src/tjts5901/templates/auth/login.html:61
msgid "Not registered?"
msgstr "Ei vielä tunnuksia?"

#: src/tjts5901/templates/auth/login.html:62
msgid "Create an account"
msgstr "Luo tunnus"

#: src/tjts5901/templates/auth/profile.html:37
msgid "My items"
msgstr ""

#: src/tjts5901/templates/auth/profile.html:44
msgid "Purchase date"
msgstr ""

#: src/tjts5901/templates/auth/profile.html:49
msgid "Item ID"
msgstr ""

#: src/tjts5901/templates/auth/profile.html:53
#: src/tjts5901/templates/items/index.html:34
msgid "Seller"
msgstr "Myyjä"

#: src/tjts5901/templates/auth/profile.html:59
#, python-format
msgid "Product image for %(title)s"
msgstr ""

#: src/tjts5901/templates/auth/register.html:24
msgid "Register on our platform"
msgstr "Rekisteröidy palveluun"

#: src/tjts5901/templates/auth/register.html:42
msgid "Confirm password"
msgstr "Vahvista salasana"

#: src/tjts5901/templates/auth/register.html:53
#, python-format
msgid "I agree to the %(terms)s"
msgstr "Hyväksyn %(terms)s"

#: src/tjts5901/templates/auth/register.html:53
msgid "terms and conditions"
msgstr "käyttöehdot"

#: src/tjts5901/templates/auth/register.html:58
msgid "Register account"
msgstr "Rekisteröi tunnus"

#: src/tjts5901/templates/auth/register.html:62
msgid "Already have an account?"
msgstr "Onko sinulla jo tunnus?"

#: src/tjts5901/templates/auth/register.html:63
msgid "Login here"
msgstr "Kirjaudu tästä"

#: src/tjts5901/templates/auth/tokens.html:11
msgid "Personal Access Tokens"
msgstr "Henkilökohtaiset poletit"

#: src/tjts5901/templates/auth/tokens.html:13
msgid ""
"Personal access tokens allow third-party services to authenticate with "
"our application on your behalf."
msgstr ""
"Henkilökohtaiset poletit mahdollistavat kolmannen osapuolen "
"palveluidenautentikoinnin sovelluksen puolesta."

#: src/tjts5901/templates/auth/tokens.html:19
msgid "Your new personal access token"
msgstr "Uusi henkilökohtainen poletti"

#: src/tjts5901/templates/auth/tokens.html:21
msgid ""
"Your new personal access token is shown below. You may now use this token"
" to make API requests."
msgstr ""
"Uusi henkilökohtainen poletti näkyy alla. Voit nyt käyttää tätä "
"polettiaAPI-pyyntöihin."

#: src/tjts5901/templates/auth/tokens.html:25
msgid "Copy"
msgstr "Kopioi"

#: src/tjts5901/templates/auth/tokens.html:35
msgid "Make sure to copy your new token now. You won't be able to see it again!"
msgstr "Varmista, että kopioit uuden poletin. Et voi nähdä sitä uudelleen!"

#: src/tjts5901/templates/auth/tokens.html:41
#: src/tjts5901/templates/auth/tokens.html:54
msgid "Create access token"
msgstr "Luo poletti"

#: src/tjts5901/templates/auth/tokens.html:45
msgid "Name"
msgstr "Nimi"

#: src/tjts5901/templates/auth/tokens.html:46
msgid " Enter token name"
msgstr "Anna poletin nimi"

#: src/tjts5901/templates/auth/tokens.html:47
msgid ""
"Give your token a descriptive name so you can easily identify it in the "
"future."
msgstr ""
"Anna poletille kuvaava nimi, jotta voit helposti tunnistaa sen "
"tulevaisuudessa."

#: src/tjts5901/templates/auth/tokens.html:50
msgid "Expires at"
msgstr "Vanhenee"

#: src/tjts5901/templates/auth/tokens.html:52
msgid "Leave blank to never expire."
msgstr "Jätä tyhjäksi, jos et halua poletin vanhentuman."

#: src/tjts5901/templates/auth/tokens.html:64
msgid "Active Access Tokens"
msgstr "Aktiiviset poletit"

#: src/tjts5901/templates/auth/tokens.html:68
msgid "Token name"
msgstr "Poletin nimi"

#: src/tjts5901/templates/auth/tokens.html:69
msgid "Created"
msgstr "Luotu"

#: src/tjts5901/templates/auth/tokens.html:70
msgid "Last used"
msgstr "Viimeksi käytetty"

#: src/tjts5901/templates/auth/tokens.html:71
msgid "Expires"
msgstr "Vanhenee"

#: src/tjts5901/templates/auth/tokens.html:72
msgid "Actions"
msgstr "Toiminnot"

#: src/tjts5901/templates/auth/tokens.html:83
#: src/tjts5901/templates/auth/tokens.html:89
msgid "Never"
msgstr "Ei koskaan"

#: src/tjts5901/templates/auth/tokens.html:95
#: src/tjts5901/templates/items/update.html:31
msgid "Delete"
msgstr "Poista"

#: src/tjts5901/templates/items/index.html:20
msgid "Items on sale"
msgstr "Myynnissä olevat tuotteet"

#: src/tjts5901/templates/items/index.html:31
#: src/tjts5901/templates/items/sell.html:15
#: src/tjts5901/templates/items/update.html:17
msgid "Title"
msgstr "Otsikko"

#: src/tjts5901/templates/items/index.html:32
#: src/tjts5901/templates/items/sell.html:19
#: src/tjts5901/templates/items/update.html:21
msgid "Description"
msgstr "Kuvaus"

#: src/tjts5901/templates/items/index.html:33
#: src/tjts5901/templates/items/sell.html:24
msgid "Starting Bid"
msgstr "Aloitushinta"

#: src/tjts5901/templates/items/index.html:35
msgid "Created At"
msgstr "Luotu"

#: src/tjts5901/templates/items/index.html:36
msgid "Closes At"
msgstr "Sulkeutuu"

#: src/tjts5901/templates/items/sell.html:4
msgid "Sell an Item"
msgstr "Lisää tuote myyntiin"

#: src/tjts5901/templates/items/sell.html:12
msgid "Add Item"
msgstr "Lisää tuote"

#: src/tjts5901/templates/items/sell.html:28
msgid "Currency"
msgstr "Valuutta"

#: src/tjts5901/templates/items/sell.html:40
msgid "Flash sale!"
msgstr "Salamakauppa!"

#: src/tjts5901/templates/items/sell.html:46
msgid "Add to listing"
msgstr "Lisää myyntiin"

#: src/tjts5901/templates/items/update.html:25
msgid "Starting Price"
msgstr "Aloitushinta"

#: src/tjts5901/templates/items/update.html:30
msgid "Update listing"
msgstr "Päivitä myynti-ilmoitus"

#: src/tjts5901/templates/items/update.html:31
msgid "Are you sure?"
msgstr "Oletko varma?"

#: src/tjts5901/templates/items/view.html:21
#, python-format
msgid "%(item)s by %(seller)s"
msgstr "%(item)s, myyjänä %(seller)s"

#: src/tjts5901/templates/items/view.html:27
msgid "Closes at"
msgstr "Sulkeutuu"

#: src/tjts5901/templates/items/view.html:35
msgid "Added at"
msgstr "Lisätty"

#: src/tjts5901/templates/items/view.html:45
msgid "Starting bid"
msgstr "Aloitushinta"

#: src/tjts5901/templates/items/view.html:56
msgid "Bid amount"
msgstr "Tarjous"

#: src/tjts5901/templates/items/view.html:62
#, python-format
msgid "Minimum bid is %(min_bid)s"
msgstr "Vähimmäistarjous on %(min_bid)s"

#: src/tjts5901/templates/items/view.html:66
msgid "Bid"
msgstr "Tarjoa"

#: src/tjts5901/templates/items/view.html:72
msgid "Congratulations!"
msgstr "Onnittelut!"

#: src/tjts5901/templates/items/view.html:72
msgid "You won the bid!"
msgstr "Voitit huutokisan!"

#: src/tjts5901/templates/items/view.html:80
msgid "Final price"
msgstr "Lopullinen hinta"

#: src/tjts5901/templates/items/view.html:84
#, python-format
msgid "You won this item at %(closes_at)s"
msgstr "Voitit tämän tuotteen %(closes_at)s"

#: src/tjts5901/templates/items/view.html:90
msgid "Pay"
msgstr "Maksa"

#: src/tjts5901/templates/items/view.html:97
msgid "Item is no longer at sale."
msgstr "Myynti on päättynyt."

#: src/tjts5901/templates/items/view.html:97
msgid "Next time, act faster!"
msgstr "Jatkossa toimi nopeammin!"

#: src/tjts5901/templates/items/view.html:105
#, python-format
msgid "%(icon)s Contact seller"
msgstr "%(icon)s Ota yhteyttä myyjään"

#: src/tjts5901/templates/items/view.html:106
#, python-format
msgid "%(icon)s Call seller"
msgstr "%(icon)s Soita myyjälle"

#: src/tjts5901/templates/items/view.html:121
msgid "Elon-senpai notice me!"
msgstr "Elon-senpai huomioi minut!"

#: src/tjts5901/templates/items/view.html:125
msgid "Zuckdaddy, you so alpha!"
msgstr "Zuckdaddy, olet niin alpha!"

#: src/tjts5901/templates/items/view.html:129
msgid "Email to a friend"
msgstr "Lähetä ystävälle"

#~ msgid "Your item was not sold"
#~ msgstr "Tuotetteesi ei käynyt kaupaksi"

#~ msgid "Your item <a href=\"%(url)s\">%(title)s</a> was not sold."
#~ msgstr "Tuotteesi <a href=\"%(url)s\">%(title)s</a> ei käynyt kaupaksi."
+493 −0
Original line number Diff line number Diff line

msgid ""
msgstr ""
"Project-Id-Version:  TJTS5901-K23\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2023-02-19 12:49+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: sv\n"
"Language-Team: sv <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"

#: src/tjts5901/app.py:42
msgid "Hill Valley DMC dealership"
msgstr "Hill Valley DMC återförsäljare"

#: src/tjts5901/app.py:78 tests/test_i18n.py:79 tests/test_i18n.py:103
msgid "Hello, World!"
msgstr "Hej världen!"

#: src/tjts5901/auth.py:255
msgid "Required field missing"
msgstr "Obligatoriskt fält saknas"

#: src/tjts5901/auth.py:258
#, python-format
msgid "Error creating token: %s"
msgstr "Fel vid skapande av token: %s"

#: src/tjts5901/auth.py:260
#, python-format
msgid "Created token: %s"
msgstr "Skapad token: %s"

#: src/tjts5901/items.py:187
#, python-format
msgid "Starting bid must be greater than %(amount)s."
msgstr "Budet måste vara minst %(amount)s"

#: src/tjts5901/items.py:203
msgid "Item listed successfully!"
msgstr "Objektet listades framgångsrikt!"

#: src/tjts5901/items.py:206
#, python-format
msgid "Error creating item: %(exc)s"
msgstr "Fel vid skapandet av objektet: %(exc)s"

#: src/tjts5901/items.py:251
msgid "Congratulations! You won the auction!"
msgstr "Grattis! Du har vunnit auktionen!"

#: src/tjts5901/items.py:253 src/tjts5901/items.py:423
msgid "This item is no longer on sale."
msgstr "Denna vara är inte längre till rea."

#: src/tjts5901/items.py:256
msgid "This item is closing soon! Act now! Now! Now!"
msgstr "Detta objekt stänger snart! Handla nu! Nu! Nu!"

#: src/tjts5901/items.py:275
msgid "Title is required."
msgstr "Titel krävs."

#: src/tjts5901/items.py:282
#, python-format
msgid "Error updating item: %(exc)s"
msgstr "Fel vid uppdatering av objekt: %(exc)s"

#: src/tjts5901/items.py:287
msgid "Item updated successfully!"
msgstr "Artikeln har uppdaterats framgångsrikt!"

#: src/tjts5901/items.py:306
#, python-format
msgid "Error deleting item: %(exc)s"
msgstr "Fel vid radering av objekt: %(exc)s"

#: src/tjts5901/items.py:308
msgid "Item deleted successfully!"
msgstr "Föremålet har tagits bort framgångsrikt!"

#: src/tjts5901/items.py:334 src/tjts5901/items.py:417
#, python-format
msgid "Bid must be at least %(min_amount)s"
msgstr "Budet måste vara minst %(min_amount)s"

#: src/tjts5901/items.py:351 src/tjts5901/items.py:442
#, python-format
msgid "Error placing bid: %(exc)s"
msgstr "Fel i budgivningen: %(exc)s"

#: src/tjts5901/items.py:353
msgid "Bid placed successfully!"
msgstr "Budet har placerats framgångsrikt!"

#: src/tjts5901/items.py:401
#, python-format
msgid "Missing required argument %(argname)s"
msgstr "Det obligatoriska argumentet %(argname)s saknas"

#: src/tjts5901/items.py:406
#, python-format
msgid "Invalid value for argument %(argname)s"
msgstr "Ogiltigt värde för argument %(argname)s"

#: src/tjts5901/items.py:411
#, python-format
msgid "Error parsing argument %(argname)s: %(exc)s"
msgstr "Fel vid analys av argument %(argname)s: %(exc)s"

#: src/tjts5901/templates/base.html:48
msgid "Home"
msgstr "Hem"

#: src/tjts5901/templates/base.html:51
msgid "Sell"
msgstr "Sälja"

#: src/tjts5901/templates/base.html:54
msgid "About"
msgstr "Handla om"

#: src/tjts5901/templates/base.html:61
msgid "Language"
msgstr "Språk"

#: src/tjts5901/templates/base.html:72
msgid "Log Out"
msgstr "Logga ut"

#: src/tjts5901/templates/base.html:74
msgid "Register"
msgstr "Registrera"

#: src/tjts5901/templates/base.html:75
msgid "Log In"
msgstr "Logga in"

#: src/tjts5901/templates/base.html:80
msgid "Search"
msgstr "Sök"

#: src/tjts5901/templates/base.html:103
msgid "Message"
msgstr "Meddelande"

#: src/tjts5901/templates/base.html:105
msgid "Close"
msgstr "Stänga"

#: src/tjts5901/templates/auth/login.html:25
msgid "Sign in to our platform"
msgstr "Logga in på vår plattform"

#: src/tjts5901/templates/auth/login.html:32
#: src/tjts5901/templates/auth/register.html:31
msgid "Email"
msgstr "E-post"

#: src/tjts5901/templates/auth/login.html:33
msgid "Your email"
msgstr "Din email"

#: src/tjts5901/templates/auth/login.html:40
#: src/tjts5901/templates/auth/register.html:38
msgid "Password"
msgstr "Lösenord"

#: src/tjts5901/templates/auth/login.html:42
msgid "Your password"
msgstr "Ditt lösenord"

#: src/tjts5901/templates/auth/login.html:51
msgid "Remember me"
msgstr "Kom ihåg mig"

#: src/tjts5901/templates/auth/login.html:54
msgid "Lost password?"
msgstr "Glömt lösenord?"

#: src/tjts5901/templates/auth/login.html:57
msgid "Login"
msgstr "Logga in"

#: src/tjts5901/templates/auth/login.html:61
msgid "Not registered?"
msgstr "Inte registrerad?"

#: src/tjts5901/templates/auth/login.html:62
msgid "Create an account"
msgstr "Skapa ett konto"

#: src/tjts5901/templates/auth/profile.html:37
msgid "My items"
msgstr ""

#: src/tjts5901/templates/auth/profile.html:44
msgid "Purchase date"
msgstr ""

#: src/tjts5901/templates/auth/profile.html:49
msgid "Item ID"
msgstr ""

#: src/tjts5901/templates/auth/profile.html:53
#: src/tjts5901/templates/items/index.html:34
msgid "Seller"
msgstr "Säljare"

#: src/tjts5901/templates/auth/profile.html:59
#, python-format
msgid "Product image for %(title)s"
msgstr ""

#: src/tjts5901/templates/auth/register.html:24
msgid "Register on our platform"
msgstr "Registrera dig på vår plattform"

#: src/tjts5901/templates/auth/register.html:42
msgid "Confirm password"
msgstr "Bekräfta lösenord"

#: src/tjts5901/templates/auth/register.html:53
#, python-format
msgid "I agree to the %(terms)s"
msgstr "Jag godkänner %(terms)s"

#: src/tjts5901/templates/auth/register.html:53
msgid "terms and conditions"
msgstr "Villkor"

#: src/tjts5901/templates/auth/register.html:58
msgid "Register account"
msgstr "Registrera konto"

#: src/tjts5901/templates/auth/register.html:62
msgid "Already have an account?"
msgstr "Har du redan ett konto?"

#: src/tjts5901/templates/auth/register.html:63
msgid "Login here"
msgstr "Logga in här"

#: src/tjts5901/templates/auth/tokens.html:11
msgid "Personal Access Tokens"
msgstr "Personliga åtkomsttokens"

#: src/tjts5901/templates/auth/tokens.html:13
msgid ""
"Personal access tokens allow third-party services to authenticate with "
"our application on your behalf."
msgstr ""
"Personliga åtkomsttokens tillåter tredjepartstjänster att autentisera med"
" vår applikation å dina vägnar."

#: src/tjts5901/templates/auth/tokens.html:19
msgid "Your new personal access token"
msgstr "Din nya personliga åtkomsttoken"

#: src/tjts5901/templates/auth/tokens.html:21
msgid ""
"Your new personal access token is shown below. You may now use this token"
" to make API requests."
msgstr ""
"Din nya personliga åtkomsttoken visas nedan. Du kan nu använda denna "
"token för att göra API-förfrågningar."

#: src/tjts5901/templates/auth/tokens.html:25
msgid "Copy"
msgstr "Kopiera"

#: src/tjts5901/templates/auth/tokens.html:35
msgid "Make sure to copy your new token now. You won't be able to see it again!"
msgstr ""
"Se till att kopiera din nya token nu. Du kommer inte att kunna se den "
"igen!"

#: src/tjts5901/templates/auth/tokens.html:41
#: src/tjts5901/templates/auth/tokens.html:54
msgid "Create access token"
msgstr "Skapa åtkomsttoken"

#: src/tjts5901/templates/auth/tokens.html:45
msgid "Name"
msgstr "namn"

#: src/tjts5901/templates/auth/tokens.html:46
msgid " Enter token name"
msgstr " Ange tokennamn"

#: src/tjts5901/templates/auth/tokens.html:47
msgid ""
"Give your token a descriptive name so you can easily identify it in the "
"future."
msgstr ""
"Ge din token ett beskrivande namn så att du enkelt kan identifiera den i "
"framtiden."

#: src/tjts5901/templates/auth/tokens.html:50
msgid "Expires at"
msgstr "Går ut kl"

#: src/tjts5901/templates/auth/tokens.html:52
msgid "Leave blank to never expire."
msgstr "Lämna tomt för att aldrig upphöra."

#: src/tjts5901/templates/auth/tokens.html:64
msgid "Active Access Tokens"
msgstr "Active Access Tokens"

#: src/tjts5901/templates/auth/tokens.html:68
msgid "Token name"
msgstr "Tokennamn"

#: src/tjts5901/templates/auth/tokens.html:69
msgid "Created"
msgstr "Skapad"

#: src/tjts5901/templates/auth/tokens.html:70
msgid "Last used"
msgstr "Senast använd"

#: src/tjts5901/templates/auth/tokens.html:71
msgid "Expires"
msgstr "Upphör att gälla"

#: src/tjts5901/templates/auth/tokens.html:72
msgid "Actions"
msgstr "Handlingar"

#: src/tjts5901/templates/auth/tokens.html:83
#: src/tjts5901/templates/auth/tokens.html:89
msgid "Never"
msgstr "Aldrig"

#: src/tjts5901/templates/auth/tokens.html:95
#: src/tjts5901/templates/items/update.html:31
msgid "Delete"
msgstr "Radera"

#: src/tjts5901/templates/items/index.html:20
msgid "Items on sale"
msgstr "Föremål till försäljning"

#: src/tjts5901/templates/items/index.html:31
#: src/tjts5901/templates/items/sell.html:15
#: src/tjts5901/templates/items/update.html:17
msgid "Title"
msgstr "Titel"

#: src/tjts5901/templates/items/index.html:32
#: src/tjts5901/templates/items/sell.html:19
#: src/tjts5901/templates/items/update.html:21
msgid "Description"
msgstr "Beskrivning"

#: src/tjts5901/templates/items/index.html:33
#: src/tjts5901/templates/items/sell.html:24
msgid "Starting Bid"
msgstr "Startbud"

#: src/tjts5901/templates/items/index.html:35
msgid "Created At"
msgstr "Skapad vid"

#: src/tjts5901/templates/items/index.html:36
msgid "Closes At"
msgstr "Stänger kl"

#: src/tjts5901/templates/items/sell.html:4
msgid "Sell an Item"
msgstr "Sälja ett föremål"

#: src/tjts5901/templates/items/sell.html:12
msgid "Add Item"
msgstr "Lägg till vara"

#: src/tjts5901/templates/items/sell.html:28
msgid "Currency"
msgstr "Valuta"

#: src/tjts5901/templates/items/sell.html:40
msgid "Flash sale!"
msgstr "Flash rea!"

#: src/tjts5901/templates/items/sell.html:46
msgid "Add to listing"
msgstr "Lägg till i annonsen"

#: src/tjts5901/templates/items/update.html:25
msgid "Starting Price"
msgstr "Startpris"

#: src/tjts5901/templates/items/update.html:30
msgid "Update listing"
msgstr "Uppdatera lista"

#: src/tjts5901/templates/items/update.html:31
msgid "Are you sure?"
msgstr "Är du säker?"

#: src/tjts5901/templates/items/view.html:21
#, python-format
msgid "%(item)s by %(seller)s"
msgstr "%(item)s av %(seller)s"

#: src/tjts5901/templates/items/view.html:27
msgid "Closes at"
msgstr "Stänger kl."

#: src/tjts5901/templates/items/view.html:35
msgid "Added at"
msgstr "Tillagd på"

#: src/tjts5901/templates/items/view.html:45
msgid "Starting bid"
msgstr "Startbud"

#: src/tjts5901/templates/items/view.html:56
msgid "Bid amount"
msgstr "Budbelopp"

#: src/tjts5901/templates/items/view.html:62
#, python-format
msgid "Minimum bid is %(min_bid)s"
msgstr "Minsta bud är %(min_bid)s"

#: src/tjts5901/templates/items/view.html:66
msgid "Bid"
msgstr "Anbud"

#: src/tjts5901/templates/items/view.html:72
msgid "Congratulations!"
msgstr "Grattis!"

#: src/tjts5901/templates/items/view.html:72
msgid "You won the bid!"
msgstr "Du vann budet!"

#: src/tjts5901/templates/items/view.html:80
msgid "Final price"
msgstr "Slutgiltigt pris"

#: src/tjts5901/templates/items/view.html:84
#, python-format
msgid "You won this item at %(closes_at)s"
msgstr "Du vann detta föremål på %(closes_at)s"

#: src/tjts5901/templates/items/view.html:90
msgid "Pay"
msgstr "Betala"

#: src/tjts5901/templates/items/view.html:97
msgid "Item is no longer at sale."
msgstr "Föremålet är inte längre till salu."

#: src/tjts5901/templates/items/view.html:97
msgid "Next time, act faster!"
msgstr "Nästa gång, agera snabbare!"

#: src/tjts5901/templates/items/view.html:105
#, python-format
msgid "%(icon)s Contact seller"
msgstr "%(icon)s Kontakta säljaren"

#: src/tjts5901/templates/items/view.html:106
#, python-format
msgid "%(icon)s Call seller"
msgstr "%(icon)s Ring till säljaren"

#: src/tjts5901/templates/items/view.html:121
msgid "Elon-senpai notice me!"
msgstr "Elon-senpai lägger märke till mig!"

#: src/tjts5901/templates/items/view.html:125
msgid "Zuckdaddy, you so alpha!"
msgstr "Zuckdaddy, du är så alfa!"

#: src/tjts5901/templates/items/view.html:129
msgid "Email to a friend"
msgstr "Skicka e-post till en vän"

#~ msgid "Your item was not sold"
#~ msgstr "Din vara såldes inte"

#~ msgid "Your item <a href=\"%(url)s\">%(title)s</a> was not sold."
#~ msgstr "Din vara <a href=\"%(url)s\">%(title)s</a> såldes inte."
+477 −0
Original line number Diff line number Diff line
msgid ""
msgstr ""
"Project-Id-Version: tjts5901-k23\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2023-02-19 12:49+0000\n"
"PO-Revision-Date: 2023-02-20 11:30\n"
"Last-Translator: \n"
"Language-Team: Klingon\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Crowdin-Project: tjts5901-k23\n"
"X-Crowdin-Project-ID: 569091\n"
"X-Crowdin-Language: tlh-AA\n"
"X-Crowdin-File: messages.pot\n"
"X-Crowdin-File-ID: 3\n"
"Language: tlh_AA\n"

#: src/tjts5901/app.py:42
msgid "Hill Valley DMC dealership"
msgstr "qeylIS wo'"

#: src/tjts5901/app.py:78 tests/test_i18n.py:79 tests/test_i18n.py:103
msgid "Hello, World!"
msgstr "qo' vIvan!"

#: src/tjts5901/auth.py:255
msgid "Required field missing"
msgstr "qo'Daq ja'choH"

#: src/tjts5901/auth.py:258
#, python-format
msgid "Error creating token: %s"
msgstr "chenmoHwI'pu' %s"

#: src/tjts5901/auth.py:260
#, python-format
msgid "Created token: %s"
msgstr "ghojwI' vIHechtaHvIS. %s"

#: src/tjts5901/items.py:187
#, python-format
msgid "Starting bid must be greater than %(amount)s."
msgstr "vaj bIHvaD Hoch taqlu' %(amount)s."

#: src/tjts5901/items.py:203
msgid "Item listed successfully!"
msgstr "toH, chIch buSqu'!"

#: src/tjts5901/items.py:206
#, python-format
msgid "Error creating item: %(exc)s"
msgstr "jachtaHvIS. %(exc)s"

#: src/tjts5901/items.py:251
msgid "Congratulations! You won the auction!"
msgstr "tlhochqu' 'urmang! naDev Data'!"

#: src/tjts5901/items.py:253 src/tjts5901/items.py:423
msgid "This item is no longer on sale."
msgstr "muSHa'ghach'e' neH ghaH."

#: src/tjts5901/items.py:256
msgid "This item is closing soon! Act now! Now! Now!"
msgstr "QI'yaH, mIwvam! DaH yIvang! DaH! DaH!"

#: src/tjts5901/items.py:275
msgid "Title is required."
msgstr "Dochvetlh 'oH ghu'vam."

#: src/tjts5901/items.py:282
#, python-format
msgid "Error updating item: %(exc)s"
msgstr "'elpu'bogh. %(exc)s"

#: src/tjts5901/items.py:287
msgid "Item updated successfully!"
msgstr "DujtaHvIS chenpu'!"

#: src/tjts5901/items.py:306
#, python-format
msgid "Error deleting item: %(exc)s"
msgstr "'e' vIqaSmoHbej, %(exc)s"

#: src/tjts5901/items.py:308
msgid "Item deleted successfully!"
msgstr "chIch chavvam mIghqu'!"

#: src/tjts5901/items.py:334 src/tjts5901/items.py:417
#, python-format
msgid "Bid must be at least %(min_amount)s"
msgstr "vaj choq %(min_amount)s"

#: src/tjts5901/items.py:351 src/tjts5901/items.py:442
#, python-format
msgid "Error placing bid: %(exc)s"
msgstr "'ach ngeb tu'lu'bej. %(exc)s"

#: src/tjts5901/items.py:353
msgid "Bid placed successfully!"
msgstr "QI'yaH!"

#: src/tjts5901/items.py:401
#, python-format
msgid "Missing required argument %(argname)s"
msgstr "jav %(argname)s"

#: src/tjts5901/items.py:406
#, python-format
msgid "Invalid value for argument %(argname)s"
msgstr "ghe'orDaq QongDaq yInlu' %(argname)s"

#: src/tjts5901/items.py:411
#, python-format
msgid "Error parsing argument %(argname)s: %(exc)s"
msgstr "'orghenya'nganpu' %(argname)s: %(exc)s"

#: src/tjts5901/templates/base.html:48
msgid "Home"
msgstr "juH"

#: src/tjts5901/templates/base.html:51
msgid "Sell"
msgstr "ngev"

#: src/tjts5901/templates/base.html:54
msgid "About"
msgstr "'antu'"

#: src/tjts5901/templates/base.html:61
msgid "Language"
msgstr "Hol"

#: src/tjts5901/templates/base.html:72
msgid "Log Out"
msgstr "Qo'noS"

#: src/tjts5901/templates/base.html:74
msgid "Register"
msgstr "qech"

#: src/tjts5901/templates/base.html:75
msgid "Log In"
msgstr "QonoS"

#: src/tjts5901/templates/base.html:80
msgid "Search"
msgstr "SoQ"

#: src/tjts5901/templates/base.html:103
msgid "Message"
msgstr "QIn"

#: src/tjts5901/templates/base.html:105
msgid "Close"
msgstr "SoQmoH"

#: src/tjts5901/templates/auth/login.html:25
msgid "Sign in to our platform"
msgstr "'ej tIqDajDaq bIlengchu'taHvIS,"

#: src/tjts5901/templates/auth/login.html:32
#: src/tjts5901/templates/auth/register.html:31
msgid "Email"
msgstr "maSIl"

#: src/tjts5901/templates/auth/login.html:33
msgid "Your email"
msgstr "chIm"

#: src/tjts5901/templates/auth/login.html:40
#: src/tjts5901/templates/auth/register.html:38
msgid "Password"
msgstr "polonyuS"

#: src/tjts5901/templates/auth/login.html:42
msgid "Your password"
msgstr "puqlI'"

#: src/tjts5901/templates/auth/login.html:51
msgid "Remember me"
msgstr "HIqaw"

#: src/tjts5901/templates/auth/login.html:54
msgid "Lost password?"
msgstr "Hamlet, joHwI'?"

#: src/tjts5901/templates/auth/login.html:57
msgid "Login"
msgstr "QonoS"

#: src/tjts5901/templates/auth/login.html:61
msgid "Not registered?"
msgstr "qej'a'?"

#: src/tjts5901/templates/auth/login.html:62
msgid "Create an account"
msgstr "mIw'a' latlh yIchaw'chugh"

#: src/tjts5901/templates/auth/profile.html:37
msgid "My items"
msgstr "mu'aw'"

#: src/tjts5901/templates/auth/profile.html:44
msgid "Purchase date"
msgstr "Qujchuq"

#: src/tjts5901/templates/auth/profile.html:49
msgid "Item ID"
msgstr "jI'IL"

#: src/tjts5901/templates/auth/profile.html:53
#: src/tjts5901/templates/items/index.html:34
msgid "Seller"
msgstr "je'"

#: src/tjts5901/templates/auth/profile.html:59
#, python-format
msgid "Product image for %(title)s"
msgstr "lIHwI' %(title)s"

#: src/tjts5901/templates/auth/register.html:24
msgid "Register on our platform"
msgstr "qeylIS wIghoSqa'moH"

#: src/tjts5901/templates/auth/register.html:42
msgid "Confirm password"
msgstr "qorDu'"

#: src/tjts5901/templates/auth/register.html:53
#, python-format
msgid "I agree to the %(terms)s"
msgstr "jIHeghnIS %(terms)s"

#: src/tjts5901/templates/auth/register.html:53
msgid "terms and conditions"
msgstr "je' ghotpu'"

#: src/tjts5901/templates/auth/register.html:58
msgid "Register account"
msgstr "vaj DaH 'e' yImev"

#: src/tjts5901/templates/auth/register.html:62
msgid "Already have an account?"
msgstr "johpu'' bIH, qar'a'?"

#: src/tjts5901/templates/auth/register.html:63
msgid "Login here"
msgstr "naDev"

#: src/tjts5901/templates/auth/tokens.html:11
msgid "Personal Access Tokens"
msgstr "puqbe'wI'"

#: src/tjts5901/templates/auth/tokens.html:13
msgid "Personal access tokens allow third-party services to authenticate with our application on your behalf."
msgstr "tlhoy dabuSbe'chugh, vaj ghu' daja'chugh."

#: src/tjts5901/templates/auth/tokens.html:19
msgid "Your new personal access token"
msgstr "not wa'Hu' ramjep, not che'rup."

#: src/tjts5901/templates/auth/tokens.html:21
msgid "Your new personal access token is shown below. You may now use this token to make API requests."
msgstr "reH DuHbe'chugh qaSpa' DaTuQnIS. soHDaq pImmeyDaj choQoTmoHmeH mIw."

#: src/tjts5901/templates/auth/tokens.html:25
msgid "Copy"
msgstr "qoS"

#: src/tjts5901/templates/auth/tokens.html:35
msgid "Make sure to copy your new token now. You won't be able to see it again!"
msgstr "not SuQongtaHvIS DaQoyDI'. bISaHQo'!"

#: src/tjts5901/templates/auth/tokens.html:41
#: src/tjts5901/templates/auth/tokens.html:54
msgid "Create access token"
msgstr "yIsaH"

#: src/tjts5901/templates/auth/tokens.html:45
msgid "Name"
msgstr "pong"

#: src/tjts5901/templates/auth/tokens.html:46
msgid " Enter token name"
msgstr " qeylIS pong"

#: src/tjts5901/templates/auth/tokens.html:47
msgid "Give your token a descriptive name so you can easily identify it in the future."
msgstr "juplI' DaDalaHchu'mo' bIQongchu'"

#: src/tjts5901/templates/auth/tokens.html:50
msgid "Expires at"
msgstr "wo'"

#: src/tjts5901/templates/auth/tokens.html:52
msgid "Leave blank to never expire."
msgstr "chIch neH choraQ."

#: src/tjts5901/templates/auth/tokens.html:64
msgid "Active Access Tokens"
msgstr "ghojmoHwI''a'"

#: src/tjts5901/templates/auth/tokens.html:68
msgid "Token name"
msgstr "pong"

#: src/tjts5901/templates/auth/tokens.html:69
msgid "Created"
msgstr "Chen"

#: src/tjts5901/templates/auth/tokens.html:70
msgid "Last used"
msgstr "ghom'a'pu'qo'"

#: src/tjts5901/templates/auth/tokens.html:71
msgid "Expires"
msgstr "'eS"

#: src/tjts5901/templates/auth/tokens.html:72
msgid "Actions"
msgstr "ghuy'cha'"

#: src/tjts5901/templates/auth/tokens.html:83
#: src/tjts5901/templates/auth/tokens.html:89
msgid "Never"
msgstr "not"

#: src/tjts5901/templates/auth/tokens.html:95
#: src/tjts5901/templates/items/update.html:31
msgid "Delete"
msgstr "Dotlh"

#: src/tjts5901/templates/items/index.html:20
msgid "Items on sale"
msgstr "lubuSHa'ha'"

#: src/tjts5901/templates/items/index.html:31
#: src/tjts5901/templates/items/sell.html:15
#: src/tjts5901/templates/items/update.html:17
msgid "Title"
msgstr "mang"

#: src/tjts5901/templates/items/index.html:32
#: src/tjts5901/templates/items/sell.html:19
#: src/tjts5901/templates/items/update.html:21
msgid "Description"
msgstr "qurgh"

#: src/tjts5901/templates/items/index.html:33
#: src/tjts5901/templates/items/sell.html:24
msgid "Starting Bid"
msgstr "Si'Hutlh"

#: src/tjts5901/templates/items/index.html:35
msgid "Created At"
msgstr "chenmoH"

#: src/tjts5901/templates/items/index.html:36
msgid "Closes At"
msgstr "SaQ"

#: src/tjts5901/templates/items/sell.html:4
msgid "Sell an Item"
msgstr "'ach Hoch Doch"

#: src/tjts5901/templates/items/sell.html:12
msgid "Add Item"
msgstr "joH"

#: src/tjts5901/templates/items/sell.html:28
msgid "Currency"
msgstr "qeylIS"

#: src/tjts5901/templates/items/sell.html:40
msgid "Flash sale!"
msgstr "reH pumbogh ngevwI'!"

#: src/tjts5901/templates/items/sell.html:46
msgid "Add to listing"
msgstr "yotlhDaq ratlh"

#: src/tjts5901/templates/items/update.html:25
msgid "Starting Price"
msgstr "'ay' wa'"

#: src/tjts5901/templates/items/update.html:30
msgid "Update listing"
msgstr "yupma'"

#: src/tjts5901/templates/items/update.html:31
msgid "Are you sure?"
msgstr "bIbej'a'?"

#: src/tjts5901/templates/items/view.html:21
#, python-format
msgid "%(item)s by %(seller)s"
msgstr "%(item)s qonwI' %(seller)s"

#: src/tjts5901/templates/items/view.html:27
msgid "Closes at"
msgstr "qo'Daq yIjaH"

#: src/tjts5901/templates/items/view.html:35
msgid "Added at"
msgstr "nen"

#: src/tjts5901/templates/items/view.html:45
msgid "Starting bid"
msgstr "Horey'So"

#: src/tjts5901/templates/items/view.html:56
msgid "Bid amount"
msgstr "ghom"

#: src/tjts5901/templates/items/view.html:62
#, python-format
msgid "Minimum bid is %(min_bid)s"
msgstr "joq %(min_bid)s"

#: src/tjts5901/templates/items/view.html:66
msgid "Bid"
msgstr "cha'par"

#: src/tjts5901/templates/items/view.html:72
msgid "Congratulations!"
msgstr "tlhochqu' 'urmang!"

#: src/tjts5901/templates/items/view.html:72
msgid "You won the bid!"
msgstr "ta'meyDaq puqloD!"

#: src/tjts5901/templates/items/view.html:80
msgid "Final price"
msgstr "romuluS"

#: src/tjts5901/templates/items/view.html:84
#, python-format
msgid "You won this item at %(closes_at)s"
msgstr "'oH Daleghpu' %(closes_at)s"

#: src/tjts5901/templates/items/view.html:90
msgid "Pay"
msgstr "QI'tu'"

#: src/tjts5901/templates/items/view.html:97
msgid "Item is no longer at sale."
msgstr "chuQun ghaH."

#: src/tjts5901/templates/items/view.html:97
msgid "Next time, act faster!"
msgstr "poHlIj yImev!"

#: src/tjts5901/templates/items/view.html:105
#, python-format
msgid "%(icon)s Contact seller"
msgstr "%(icon)s yIche'meH"

#: src/tjts5901/templates/items/view.html:106
#, python-format
msgid "%(icon)s Call seller"
msgstr "%(icon)s mach"

#: src/tjts5901/templates/items/view.html:121
msgid "Elon-senpai notice me!"
msgstr "jIHvaD jatlhtaHvIS, jIHvaD puqloDpu'!"

#: src/tjts5901/templates/items/view.html:125
msgid "Zuckdaddy, you so alpha!"
msgstr "SoH, puqloDnIS!"

#: src/tjts5901/templates/items/view.html:129
msgid "Email to a friend"
msgstr "wo' Duypu'DI' yIlo'"
+88 −2
Original line number Diff line number Diff line
from os import environ
import pytest
from flask import Flask
from faker import Faker
from werkzeug.security import generate_password_hash

from tjts5901.app import create_app
# Disable Flask debug mode for testing
environ["FLASK_DEBUG"] = "0"


@pytest.fixture
def pytest_addoption(parser: pytest.Parser):
    """
    Callback to add command-line options for pytest.

    Add option to define environment url to test.

    Usage example from agruments::
        $ pytest --environment-url "https://example.com"

    Note: GitLab CI automatically sets environment variable `CI_ENVIRONMENT_URL`
    to the address of environment to test.

    Usage example from environment variable::
        $ CI_ENVIRONMENT_URL="https://example.com" pytest

    Note: If you want to test locally, you can define environment variable in
    .env file.
    """

    parser.addoption("--environment-url",
                     dest="CI_ENVIRONMENT_URL",
                     help="Deployment webaddress",
                     default=environ.get("CI_ENVIRONMENT_URL"))


@pytest.fixture(scope="session")
def app():
    """
    Application fixture.
@@ -15,6 +44,9 @@ def app():
    >>> def test_mytest(app: Flask):
    >>>     ...
    """

    from tjts5901.app import create_app

    flask_app = create_app({
        'TESTING': True,
        'DEBUG': False,
@@ -45,3 +77,57 @@ def app():
def client(app):
    return app.test_client()


@pytest.fixture(scope='session', autouse=True)
def faker_session_locale(app: Flask):
    """
    Fixture to set faker locale.

    Compares the available locales in the application with the locales
    available in faker. If there is a match, the locale is set to the
    application locale.

    This fixture is autouse, so it will be applied to all tests.
    """

    from faker.config import AVAILABLE_LOCALES, DEFAULT_LOCALE

    languages = []
    with app.app_context():
        for locale in app.extensions['babel'].instance.list_translations():
            locale_code = str(locale)
            if locale_code in AVAILABLE_LOCALES:
                languages.append(locale_code)

    if len(languages) == 0:
        languages = [DEFAULT_LOCALE]

    return languages


@pytest.fixture()
def user(app: Flask, faker: Faker):
    """
    User fixture.

    Creates a user into database and returns it.
    """

    from tjts5901.models import User

    print(faker.locales[0])

    with app.app_context():
        password = faker.password()
        user = User(
            email=faker.email(),
            password=generate_password_hash(password),
            locale=f"{faker.locales[0]}.UTF-8",
        )
        user.save()
        setattr(user, '_plaintext_password', password)

        yield user

        user.delete()
+1 −1
Original line number Diff line number Diff line
@@ -6,5 +6,5 @@ def test_config():


def test_hello(client):
    response = client.get('/hello')
    response = client.get('/hello', headers={'Accept-Language': 'en_US'})
    assert response.data == b'Hello, World!'

tests/test_i18n.py

0 → 100644
+159 −0
Original line number Diff line number Diff line
"""
Translation tests
=================
"""

import os
from typing import List

import pytest
from flask import Flask
from flask_babel import (
    Babel,
    Locale,
    force_locale,
    gettext,
    get_translations,
)
from babel.messages.extract import extract_from_dir

from tjts5901.i18n import SupportedLocales
from tjts5901 import __file__ as pkg_file

@pytest.fixture
def babel(app: Flask):
    """
    Babel translation fixture.

    Returns babel tranlaslation fixture registered in flask app
    """
    with app.app_context():
        yield app.extensions['babel'].instance


def test_for_supported_locales(app: Flask, babel: Babel):
    """
    Compare supported locales with locales with translations available.
    """
    with app.app_context():
        languages: List[Locale] = babel.list_translations()

        # Using list comprehension to convert Enum to list of Locales
        # required_languages = [Locale.parse(locale.value) for locale in SupportedLocales]
        required_languages: List[Locale] = list()
        for locale in SupportedLocales:
            required_languages.append(Locale.parse(locale.value))

        for required in required_languages:

            # Skip English, as it is the default language of the application.
            if required.language == 'en':
                continue

            assert required in languages, f"Missing translation for language {required.language}"


def test_babel_translations(app: Flask, babel: Babel):
    """
    Test that translations exists for test string "Hello, world!". This test
    will fail if the translation is missing for any language.

    This test is not intended to test the translation itself, but rather to
    ensure that the translation exists.

    And if the actual translation for "Hello, world!" is "Hello, world!", then
    the test needs to be updated to use a different test string.
    """

    # For flask_babel to work, we need to run in app context
    with app.app_context():

        # Iterate through all of the languages available.
        languages: List[Locale] = babel.list_translations()
        for locale in languages:
            if locale.language == "en":
                # By default everything should be in english
                continue

            with force_locale(locale):
                assert gettext("Hello, World!") != "Hello, World!", f"Message is not translated for language {locale.language}"


def test_app_language_detection(client, babel):
    """
    Similar to :func:`test_babel_translations`, but uses e2e test client
    to test translations.

    Uses the Accept-Language header to set the language for the request.

    TODO: Write variation that includes the territory code in the Accept-Language header.
    """

    # Iterate through all of the languages available.
    with client.application.app_context():
        languages: List[Locale] = babel.list_translations()

    for locale in languages:
        if locale.language == "en":
            # By default everything should be in english
            continue

        response = client.get('/hello', headers={'Accept-Language': locale.language})
        resp_as_string = response.data.decode('utf-8')
        assert gettext("Hello, World!") == resp_as_string, f"Message is not translated for language {locale.language}"


@pytest.fixture(scope="session")
def app_strings():
    """
    Fixture for extracting strings from the application source code.
    """

    # TODO: Read method_map from config file
    method_map = [
        ('**.py', 'python'),
        ('**/templates/**.html', 'jinja2'),
    ]

    # Collect all of the messages from the source code
    dir_path = os.path.dirname(pkg_file)
    messages = set()
    for msg in extract_from_dir(dir_path, method_map):
        messages.add(msg[2])

    return messages


@pytest.mark.parametrize("locale", SupportedLocales)
def test_app_translation_status(locale, app, babel, app_strings, fail_treshold=0.15):
    """
    Check that the majority of strings in application are translated.

    This test will fail if the percentage of untranslated strings is greater
    than the :param:`fail_treshold`.

    :param fail_treshold:  Acceptable percentile for untraslated strings.
    """
    unique_messages = len(app_strings)

    with app.app_context():
        locale = Locale.parse(locale.value)

        if locale.language == "en":
            # By default everything should be in english
            return

        with force_locale(locale):
            untranslated_messages = 0
            # Get the catalog for the current locale, and check if the extracted message
            # is in the catalog
            catalog = get_translations()._catalog  # pylint: disable=protected-access
            for msg in app_strings:
                # If the message is not in the catalog, then it is untranslated
                if catalog.get(msg, "") == "":
                    untranslated_messages += 1

            # Calculate the percentage of untranslated messages
            untranslated_percent = untranslated_messages / unique_messages

            assert untranslated_percent < fail_treshold, f"Too many untranslated strings for language {locale.language} ({untranslated_percent:.2%})"
+74 −0
Original line number Diff line number Diff line
"""
Test the notifications module.
"""
import pytest

from flask import Flask, flash, url_for
from flask.testing import FlaskClient
from tjts5901.models import Notification, User
from tjts5901.notification import send_notification, get_notifications


def test_send_notification(user: User):
    """
    Test that a notification can be sent to a user.
    """

    send_notification(user, "Test message", "message", "Test title")

    # Check that the notification was saved to the database.
    notifications = Notification.objects(user=user)
    assert len(notifications) == 1, "Notification was not saved to the database."

    # Check that the notification has the correct values.
    notification = notifications.first()
    assert notification.message == "Test message"
    assert notification.category == "message"
    assert notification.title == "Test title"


def test_flash_messages(user: User, app: Flask):
    """
    Test that flask.flash() can be used to send notifications.
    """

    with app.test_request_context():
        flash("Test message", "message")

        notifications = get_notifications(user)
        assert len(notifications) == 1, "Flash message was returned."

        notification = notifications.pop()
        assert notification.message == "Test message"
        assert notification.category == "message"

def test_notifications_view(client: FlaskClient, user: User):
    """
    Test that the notifications view works.
    """

    # Send a notification to the user.

    db_notification = {
        "message": "This is a database message",
        "category": "message",
    }

    send_notification(user, **db_notification)

    with client:
        # Log in as the user.
        client.post(
            url_for("auth.login"),
            data={"email": user.email, "password": user._plaintext_password},
            follow_redirects=False,
        )

        # Check that the notification is shown on the page.
        response = client.get(url_for('notification.user_notifications'))
        
        assert response.status_code == 200
        assert response.is_json

        assert db_notification['message'] in [msg['message'] for msg in response.json['notifications']], \
            "Database message was not returned."

tests/test_smoke.py

0 → 100644
+68 −0
Original line number Diff line number Diff line
"""
Smoketests
==========

Thease are tests that run in gitlab runner, but they target review or staging
environment.
"""
import requests
import pytest

@pytest.fixture
def deployment_address(pytestconfig: pytest.Config):
    """
    Get the address for environment to check.

    Define it in either in environment variable `CI_ENVIRONMENT_URL`, 
    or as pytest argument `--environment url <address>`.

    To test locally setup in .env::
        CI_ENVIRONMENT_URL="http://localhost:5000/"
    """

    url = pytestconfig.getoption("CI_ENVIRONMENT_URL")

    if not url:
        return pytest.skip("Could not determine environment url. Please check that container contains variable CI_ENVIRONMENT_URL or define `--environment-url <address>`")

    return url


def test_server_status(deployment_address: str, path="/server-info"):
    """
    Fetch server status page and checks for Falsies.
    """

    deployment_address = deployment_address.rstrip("/")
    resp = requests.get(deployment_address + path, timeout=5)

    assert resp.status_code == 200
    assert resp.headers['Content-Type'] == 'application/json'

    data = resp.json()

    assert data['sentry_available'] is True, "App reporting that the sentry is not available"
    assert data['database_connectable'] is True, "App reporting that the database is not available"


def test_404(deployment_address, path="/_404"):
    """
    Anti-thesis: Check that the correct error code - 404 - is returned for missing page (:param:`path`)
    """

    deployment_address = deployment_address.rstrip("/")
    resp = requests.get(deployment_address + path, timeout=5)
    assert resp.status_code == 404, f"Expected to receive 404 for path {path}"
    # QED


def test_fronpage_loading(deployment_address):
    """
    Fetch fronpage of our application, and check for known string.
    """
    from test_app import IN_TITLE  # pylint: disable=import-outside-toplevel

    resp = requests.get(deployment_address, timeout=5)

    assert resp.status_code == 200, f"Failed to fetch page {deployment_address}"
    assert IN_TITLE.encode() in resp.content