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

Target

Select target project
  • startuplab/courses/tjts5901-continuous-software-engineering/TJTS5901-K23_template
  • planet-of-the-apes/tjts-5901-apeuction
  • uunot-yliopiston-leivissa/tjts-5901-uunot
  • contain-the-cry/tjts-5901-auction-system
  • avengers/avengers
  • cse6/cse-6
  • 13th/13-sins-of-gitlab
  • 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/auction-project
  • 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/tjts-5901-k-23-red-october
  • good-on-paper/good-paper-project
  • desperados/desperados
19 results
Show changes
Commits on Source (193)
Showing with 1254 additions and 35 deletions
......@@ -69,7 +69,7 @@
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [ 5001 ],
// "forwardPorts": [ 5001 ],
// Uncomment the next line if you want start specific services in your Docker Compose config.
// "runServices": [],
......@@ -90,5 +90,6 @@
// "terraform": "latest",
"azure-cli": {
"version": "lts"
}
}
}
......@@ -165,3 +165,6 @@ cython_debug/
_docs/
.env
.DS_Store
# Ignore certificates
azure-sp.pem
## Stage names in the pipeline.
stages:
- build
- test
- staging
- smoketest
- deploy
variables:
## Name for the generated image. Change this if you wish, but watch out
## for special characters and spaces!
DOCKER_IMAGE_NAME: ${DOCKER_REGISTRY}/tjts5901
DOCKER_TAG: latest
DOCKER_TAG: ${CI_COMMIT_REF_SLUG}
## (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
## Example gitlab-ci buildkit template: https://gitlab.com/txlab/ci-templates
build:
stage: build
environment: production
image:
name: moby/buildkit:v0.10.6-rootless
entrypoint: [ "sh", "-c" ]
......@@ -43,3 +48,157 @@ build:
--local dockerfile=. \
--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
stage: test
image: ${DOCKER_IMAGE_NAME}:${DOCKER_TAG}
variables:
## Setup variable pointin to mongo service
## Notice: the `mongo` address might not work.
MONGO_URL: mongodb://mongo/tjts5901-test
## When job is started, also start these things
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]
## 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
## Push latest image into registry with the `latest` tag.
docker tag latest:
stage: deploy
environment:
name: production
image: docker:20.10.23
only:
- main
script:
## Copy credentials to container
- 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
## 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
### As a ...
(user role)
### I want to ...
(goal)
### So that ...
(benefit)
### I know I'm done when ...
(the thing does thang)
/label ~"User Story"
......@@ -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
......
# TJTS5901 Course Template Project
This is the template for 2023 TJTS5901 Continuous Software Engineering -course.
Project template for 2023 TJTS5901 Continuous Software Engineering -course.
Sisu: <https://sisu.jyu.fi/student/courseunit/otm-38b7f26b-1cf9-4d2d-a29b-e1dcb5c87f00>
Moodle: <https://moodle.jyu.fi/course/view.php?id=20888>
- Sisu: <https://sisu.jyu.fi/student/courseunit/otm-38b7f26b-1cf9-4d2d-a29b-e1dcb5c87f00>
- Moodle: <https://moodle.jyu.fi/course/view.php?id=20888>
To get started with the project, see [`week_1.md`](./week_1.md)
The application is deployed at <https://tjts5901-app.azurewebsites.net>
## Start the app
Repository provides an `docker-compose` file to start the app:
Repository provides an `docker-compose` file to start the app. Edit `docker-compose.yml` to uncomment the ports, and run:
```sh
docker-compose up --build tjts5901
......@@ -21,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)
[python: **.py]
[jinja2: **/templates/**.html]
......@@ -34,10 +34,16 @@ services:
## Don't restart container if it exits. Useful for debugging, not for production.
restart: 'no'
# Example for another service, such as a database.
# mongodb:
# image: mongo:stable
# restart: unless-stopped
## Start a mongodb container and link it to the app container
depends_on:
- mongodb
# MongoDB container
mongodb:
image: mongo:4.2
restart: unless-stopped
ports:
- 27017:27017
volumes:
tjts5901-vscode-extensions:
"""
Hack to get the hosted gitlab magiclink extension to work with mkdocs.
See: https://github.com/facelessuser/pymdown-extensions/issues/933
"""
import pymdownx.magiclink
base_url = "https://gitlab.jyu.fi"
pymdownx.magiclink.PROVIDER_INFO["gitlab"].update({
"url": base_url,
"issue": "%s/{}/{}/issues/{}" % base_url,
"pull": "%s/{}/{}/merge_requests/{}" % base_url,
"commit": "%s/{}/{}/commit/{}" % base_url,
"compare": "%s/{}/{}/compare/{}...{}" % base_url,
})
def define_env(env):
pass
......@@ -7,6 +7,7 @@
site_name: JYU TJTS5901 Course Documentation
site_author: University of Jyväskylä / StartupLab
copyright: © Copyright 2023 - <a href="https://www.jyu.fi">University of Jyväskylä</a> - This work is licensed under a <a rel="license" href="https://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International</a>.
theme:
name: material
......@@ -22,7 +23,7 @@ theme:
logo: https://gitlab.jyu.fi/uploads/-/system/appearance/header_logo/1/jyu-logo3.png
icon:
repo: fontawesome/brands/gitlab
repo: material/file-document
extra_css:
- jyu-stylesheet.css
......@@ -50,12 +51,26 @@ markdown_extensions:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
- pymdownx.magiclink:
user: startuplab/courses/tjts5901-continuous-software-engineering
repo: TJTS5901-K23_template
provider: gitlab
repo_url_shortener: true
repo_url_shorthand: true
plugins:
- offline
- search
- git-revision-date-localized:
type: date
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:
......@@ -69,4 +84,6 @@ extra:
link: https://sisu.jyu.fi/student/courseunit/otm-38b7f26b-1cf9-4d2d-a29b-e1dcb5c87f00
text: Sisu
- icon: fontawesome/brands/discord
link: http://discord.com
link: https://discord.gg/QfbAjzxJYd
- icon: simple/zoom
link: https://jyufi.zoom.us/j/64685455360
......@@ -20,3 +20,15 @@ FLASK_DEBUG=1
# Enable rich logging for more human readable log output. Requires installing
# `rich` and `flask-rich` packages.
#RICH_LOGGING=1
# Mongodb connection string
MONGO_URL=mongodb://mongodb:27017/tjts5901
# Sentry logging. Fetch the DSN ingest key: <https://docs.sentry.io/platforms/python/guides/flask/>
#SENTRY_DSN=https://ABC@XYZ.ingest.sentry.io/123
# 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
......@@ -28,11 +28,16 @@ dependencies = {file = ["requirements.txt"]}
[project.optional-dependencies]
test = [
"pytest",
"coverage",
"coverage[toml]",
"requests",
"faker",
]
docs = [
"mkdocs",
"mkdocs-material",
"mkdocs-git-revision-date-localized-plugin",
"mkdocs-macros-plugin",
]
[build-system]
......@@ -43,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",
]
......@@ -2,11 +2,23 @@
importlib-metadata
# Framework and libraries
flask
flask==2.2.2
python-dotenv
flask-login
flask-babel
flask-mongoengine==1.0
CurrencyConverter
Flask-APScheduler
# Git hooks
pre-commit
# Sentry for error reporting
sentry-sdk[flask]
sentry-sdk[pymongo]
# More production-ready web server
#gunicorn
gunicorn
......@@ -6,7 +6,14 @@ JYU TJTS5901 Course project
from importlib_metadata import (PackageNotFoundError,
version)
from .app import create_app
try:
__version__ = version(__name__)
except PackageNotFoundError:
__version__ = "unknown"
__all__ = [
"create_app",
"__version__",
]
......@@ -7,8 +7,10 @@ Flask tutorial: https://flask.palletsprojects.com/en/2.2.x/tutorial/
"""
import logging
from os import environ
from typing import Dict, Optional
import os
from typing import Dict, Literal, Optional
from dotenv import load_dotenv
from flask import (
......@@ -18,7 +20,13 @@ 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__)
def create_app(config: Optional[Dict] = None) -> Flask:
......@@ -29,15 +37,59 @@ def create_app(config: Optional[Dict] = None) -> Flask:
"""
flask_app = Flask(__name__, instance_relative_config=True)
if config:
flask_app.config.from_mapping(
SECRET_KEY='dev',
BRAND=_("Hill Valley DMC dealership"),
)
# load the instance config, if it exists, when not testing
if config is None:
flask_app.config.from_pyfile('config.py', silent=True)
else:
flask_app.config.from_mapping(config)
# Set flask config variable for "rich" loggin from environment variable.
flask_app.config.from_envvar("RICH_LOGGING", silent=True)
# Initialize logging early, so that we can log the rest of the initialization.
from .logging import init_logging # pylint: disable=import-outside-toplevel
init_logging(flask_app)
# ensure the instance folder exists
try:
os.makedirs(flask_app.instance_path)
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
# Register blueprints
from . import views # pylint: disable=import-outside-toplevel
flask_app.register_blueprint(views.bp, url_prefix='')
# a simple page that says hello
@flask_app.route('/hello')
def hello():
return _('Hello, World!')
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
......@@ -47,17 +99,9 @@ def create_app(config: Optional[Dict] = None) -> Flask:
load_dotenv()
# Create the Flask application.
app = create_app()
flask_app = create_app()
# Initialize "rich" output if enabled. It produces more human readable logs.
# You need to install `flask-rich` to use this.
if app.config.get("RICH_LOGGING"):
from flask_rich import RichApplication
RichApplication(app)
app.logger.info("Using [blue]rich[/blue] interface for logging")
@app.route("/server-info")
@flask_app.route("/server-info")
def server_info() -> Response:
"""
A simple endpoint for checking the status of the server.
......@@ -66,7 +110,29 @@ def server_info() -> Response:
running correctly.
"""
# Test for database connection
database_ping = False
try:
from .db import db # pylint: disable=import-outside-toplevel
database_ping = db.connection.admin.command('ping').get("ok", False) and True
except Exception as exc: # pylint: disable=broad-except
logger.warning("Error querying mongodb server: %r", exc,
exc_info=True,
extra=flask_app.config.get_namespace("MONGODB"))
# Check for sentry
sentry_available = False
try:
from sentry_sdk import Hub
sentry_available = True if Hub.current.client else False
except ImportError:
logger.warning("Sentry package is not installed")
except TypeError:
logger.info("Sentry is not integrated")
response = {
"database_connectable": database_ping,
'sentry_available': sentry_available,
"version": get_version(),
"build_date": environ.get("BUILD_DATE", None)
}
......
from datetime import datetime
import functools
import logging
from flask import (
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 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__)
def init_auth(app):
"""
Integrate authentication into the application.
"""
app.register_blueprint(bp)
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.user_loader(load_logged_in_user)
app.config['AUTH_HEADER_NAME'] = 'Authorization'
login_manager.request_loader(load_user_from_request)
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)
if email == "me" and current_user.is_authenticated:
email = current_user.email
try:
user = User.objects.get_or_404(email=email)
except DoesNotExist:
logger.error("User not found: %s", email)
abort(404)
return user
@bp.route('/register', methods=('GET', 'POST'))
def register():
if request.method == 'POST':
print("Registering user...")
email = request.form['email']
password = request.form['password']
password2 = request.form['password2']
terms = request.form.get('terms', False)
error = None
if not email:
error = 'Username is required.'
elif not password:
error = 'Password is required.'
elif password != password2:
error = 'Passwords do not match.'
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),
timezone=timezone,
)
user.save()
flash("You have been registered. Please log in.")
except Exception as exc:
error = f"Error creating user: {exc!s}"
else:
return redirect(url_for("auth.login"))
print("Could not register user:", error)
flash(error)
return render_template('auth/register.html')
@bp.route('/login', methods=('GET', 'POST'))
def login():
if request.method == 'POST':
email = request.form['email']
password = request.form['password']
user = None
error = None
try:
user = User.objects.get(email=email)
except DoesNotExist:
error = 'Incorrect username.'
if user is None:
error = 'Incorrect username.'
elif not check_password_hash(user['password'], password):
error = 'Incorrect password.'
if error is None:
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.")
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():
"""
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))
"""
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'])
import logging
from os import environ
from flask_mongoengine import MongoEngine
db = MongoEngine()
logger = logging.getLogger(__name__)
def init_db(app):
"""
Initialize the database connection.
Fetches the database connection string from the environment variable `MONGO_URL`
and, if present, sets the `MONGODB_SETTINGS` configuration variable to use it.
"""
# To keep secrets private, we use environment variables to store the database connection string.
# `MONGO_URL` is expected to be a valid MongoDB connection string, see: blah blah blah
mongodb_url = environ.get("MONGO_URL")
if mongodb_url is not None:
app.config["MONGODB_SETTINGS"] = {
"host": mongodb_url,
}
logger.info("Database connection string found, using it.",
# You can use the `extra` parameter to add extra information to the log message.
# This is useful for debugging, but should be removed in production.
extra={"MONGO_URL": mongodb_url} if app.debug else {})
else:
logger.warning("No database connection string found in env, using defaults.",
extra={"MONGODB_SETTINGS": app.config.get("MONGODB_SETTINGS")} if app.debug else {})
db.init_app(app)
"""
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]