Compare commits

...
24 Commits
Author SHA1 Message Date
Aaro Koivukangas 0aef3a64b7 changed database url to better place 2025-10-16 19:20:35 +03:00
Aaro Koivukangas 4e2415e8c1 simplified code 2025-10-16 19:12:07 +03:00
Aaro Koivukangas 21140d3477 changed postgres_url to db, user, password etc 2025-10-16 15:29:18 +03:00
Aaro Koivukangas 774bb5f453 another gh actions typofix 2025-10-07 00:16:57 +03:00
Aaro Koivukangas 7cbd4d6835 typofix 2025-10-07 00:15:44 +03:00
Aaro Koivukangas 34d027b161 rename gh actions job 2025-10-06 23:58:38 +03:00
Aaro Koivukangas b58ffef8f2 remove deployment to fly.io 2025-10-06 23:57:49 +03:00
Aaro Koivukangas 9bb7b27e99 typofix 2025-10-06 23:57:12 +03:00
Aaro Koivukangas 1d4a1febeb changed github actions to better match other procjects 2025-10-06 16:07:09 +03:00
Aaro KoivukangasandGitHub 333abd90e5 Updated README.md, removed old links 2025-01-07 15:24:46 +02:00
Aaro Koivukangas 4f8938bab2 added package creation to gh actions 2024-11-11 11:53:14 +01:00
Aaro Koivukangas 17e9878dfa added better python target 2024-03-04 22:33:51 +02:00
iritmaximus f9b1683816 can now connect to external postgres database 2023-05-18 00:16:27 +03:00
iritmaximus fbd97abb19 updated both docker files to work on self hosting 2023-05-17 21:34:42 +03:00
iritmaximus 4df72b9288 fix: added clickable routes to all single game/course/cup urls 2023-03-05 23:37:09 +02:00
iritmaximus 32f07af85c fix: updated readme more 2023-03-05 23:28:55 +02:00
iritmaximus b6a66b5be5 fix: updated readme 2023-03-05 23:23:21 +02:00
iritmaximus f835b6b197 fix: removed harmful routes and added "info route" for /api 2023-03-05 23:09:15 +02:00
iritmaximus 8ccaaa1438 fix: added time format validation 2023-03-05 15:18:37 +02:00
iritmaximus a88ac13dd3 fix: added session closes 2023-03-05 14:54:20 +02:00
iritmaximus 9c8ce39d04 fix: count of cup_id 2023-03-05 14:39:17 +02:00
iritmaximus 45c84cc0d9 fix: game_id count 2023-03-05 14:18:28 +02:00
iritmaximus 8ed709a5e7 fix: games count 2023-03-05 14:15:06 +02:00
iritmaximus 97cd806b88 fix: count of cups 2023-03-05 14:10:18 +02:00
23 changed files with 240 additions and 122 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Build and deploy containers
on:
release:
types: [published]
push:
branches:
- main
env:
IMAGE_NAME: ${{ github.event_name == 'release' && 'timetrials' || 'timetrials-staging' }}
jobs:
build_timetrials:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract Docker image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: "${{ vars.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}"
# images: "${{ vars.DOCKER_USERNAME }}/${IMAGE_NAME}"
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: "{{defaultContext}}"
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
annotations: ${{ steps.meta.outputs.annotations }}
-40
View File
@@ -1,40 +0,0 @@
name: Deploy to fly.io and create version
on:
push:
branches:
- main
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_V: true
DEFAULT_BUMP: patch
MAJOR_STRING_TOKEN: "release:"
MINOR_STRING_TOKEN: "feat:"
PATCH_STRING_TOKEN: "fix:"
NONE_STRING_TOKEN: "none:"
permissions:
contents: write
jobs:
bump-version:
runs-on: ubuntu-latest
needs: [deploy]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: '0'
- name: Bump version and push tag
uses: anothrNick/github-tag-action@1.61.0
deploy:
name: Deploy app
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v3
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
+21 -6
View File
@@ -1,11 +1,26 @@
# syntax=docker/dockerfile:1
FROM python:alpine
WORKDIR /app
FROM python:3.10-alpine
RUN pip install --upgrade pip
COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
RUN adduser -D nonroot
RUN mkdir /home/app/ && chown -R nonroot:nonroot /home/app
WORKDIR /home/app
USER nonroot
COPY . .
COPY --chown=nonroot:nonroot requirements.txt requirements.txt
CMD [ "gunicorn", "-w", "4", "flaskr:app", "-b", "0.0.0.0:5000", "--access-logfile", "-", "--error-logfile", "-"]
# venv
ENV VIRTUAL_ENV=/home/app/venv
# python setup
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN export FLASK_APP=__init__.py
RUN pip install -r requirements.txt
COPY --chown=nonroot:nonroot flaskr flaskr
ENTRYPOINT ["gunicorn"]
CMD [ "-w", "4", "flaskr:app", "-b", "0.0.0.0:5000", "--access-logfile", "-", "--error-logfile", "-"]
# CMD ["python", "run.py"]
# CMD [ "gunicorn", "-w", "4", "flaskr:app", "-b", "0.0.0.0:5000"]
+53 -12
View File
@@ -16,13 +16,14 @@ The database will be PostgreSQL-based database hosted on fly.io.
* games
* cups
* courses
* standards
* times
### Fancier db things
* authenticated user can add times and edit old ones TODO
* other boring user/account things, can login
* can sort times by game, track, time and other things TODO
* user can log in and create new times.
* can see times by game, cup and course.
* the site tells how many times there are for each category.
* can click on individual courses, cups or games to see all the times in the selected category
### Välipalautus 3
Most of the fancier db things are todo. This is sort of the MVP (minimun viable product).
@@ -30,16 +31,13 @@ Most of the fancier db things are todo. This is sort of the MVP (minimun viable
## Deployment
The site is being deployed at https://tsoha-flask.fly.dev/
~~The site is being deployed at https://tsoha-flask.fly.dev/~~
## Routes
### GET https://tsoha-flask.fly.dev/
Works as the main page, can navigate to other sites.
### GET [db](https://tsoha-flask.fly.dev/db)
Just to test if the db works (at the moment)
### GET [games](https://tsoha-flask.fly.dev/games)
List of games.
@@ -47,7 +45,7 @@ List of games.
List of cups
### GET [courses](https://tsoha-flask.fly.dev/courses)
TODO list of all courses.
List of all courses.
### GET [login](https://tsoha-flask.fly.dev/login)
Login page to the site. You will be redirected here, if you try to access other pages without a session (login in).
@@ -58,8 +56,51 @@ List of all times submitted by the players. There is also a button to add new ti
### GET [create/time](https://tsoha-flask.fly.dev/create/time)
A page where you can submit new times, you have to be logged in for the submission to go through.
### GET [api/(game/cup/course)/id](https://tsoha-flask.fly.dev/api/)
You will be redirected to some of these from clicking any of the corresponding fields in the times.
## Note
If you get an error ´sqlalchemy.exc.NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:postgres´,
change your url from ´postgres://´ to ´postgresql://´.
If you get an error `sqlalchemy.exc.NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:postgres`,
change your url from `postgres://` to `postgresql://`.
## Local dev
### Requirements
If you want to run this locally, you need
* Virtual environment
* Proper .env file
* Docker and docker-compose installed (optional)
### Setting up
* Venv (virtual environment) can be achieved through running `python -m venv .venv` in the `timetrials` directory.
This will create the venv file to `timetrials/.venv` (a hidden file).
* `.env` needs to contain `POSTGRES_URL` and `SECRET_KEY`
- The `POSTGRES_URL` needs to be the full url to your postgres database, containing user, password and database
- `SECRET_KEY` is some random string that you need to create. One simple way is to launch the python interactive prompt with
```bash
python
```
and then run the commands
```python
>>> import secrets
>>> secrets.token_hex(16)
```
and copy the result to the `SECRET_KEY` variable.
After that you can either build the app by running
```bash
docker-compose up -d
```
If you don't have or don't want to use docker, you can activate the venv by calling
```bash
source .venv/bin/activate
```
(google the proper way to do it if you are on Windows).
Then install the requirements with pip
```bash
pip install -r requirements.txt
```
and then you can just run the command
```bash
python run.py
```
+38
View File
@@ -0,0 +1,38 @@
services:
flask:
build:
context: .
dockerfile: Dockerfile
# command: sh -c "gunicorn -w 4 flaskr:app -b 0.0.0.0:5000 --access-logfile '-' --error-logfile '-'"
# command: sh -c "ls"
ports:
- "5000:5000"
volumes:
- .:/app
env_file: .env
environment:
- POSTGRES_DB
- POSTGRES_PORT
- POSTGRES_HOST
- POSTGRES_USER
- POSTGRES_PASSWORD
# extra_hosts:
# - "host.docker.internal:host-gateway"
depends_on:
- db
db:
image: postgres:16-alpine
volumes:
- ./data/:/var/lib/postgresql/data
environment:
- POSTGRES_DB=timetrials-db
- POSTGRES_PORT=5432
- POSTGRES_HOST=db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
healthcheck:
test: pg_isready -U postgres -d timetrials-db
interval: 10s
timeout: 3s
retries: 3
-12
View File
@@ -1,12 +0,0 @@
services:
flask:
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:5000"
volumes:
- .:/app
env_file: .env
environment:
- POSTGRES_URL
+17
View File
@@ -7,6 +7,23 @@ load_dotenv()
app = Flask(__name__)
app.config["SECRET_KEY"] = getenv("SECRET_KEY")
if getenv("POSTGRES_URL") and getenv("POSTGRES_URL").startswith("postgresql://"):
app.config["SQLALCHEMY_DATABASE_URI"] = getenv("POSTGRES_URL")
else:
postgres_host = getenv("POSTGRES_HOST")
postgres_db = getenv("POSTGRES_DB")
postgres_port = getenv("POSTGRES_PORT")
postgres_user = getenv("POSTGRES_USER")
postgres_password = getenv("POSTGRES_PASSWORD")
postgres_variables = [postgres_host, postgres_db, postgres_port, postgres_user, postgres_password]
for var in postgres_variables:
if not var:
raise ValueError("All environment variables not set")
database_url = f"postgresql://{postgres_user}:{postgres_password}@{postgres_host}:{postgres_port}/{postgres_db}"
app.config["SQLALCHEMY_DATABASE_URI"] = database_url
# import flaskr.views
import flaskr.views.home
+3 -2
View File
@@ -4,10 +4,8 @@ from flask_sqlalchemy import SQLAlchemy
from random import randint
app.config["SQLALCHEMY_DATABASE_URI"] = getenv("POSTGRES_URL")
db = SQLAlchemy(app)
def create_db():
print("Initializing the db...")
db.session.execute(open("sql/schema.sql", "r").read())
@@ -15,6 +13,7 @@ def create_db():
db.session.execute(open("sql/cups.sql", "r").read())
db.session.execute(open("sql/courses.sql", "r").read())
db.session.commit()
db.session.close()
print("Db initialization completed.")
return "Success"
@@ -29,6 +28,7 @@ def reset_db():
db.session.execute(open("sql/cups.sql", "r").read())
db.session.execute(open("sql/courses.sql", "r").read())
db.session.commit()
db.session.close()
def test_db():
@@ -49,6 +49,7 @@ def test_db():
db.session.commit()
result = db.session.execute("SELECT * FROM test;").fetchall()
db.session.close()
print(result)
return "Working lol"
+2 -2
View File
@@ -1,11 +1,11 @@
<h1>Add time</h1>
<form action="/create/time" method="POST">
<form name="addNewTime" action="/create/time" method="POST">
<label for="game">Game:</label>
<input type="text" name="game" required>
<label for="course">Course:</label>
<input type="text" name="course" required>
<label for="time">Time (mm:ss:mss):</label>
<input type="text" name="time" required>
<input type="text" name="time" required pattern="/\d{0,2}:[0-5]\d:\d\d\/">
<input type="submit" name="time">
</form>
+2 -2
View File
@@ -18,8 +18,8 @@ table, tr, td {
</tr>
{% for time in times %}
<tr>
<td>{{time[0]}}</td>
<td>{{time[1]}}</td>
<td><a href="/api/game/{{time[0].replace(' ', '%20')}}">{{time[0]}}</a></td>
<td><a href="/api/cup/{{time[1].replace(' ', '%20')}}">{{time[1]}}</a></td>
<td>{{time[2]}}</td>
<td>{{time[3]}}</td>
</tr>
+7 -6
View File
@@ -8,22 +8,23 @@ table, tr, td {
}
</style>
<p>There is {{count}} times in {{cup_name}}</p>
<h2>Times</h2>
<table>
<tr>
<td>Time</td>
<td>Course</td>
<td>Cup</td>
<td>Game</td>
<td>Time</td>
<td>User</td>
</tr>
{% if times_exist %}
{% for time in times %}
<tr>
<td>{{time[0]}}</td>
<td><a href="/api/course/{{item[1].replace(' ', '%20')}}">{{time[1]}}</a></td>
<td><a href="/api/cup/{{item[2].replace(' ', '%20')}}">{{time[2]}}</a></td>
<td><a href="/api/game/{{item[3].replace(' ', '%20')}}">{{time[3]}}</a></td>
<td><a href="/api/course/{{time[0].replace(' ', '%20')}}">{{time[0]}}</a></td>
<td><a href="/api/game/{{time[1].replace(' ', '%20')}}">{{time[1]}}</a></td>
<td>{{time[2]}}</td>
<td>{{time[3]}}</td>
</tr>
{% endfor %}
{% endif %}
+2
View File
@@ -8,6 +8,8 @@ table, tr, td {
}
</style>
<p>There is {{count}} cups</p>
<table>
{% if cups_exist %}
<tr>
+4 -2
View File
@@ -8,6 +8,8 @@ table, tr, td {
}
</style>
<p>There is {{count}} times in {{game_name}}</p>
<h2>Times</h2>
<table>
<tr>
@@ -19,8 +21,8 @@ table, tr, td {
{% if times_exist %}
{% for time in times %}
<tr>
<td>{{time[0]}}</td>
<td>{{time[1]}}</td>
<td><a href="/api/course/{{time[0].replace(' ', '%20')}}">{{time[0]}}</a></td>
<td><a href="/api/cup/{{time[1].replace(' ', '%20')}}">{{time[1]}}</a></td>
<td>{{time[2]}}</td>
<td>{{time[3]}}</td>
</tr>
+2
View File
@@ -8,6 +8,8 @@ table, tr, td {
}
</style>
<p>There is {{count}} games.</p>
<table>
{% if games_exist %}
<tr>
+4 -4
View File
@@ -14,17 +14,17 @@ table, tr, td {
<table style="border:1px solid black">
{% if times_exist %}
<tr>
<td>Course</td>
<td>Game</td>
<td>Course</td>
<td>Time</td>
<td>User</td>
</tr>
{% for item in times %}
<tr>
<td>{{item[0]}}</td>
<td>{{item[1]}}</td>
<td><a href="/api/game/{{item[0].replace(' ', '%20')}}">{{item[0]}}</a></td>
<td><a href="/api/course/{{item[1].replace(' ', '%20')}}">{{item[1]}}</a></td>
<td>{{item[2]}}</td>
<td>{{item[3]}}</td>
<td>{{item[3]}}</td>
</tr>
{% endfor %}
{% endif %}
+2
View File
@@ -10,6 +10,7 @@ def query_courses():
result = db.session.execute(sql).fetchall()
sql = "SELECT count(courses.id) FROM courses"
count = db.session.execute(sql).fetchone()
db.session.close()
return result, count[0]
@@ -23,4 +24,5 @@ def query_course_by_name(course_name: str):
WHERE times.course_id in (SELECT id FROM courses WHERE name=:course_name)
"""
result = db.session.execute(sql, {"course_name": course_name}).fetchall()
db.session.close()
return result
+11 -4
View File
@@ -4,7 +4,10 @@ from flaskr.database import db
def query_cups():
sql = "SELECT name, id FROM cups"
result = db.session.execute(sql).fetchall()
return result
sql = "SELECT count(cups.id) FROM cups"
count = db.session.execute(sql).fetchone()
db.session.close()
return result, count[0]
def query_cup_by_name(cup_name: str):
@@ -12,6 +15,7 @@ def query_cup_by_name(cup_name: str):
SELECT cups.id FROM cups WHERE cups.name=:cup_name
"""
result = db.session.execute(sql, {"cup_name": cup_name}).fetchone()
db.session.close()
if result:
return result[0]
else:
@@ -30,13 +34,14 @@ def get_cup_name(id: int):
def get_cup_id(course_id: int):
sql = "SELECT cups_id FROM courses WHERE id=:course_id"
result = db.session.execute(sql, {"course_id": course_id}).fetchall()
db.session.close()
if result:
return result[0]
else:
raise ValueError("No cup found for current course")
def get_all_times_in_cup(id: int) -> list[str]:
def get_all_times_in_cup(id: int):
"""Queries the database for all times made in the wanted cup"""
sql = """
@@ -47,6 +52,8 @@ def get_all_times_in_cup(id: int) -> list[str]:
JOIN users ON times.user_id=users.id
WHERE times.cup_id=:id
"""
result = db.session.execute(sql, {"id": id}).fetchall()
return result
sql = "SELECT count(times.id) FROM times WHERE times.cup_id=:id"
count = db.session.execute(sql, {"id": id}).fetchone()
db.session.close()
return result, count[0]
+10 -2
View File
@@ -4,12 +4,16 @@ from flaskr.database import db
def query_games():
sql = "SELECT name, id FROM games"
result = db.session.execute(sql).fetchall()
return result
sql = "SELECT count(games.id) FROM games"
count = db.session.execute(sql).fetchone()
db.session.close()
return result, count[0]
def query_game_by_name(game_name: str):
sql = "SELECT id FROM games WHERE games.name=:game_name"
result = db.session.execute(sql, {"game_name": game_name}).fetchone()
db.session.close()
if result:
return result[0]
else:
@@ -19,6 +23,7 @@ def query_game_by_name(game_name: str):
def get_game_name(id: int):
sql = f"SELECT name FROM games WHERE id=:id"
result = db.session.execute(sql, {"id": id}).fetchone()
db.session.close()
if result:
return result[0]
else:
@@ -35,4 +40,7 @@ def get_all_times_in_game(id: int):
WHERE times.game_id=:id
"""
results = db.session.execute(sql, {"id": id}).fetchall()
return results
sql = "SELECT count(times.id) FROM times WHERE times.game_id=:id"
count = db.session.execute(sql, {"id": id}).fetchone()
db.session.close()
return results, count[0]
+2
View File
@@ -4,6 +4,7 @@ from flaskr.database import db
def query_times():
sql = "SELECT games.name, courses.name, times.timems, users.username FROM times JOIN games ON games.id=times.game_id JOIN courses ON courses.id=times.course_id JOIN users ON users.id=times.user_id"
result = db.session.execute(sql).fetchall()
db.session.close()
print(result)
return result
@@ -49,3 +50,4 @@ def add_time(game, course, time, user):
},
)
db.session.commit()
db.session.close()
+3
View File
@@ -25,6 +25,7 @@ def user_login(name: str, password: str) -> bool:
print("checking if username exists")
sql = "SELECT password FROM users as u where u.username=:username"
result = db.session.execute(sql, {"username": name}).fetchone()
db.session.close()
print("password:", result)
if result is None:
return False
@@ -40,6 +41,7 @@ def user_login(name: str, password: str) -> bool:
def check_username_exists(username: str) -> bool:
sql = "SELECT COUNT(*) FROM users as u WHERE u.username=:username"
result = db.session.execute(sql, {"username": username}).fetchone()
db.session.close()
print("result:", result)
if result is not None:
if result[0] == 0:
@@ -61,6 +63,7 @@ def create_new_user(name: str, text_password: str):
sql = "INSERT INTO users (username, password) VALUES (:username, :password)"
db.session.execute(sql, {"username": username, "password": password})
db.session.commit()
db.session.close()
print("success")
+8 -7
View File
@@ -10,10 +10,10 @@ from flaskr.utils.cups import (
@app.route("/cups")
def get_cups():
cups = query_cups()
cups, count = query_cups()
if cups is None:
return render_template("cups.html", cups_exist=False)
return render_template("cups.html", cups=cups, cups_exist=True)
return render_template("cups.html", cups_exist=False, count=count)
return render_template("cups.html", cups=cups, cups_exist=True, count=count)
@app.route("/cups/<int:cup_id>")
@@ -24,14 +24,15 @@ def get_cup_by_id(cup_id):
cup_name = get_cup_name(cup_id)
except ValueError as e:
return render_template("error.html", message=f"Incorrect id, {e}")
times = get_all_times_in_cup(cup_id)
print(times)
times, count = get_all_times_in_cup(cup_id)
if times:
return render_template(
"cup_id.html", cup_name=cup_name, times=times, times_exist=True
"cup_id.html", cup_name=cup_name, times=times, times_exist=True, count=count
)
else:
return render_template("cup_id.html", times_exist=False)
return render_template(
"cup_id.html", times_exist=False, count=count, cup_name=cup_name
)
@app.route("/api/cup/<string:cup_name>")
+5 -5
View File
@@ -10,10 +10,10 @@ from flaskr.utils.games import (
@app.route("/games")
def get_games():
games = query_games()
games, count = query_games()
if games is None:
return render_template("games.html", games_exist=False)
return render_template("games.html", games=games, games_exist=True)
return render_template("games.html", games_exist=False, count=count)
return render_template("games.html", games=games, games_exist=True, count=count)
@app.route("/games/<int:game_id>")
@@ -25,9 +25,9 @@ def get_game_by_id(game_id):
return render_template(
"error.html", message="Incorrect id, id not found or incorrect value"
)
times = get_all_times_in_game(game_id)
times, count = get_all_times_in_game(game_id)
return render_template(
"game_id.html", times_exist=True, game_name=game_name, times=times
"game_id.html", times_exist=True, game_name=game_name, times=times, count=count
)
+3 -16
View File
@@ -1,7 +1,6 @@
from flask import render_template, redirect, url_for
from flaskr import app
from flaskr.database import test_db, create_db, reset_db
from flaskr.utils.user import check_session
@@ -12,18 +11,6 @@ def navigate():
return render_template("home.html")
# TODO remove these when ready
@app.route("/db/init")
def initialize_db():
return create_db()
@app.route("/db/reset")
def reset_database():
reset_db()
return "Initialization successful"
@app.route("/db")
def test_db_route():
return test_db()
@app.route("/api")
def api_help():
return "This is the api route. You will end up here from clicking a course, game or cup part of any time."