Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d7988509cf
|
|||
|
d96066e625
|
|||
|
00f84fee9b
|
|||
|
2b43e9a848
|
|||
|
bf6d20ca58
|
|||
|
0a52c4da7b
|
|||
|
c10b7a8013
|
|||
|
103c29e234
|
|||
|
5003b739d3
|
|||
|
4ba3ed555f
|
@@ -9,7 +9,6 @@ static
|
|||||||
.drone.yml
|
.drone.yml
|
||||||
.editorconfig
|
.editorconfig
|
||||||
.gitignore
|
.gitignore
|
||||||
Caddyfile
|
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
docker-compose*
|
docker-compose*
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Docker registry URL (used in docker-compose.yml)
|
||||||
|
REGISTRY_URL=registry.kucharczyk.xyz
|
||||||
|
|
||||||
|
# Container timezone
|
||||||
|
TZ=Europe/Prague
|
||||||
|
|
||||||
|
# User/group IDs for container (used in entrypoint.sh)
|
||||||
|
PUID=1000
|
||||||
|
PGID=100
|
||||||
|
|
||||||
|
# External port mapping
|
||||||
|
TIMETRACKER_EXTERNAL_PORT=8000
|
||||||
|
|
||||||
|
# Django production mode (set to "1" for production)
|
||||||
|
PROD=1
|
||||||
|
|
||||||
|
# Database directory (defaults to project root)
|
||||||
|
DATA_DIR=/home/timetracker/app/data
|
||||||
|
|
||||||
|
# CSRF trusted origins
|
||||||
|
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||||
@@ -22,8 +22,8 @@ jobs:
|
|||||||
- name: Run Migrations
|
- name: Run Migrations
|
||||||
run: uv run python manage.py migrate
|
run: uv run python manage.py migrate
|
||||||
|
|
||||||
# - name: Run Tests
|
- name: Run Tests
|
||||||
# run: PROD=1 uv run pytest
|
run: uv run --with pytest-django pytest
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set Version
|
- name: Set Version
|
||||||
run: echo "VERSION_NUMBER=1.6.1" >> $GITHUB_ENV
|
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ __pycache__
|
|||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
data/
|
||||||
/static/
|
/static/
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+40
-3
@@ -1,7 +1,44 @@
|
|||||||
## Unreleased
|
## 1.7.0 / 2026-05-12
|
||||||
|
|
||||||
|
### New
|
||||||
|
* Add toast notification system with HTMX middleware integration
|
||||||
|
* Add component system (Cotton-based): button, modal, table_row, search_field, gamelink
|
||||||
|
* Add needs_price_update field to Purchase model for reliable price change detection
|
||||||
|
* Add confirmation dialog before deleting a game
|
||||||
|
* Add game status information documentation (STATUSES.md)
|
||||||
|
* Allow directly updating device in session list via inline selector
|
||||||
|
* Migrate from Poetry to uv for Python dependency management
|
||||||
|
* Scope URLs to the games namespace
|
||||||
|
* Start session template shared between add and edit views
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
* Add a prompt to set game to Abandoned upon refund
|
* Major style overhaul: CSS variables, improved dark mode, Flowbite 4.x upgrade
|
||||||
|
* Improve game status evaluation and add abandon prompt on refund
|
||||||
|
* Robustify Docker container and fix default database location
|
||||||
|
* Make component rendering deterministic for improved caching
|
||||||
|
* Component caching: deterministic randomid generation
|
||||||
|
* Component test suite with 1000+ lines of tests
|
||||||
|
* Make tests more robust with django-pytest
|
||||||
|
* Update NameWithIcon component: testable, fixed platform extraction bug
|
||||||
|
* Pin Caddy version and improve make dev-prod
|
||||||
|
* Add .env.example documenting environment variables
|
||||||
|
* Unify A() component with explicit url_name vs href parameters
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fix refund confirmation not working
|
||||||
|
* Fix stats view missing first and last game values
|
||||||
|
* Fix A() component silent fallback on URL typos
|
||||||
|
* Fix secondary submit buttons not working
|
||||||
|
* Fix button not passing attributes
|
||||||
|
* Fix default mutable arguments in component functions
|
||||||
|
* Fix extra submit button when adding purchase
|
||||||
|
* Fix pointer cursor on search field button
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* Remove GraphQL API
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
* Update django-ninja to 1.6.2
|
||||||
|
|
||||||
## 1.6.1 / 2026-01-30 11:48+01:00
|
## 1.6.1 / 2026-01-30 11:48+01:00
|
||||||
|
|
||||||
@@ -161,7 +198,7 @@
|
|||||||
* Use the same form when editing a session as when adding a session
|
* Use the same form when editing a session as when adding a session
|
||||||
* Change recent session view to current year instead of last 30 days
|
* Change recent session view to current year instead of last 30 days
|
||||||
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
|
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
|
||||||
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
* Improve session listing (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
auto_https off
|
auto_https off
|
||||||
admin off
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:8000 {
|
:8000 {
|
||||||
handle_path /static/* {
|
handle_path /static/* {
|
||||||
root * /usr/share/caddy
|
root * /home/timetracker/app/static
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
handle {
|
handle /robots.txt {
|
||||||
reverse_proxy backend:8001
|
root * /home/timetracker/app/games/static
|
||||||
}
|
file_server
|
||||||
|
}
|
||||||
|
reverse_proxy localhost:8001
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
}
|
||||||
|
|
||||||
|
:8000 {
|
||||||
|
handle_path /static/* {
|
||||||
|
root * static
|
||||||
|
file_server browse
|
||||||
|
}
|
||||||
|
handle /robots.txt {
|
||||||
|
root * games/static
|
||||||
|
file_server browse
|
||||||
|
}
|
||||||
|
reverse_proxy :8001
|
||||||
|
}
|
||||||
+21
-7
@@ -22,20 +22,34 @@ ENV PROD=1 \
|
|||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PATH="/home/timetracker/app/.venv/bin:$PATH"
|
PATH="/home/timetracker/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
RUN useradd -m --uid 1000 timetracker \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
&& mkdir -p /var/www/django/static \
|
curl \
|
||||||
&& chown timetracker:timetracker /var/www/django/static
|
ca-certificates \
|
||||||
|
libcap2-bin \
|
||||||
|
supervisor \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd -m --uid 1000 timetracker \
|
||||||
|
&& mkdir -p /var/log/supervisor /etc/supervisor/conf.d /home/timetracker/data \
|
||||||
|
&& chown timetracker:timetracker /var/log/supervisor /home/timetracker/data
|
||||||
|
|
||||||
|
ARG CADDY_VERSION=2.9.1
|
||||||
|
RUN curl -sL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \
|
||||||
|
-o /tmp/caddy.tar.gz && \
|
||||||
|
tar -xzf /tmp/caddy.tar.gz -C /tmp && \
|
||||||
|
mv /tmp/caddy /usr/local/bin/caddy && \
|
||||||
|
rm /tmp/caddy.tar.gz && \
|
||||||
|
chmod +x /usr/local/bin/caddy
|
||||||
|
|
||||||
WORKDIR /home/timetracker/app
|
WORKDIR /home/timetracker/app
|
||||||
|
|
||||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||||
|
|
||||||
|
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||||
|
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
USER timetracker
|
ENV VERSION_NUMBER=1.7.0
|
||||||
|
|
||||||
ENV VERSION_NUMBER=1.6.1
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD [ "/entrypoint.sh" ]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ caddy:
|
|||||||
caddy run --watch
|
caddy run --watch
|
||||||
|
|
||||||
dev-prod: migrate collectstatic
|
dev-prod: migrate collectstatic
|
||||||
PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
|
@npx concurrently \
|
||||||
|
--names "Caddy,Django,Django-Q" \
|
||||||
|
"caddy run --config Caddyfile.dev" \
|
||||||
|
"PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker" \
|
||||||
|
"PROD=1 uv run manage.py qcluster"
|
||||||
|
|
||||||
dumpgames:
|
dumpgames:
|
||||||
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
# Game & Purchase Status Definitions
|
||||||
|
|
||||||
|
## Game Statuses
|
||||||
|
|
||||||
|
Games have a `status` field with the following values:
|
||||||
|
|
||||||
|
| Status | Code | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| **Unplayed** | `u` | Game was purchased but never played |
|
||||||
|
| **Played** | `p` | Game was played but not yet finished |
|
||||||
|
| **Finished** | `f` | Game has been completed |
|
||||||
|
| **Retired** | `r` | Game was intentionally retired (e.g., no longer accessible, collector's item) |
|
||||||
|
| **Abandoned** | `a` | Game was played but the user gave up on it |
|
||||||
|
|
||||||
|
**Setting game status:**
|
||||||
|
- Users explicitly set game status via the UI (finish/drop purchase buttons, status change form)
|
||||||
|
- Status changes are tracked in `GameStatusChange` model
|
||||||
|
- Refunding a purchase always marks its games as abandoned
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purchase-Level Status Concepts
|
||||||
|
|
||||||
|
These concepts determine whether a purchase appears in the "unfinished" or "dropped" lists in stats views.
|
||||||
|
|
||||||
|
### Finished
|
||||||
|
|
||||||
|
A purchase is considered **finished** when:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game.status == "f" OR Purchase.games.* has a PlayEvent with an ended date
|
||||||
|
```
|
||||||
|
|
||||||
|
Either signal indicates the game is complete:
|
||||||
|
- **Explicit**: User marked the game as finished (`Game.status = "f"`)
|
||||||
|
- **Implicit**: A PlayEvent exists with `ended` date set (data-driven)
|
||||||
|
|
||||||
|
This uses **OR** logic during a transition period. Later, these signals should be kept in sync so only one source of truth is needed.
|
||||||
|
|
||||||
|
### Dropped
|
||||||
|
|
||||||
|
A purchase is considered **dropped** when:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game.status == "a" OR Purchase.date_refunded IS NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
Either signal indicates the user no longer has an active interest in the game:
|
||||||
|
- **Explicit**: User marked the game as abandoned (`Game.status = "a"`)
|
||||||
|
- **Implicit**: User refunded the purchase (which automatically sets games to abandoned)
|
||||||
|
|
||||||
|
Note: Refunding a purchase always marks its games as abandoned. There is no option to refund without abandoning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unfinished vs. Dropped
|
||||||
|
|
||||||
|
The stats views categorize purchases into **unfinished** and **dropped** lists.
|
||||||
|
|
||||||
|
### Unfinished
|
||||||
|
|
||||||
|
A purchase is **unfinished** when:
|
||||||
|
1. It was purchased in the relevant time period (this year for yearly stats, all time for all-time stats)
|
||||||
|
2. It was NOT refunded (only counts toward unfinished/backlog)
|
||||||
|
3. It is NOT finished (per the finished definition above)
|
||||||
|
4. It is NOT dropped (per the dropped definition above)
|
||||||
|
5. It is NOT infinite (subscription, etc.)
|
||||||
|
6. It IS a game or DLC (not season passes or battle passes)
|
||||||
|
|
||||||
|
**Unfinished = Active backlog** — games the user may still play.
|
||||||
|
|
||||||
|
### Dropped
|
||||||
|
|
||||||
|
A purchase is **dropped** when:
|
||||||
|
1. It was purchased in the relevant time period
|
||||||
|
2. It is NOT finished (per the finished definition above)
|
||||||
|
3. It matches at least one dropped signal (per the dropped definition above)
|
||||||
|
4. It is NOT infinite
|
||||||
|
5. It IS a game or DLC
|
||||||
|
|
||||||
|
**Dropped = Terminal state** — games the user has given up on or refunded.
|
||||||
|
|
||||||
|
### Summary Table
|
||||||
|
|
||||||
|
| Category | Includes Refunded? | Key Condition |
|
||||||
|
|----------|-------------------|---------------|
|
||||||
|
| **Unfinished** | No | NOT finished, NOT dropped |
|
||||||
|
| **Dropped** | Yes | Finished OR Abandoned/Retired |
|
||||||
|
| **Refunded** | Yes | `date_refunded IS NOT NULL` |
|
||||||
|
| **Infinite** | Yes | `infinite = True` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query Patterns
|
||||||
|
|
||||||
|
### Checking if a game is finished
|
||||||
|
|
||||||
|
```python
|
||||||
|
game.finished() # Returns True if status="f" or has PlayEvent with ended date
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking if a game is abandoned
|
||||||
|
|
||||||
|
```python
|
||||||
|
game.abandoned() # Returns True if status="a"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting finished purchases
|
||||||
|
|
||||||
|
```python
|
||||||
|
Purchase.objects.finished() # All purchases where games are finished
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting dropped purchases
|
||||||
|
|
||||||
|
```python
|
||||||
|
Purchase.objects.dropped() # All purchases that are abandoned or refunded
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transition State
|
||||||
|
|
||||||
|
The system uses **OR logic** for both finished and dropped to catch any mismatch between explicit user actions and data signals:
|
||||||
|
|
||||||
|
- **Finished**: `status="f" OR PlayEvent.ended`
|
||||||
|
- **Dropped**: `status="a" OR date_refunded`
|
||||||
|
|
||||||
|
This bridges the gap between the old model (where `date_finished` and `date_dropped` were on the Purchase model) and the new model (where `Game.status` and `PlayEvent` are the sources of truth).
|
||||||
|
|
||||||
|
**Future:** These signals should be kept in sync. For example:
|
||||||
|
- Setting `Game.status = "f"` should create a PlayEvent with `ended` date
|
||||||
|
- When the sync is reliable, the OR can be simplified to a single check
|
||||||
|
|
||||||
|
Note: Refunding a purchase always automatically sets its games' status to Abandoned. This is not optional — there is no way to refund without abandoning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Unplayed games
|
||||||
|
- Unplayed games (`status="u"`) are considered **unfinished**, not dropped
|
||||||
|
- They appear in the unfinished/backlog list since they are still games the user may play
|
||||||
|
- Unplayed games that are refunded DO count as **dropped** (refund signal overrides)
|
||||||
|
|
||||||
|
### Multiple games per purchase
|
||||||
|
- A purchase can have multiple games via `Purchase.games` (many-to-many)
|
||||||
|
- A purchase is finished if ANY of its games is finished
|
||||||
|
- A purchase is dropped if ANY of its games is abandoned OR the purchase itself is refunded
|
||||||
|
|
||||||
|
### PlayEvents without ended date
|
||||||
|
- A PlayEvent with `started` but no `ended` does NOT count as finished
|
||||||
|
- This represents a game that was started but not completed
|
||||||
|
|
||||||
|
### Retired games
|
||||||
|
- Retired games (`status="r"`) are considered **dropped**
|
||||||
|
- Retirement is for games the user intentionally removed from their collection (collector's items, no longer accessible, etc.)
|
||||||
+12
-21
@@ -1,30 +1,21 @@
|
|||||||
---
|
---
|
||||||
services:
|
services:
|
||||||
backend:
|
timetracker:
|
||||||
image: registry.kucharczyk.xyz/timetracker
|
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
container_name: timetracker
|
||||||
environment:
|
environment:
|
||||||
- TZ=Europe/Prague
|
- TZ=${TZ:-Europe/Prague}
|
||||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||||
user: "1000"
|
- PUID=${PUID:-1000}
|
||||||
|
- PGID=${PGID:-100}
|
||||||
|
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||||
|
ports:
|
||||||
|
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- "static-files:/var/www/django/static"
|
- "./data:/home/timetracker/app/data"
|
||||||
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
|
- "${DOCKER_STORAGE_PATH:-/tmp}/timetracker/backups:/home/timetracker/app/games/fixtures/backups"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
|
||||||
image: caddy
|
|
||||||
volumes:
|
|
||||||
- "static-files:/usr/share/caddy:ro"
|
|
||||||
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
static-files:
|
|
||||||
|
|
||||||
|
|
||||||
+18
-18
@@ -1,23 +1,23 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Apply database migrations
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "Apply database migrations"
|
|
||||||
python manage.py migrate
|
|
||||||
|
|
||||||
echo "Collect static files"
|
PUID=${PUID:-1000}
|
||||||
|
PGID=${PGID:-100}
|
||||||
|
|
||||||
|
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
||||||
|
usermod -d "/root" timetracker
|
||||||
|
groupmod -o -g "$PGID" timetracker
|
||||||
|
usermod -o -u "$PUID" timetracker
|
||||||
|
usermod -d "${USERHOME}" timetracker
|
||||||
|
|
||||||
|
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
||||||
|
chmod 755 /home/timetracker/app
|
||||||
|
chmod 755 /home/timetracker/app/.venv
|
||||||
|
|
||||||
|
chown "$PUID:$PGID" /home/timetracker/app/data
|
||||||
|
chown "$PUID:$PGID" /var/log/supervisor
|
||||||
|
|
||||||
|
python manage.py migrate
|
||||||
python manage.py collectstatic --clear --no-input
|
python manage.py collectstatic --clear --no-input
|
||||||
|
|
||||||
_term() {
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
||||||
echo "Caught SIGTERM signal!"
|
|
||||||
kill -SIGTERM "$gunicorn_pid"
|
|
||||||
kill -SIGTERM "$django_q_pid"
|
|
||||||
}
|
|
||||||
trap _term SIGTERM
|
|
||||||
|
|
||||||
echo "Starting Django-Q cluster"
|
|
||||||
python manage.py qcluster & django_q_pid=$!
|
|
||||||
|
|
||||||
echo "Starting app"
|
|
||||||
python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
|
|
||||||
|
|
||||||
wait "$gunicorn_pid" "$django_q_pid"
|
|
||||||
|
|||||||
+11
-11
@@ -14,14 +14,14 @@ currency_to = currency_to.upper()
|
|||||||
|
|
||||||
|
|
||||||
def _get_exchange_rate(currency_from, currency_to, year):
|
def _get_exchange_rate(currency_from, currency_to, year):
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
|
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
|
||||||
)
|
)
|
||||||
exchange_rate = ExchangeRate.objects.filter(
|
rate = ExchangeRate.objects.filter(
|
||||||
currency_from=currency_from, currency_to=currency_to, year=year
|
currency_from=currency_from, currency_to=currency_to, year=year
|
||||||
).first()
|
).first()
|
||||||
if not exchange_rate:
|
if not rate:
|
||||||
logger.info(
|
logger.debug(
|
||||||
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
|
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -40,16 +40,16 @@ def _get_exchange_rate(currency_from, currency_to, year):
|
|||||||
year=year,
|
year=year,
|
||||||
rate=floatformat(rate, 2),
|
rate=floatformat(rate, 2),
|
||||||
)
|
)
|
||||||
exchange_rate = exchange_rate.rate
|
rate = exchange_rate.rate
|
||||||
else:
|
else:
|
||||||
logger.info("[convert_prices]: Could not get an exchange rate.")
|
logger.info("[convert_prices]: Could not get an exchange rate.")
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
||||||
)
|
)
|
||||||
elif exchange_rate:
|
elif rate:
|
||||||
exchange_rate = exchange_rate.rate
|
rate = rate.rate
|
||||||
return exchange_rate
|
return rate
|
||||||
|
|
||||||
|
|
||||||
def _save_converted_price(purchase, converted_price, needs_update):
|
def _save_converted_price(purchase, converted_price, needs_update):
|
||||||
@@ -78,11 +78,11 @@ def convert_prices():
|
|||||||
continue
|
continue
|
||||||
year = purchase.date_purchased.year
|
year = purchase.date_purchased.year
|
||||||
currency_from = purchase.price_currency.upper()
|
currency_from = purchase.price_currency.upper()
|
||||||
exchange_rate = _get_exchange_rate(currency_from, currency_to, year)
|
rate = _get_exchange_rate(currency_from, currency_to, year)
|
||||||
if exchange_rate:
|
if rate:
|
||||||
_save_converted_price(
|
_save_converted_price(
|
||||||
purchase,
|
purchase,
|
||||||
floatformat(purchase.price * exchange_rate, 0),
|
floatformat(purchase.price * rate, 0),
|
||||||
needs_update,
|
needs_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
-9
@@ -1,6 +1,6 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase
|
||||||
|
|
||||||
from games.models import Game, Platform, Purchase
|
from games.models import Game, Platform, Purchase
|
||||||
from games.tasks import convert_prices
|
from games.tasks import convert_prices
|
||||||
@@ -29,14 +29,7 @@ class PurchaseNeedsPriceUpdateTest(TestCase):
|
|||||||
purchase.games.add(self.game)
|
purchase.games.add(self.game)
|
||||||
self.assertTrue(purchase.needs_price_update)
|
self.assertTrue(purchase.needs_price_update)
|
||||||
|
|
||||||
with override_settings(
|
convert_prices()
|
||||||
CACHES={
|
|
||||||
"default": {
|
|
||||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
):
|
|
||||||
convert_prices()
|
|
||||||
|
|
||||||
purchase.refresh_from_db()
|
purchase.refresh_from_db()
|
||||||
self.assertFalse(purchase.needs_price_update)
|
self.assertFalse(purchase.needs_price_update)
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
first_play_date = "N/A"
|
first_play_date = "N/A"
|
||||||
last_play_date = "N/A"
|
last_play_date = "N/A"
|
||||||
|
first_play_game = None
|
||||||
|
last_play_game = None
|
||||||
if this_year_sessions:
|
if this_year_sessions:
|
||||||
first_session = this_year_sessions.earliest()
|
first_session = this_year_sessions.earliest()
|
||||||
first_play_game = first_session.game
|
first_play_game = first_session.game
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "timetracker"
|
name = "timetracker"
|
||||||
version = "1.6.1"
|
version = "1.7.0"
|
||||||
description = "A simple time tracker."
|
description = "A simple time tracker."
|
||||||
authors = [{ name = "Lukáš Kucharczyk", email = "lukas@kucharczyk.xyz" }]
|
authors = [{ name = "Lukáš Kucharczyk", email = "lukas@kucharczyk.xyz" }]
|
||||||
requires-python = ">=3.13,<4"
|
requires-python = ">=3.13,<4"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/dev/stdout
|
||||||
|
logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:caddy]
|
||||||
|
command=/usr/local/bin/caddy run --config /etc/caddy/Caddyfile
|
||||||
|
directory=/home/timetracker/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
user=timetracker
|
||||||
|
|
||||||
|
[program:gunicorn]
|
||||||
|
command=python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -
|
||||||
|
directory=/home/timetracker/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
process_name=%(program_name)s
|
||||||
|
user=timetracker
|
||||||
|
|
||||||
|
[program:qcluster]
|
||||||
|
command=python manage.py qcluster
|
||||||
|
directory=/home/timetracker/app
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
process_name=%(program_name)s
|
||||||
|
user=timetracker
|
||||||
@@ -4,7 +4,7 @@ Django settings for timetracker project.
|
|||||||
Generated by 'django-admin startproject' using Django 4.1.4.
|
Generated by 'django-admin startproject' using Django 4.1.4.
|
||||||
|
|
||||||
For more information on this file, see
|
For more information on this file, see
|
||||||
https://docs.djangoproject.com/en/4.1/topics/settings/
|
https://docs.djangoproject.com/en/4.1/topics/deployment/checklist/
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
For the full list of settings and their values, see
|
||||||
https://docs.djangoproject.com/en/4.1/ref/settings/
|
https://docs.djangoproject.com/en/4.1/ref/settings/
|
||||||
@@ -110,7 +110,7 @@ WSGI_APPLICATION = "timetracker.wsgi.application"
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
"NAME": Path(os.environ.get("DATA_DIR", str(BASE_DIR))) / "db.sqlite3",
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"timeout": 20,
|
"timeout": 20,
|
||||||
"init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;",
|
"init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;",
|
||||||
@@ -154,7 +154,7 @@ USE_TZ = True
|
|||||||
# https://docs.djangoproject.com/en/4.1/howto/static-files/
|
# https://docs.djangoproject.com/en/4.1/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = "static/"
|
STATIC_URL = "static/"
|
||||||
STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static"
|
STATIC_ROOT = BASE_DIR / "static"
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
|
||||||
|
|||||||
Reference in New Issue
Block a user