15 Commits

Author SHA1 Message Date
345315db38 Start converting to react-router
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 17:50:15 +01:00
d9076e4367 Add apiService 2023-01-25 17:49:02 +01:00
65e556656f Fix Vite proxy config 2023-01-25 17:48:51 +01:00
22fd1c356a Add tailwind typography and forms 2023-01-25 17:48:33 +01:00
9080425e1e Add django-cors-headers 2023-01-25 17:48:17 +01:00
c64c68791f Add eslint and prettier 2023-01-25 13:31:05 +01:00
f6edc8688a Remove cruft 2023-01-25 13:19:50 +01:00
1c73bba301 Remove top-level package.json 2023-01-25 13:07:45 +01:00
da5783c08f Add Nav component 2023-01-25 12:56:32 +01:00
04e6b01c2a Integrate TailwindCSS 2023-01-25 12:47:39 +01:00
3a14ea98a6 Change API url 2023-01-25 12:17:56 +01:00
ba27511b97 Add django-rest-framework 2023-01-25 12:05:37 +01:00
ae95015f55 CI: autotag
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 11:49:22 +01:00
c217fd30ef Add vite proxy config 2023-01-25 11:48:17 +01:00
ec16d40361 Add vite react
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-25 11:44:04 +01:00
99 changed files with 1503 additions and 3491 deletions

View File

@ -10,47 +10,29 @@ steps:
- python -m pip install poetry - python -m pip install poetry
- poetry install - poetry install
- poetry env info - poetry env info
- poetry run python manage.py migrate
- poetry run pytest - poetry run pytest
- name: build container (prod)
- name: build-prod
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
tags: tags:
- latest - latest
- 1.1.0
depends_on:
- "test"
when: when:
branch: branch:
- main - main
- name: build-non-prod - name: build container (non-prod)
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
tags: auto_tag: true
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when: when:
branch: branch:
exclude: exclude:
- main - main
depends_on:
- "test"
- name: redeploy on portainer
image: plugins/webhook
settings:
urls:
from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL
depends_on:
- "build-prod"
trigger: trigger:
event: event:
- push - push
- cron - cron

View File

@ -1,17 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
[**/*.js]
indent_style = space
indent_size = 2

2
.gitignore vendored
View File

@ -5,5 +5,5 @@ __pycache__
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
/static/ static
dist/ dist/

View File

@ -4,8 +4,5 @@
], ],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic", "python.analysis.typeCheckingMode": "basic"
"[python]": { }
"editor.defaultFormatter": "ms-python.black-formatter"
},
}

View File

@ -1,140 +1,6 @@
## 1.5.1 / 2023-11-14 21:10+01:00 ## Unrelease
## Improved * Add React
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00
## New
* Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00
### New
* More fields are now optional. This is to make it easier to add new items in bulk.
* Game: Wikidata ID
* Edition: Platform, Year
* Purchase: Platform
* Platform: Group
* Session: Device
* New fields:
* Game: Year Released
* To record original year of release
* Upon migration, this will be set to a year of any of the game's edition that has it set
* Purchase: Date Finished
* Editions are now unique combination of name and platform
* Add more stats:
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Sessions (count)
* Days played
* Finished (count)
* Unfinished (count)
* Refunded (count)
* Backlog Decrease (count)
* New workflow:
* Adding Game, Edition, Purchase, and Session in a row is now much faster
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
* ignore English articles when sorting names
* added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
* new game: name and sort name after typing
* new edition: name, sort name, and year when selecting game
* new purchase: platform when selecting edition
## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 1.2.0 / 2023-11-01 20:18+01:00
### New
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
### Enhancements
* Add a button to start session from game overview
## 1.1.2 / 2023-10-13 16:30+02:00
### Enhancements
* Durations are formatted in a consisent manner across all pages
### Fixes
* Game Overview: display duration when >1 hour instead of displaying 0
## 1.1.1 / 2023-10-09 20:52+02:00
### New
* Add notes section to game overview
### Enhancements
* Make it possible to add any data on the game overview page
## 1.1.0 / 2023-10-09 00:01+02:00
### New
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
* Add helper buttons next to datime fields
* Add copy button on Add session page to copy times between fields
* Change fonts to IBM Plex
### Enhancements
* Improve form appearance
* Focus important fields on forms
* 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
* 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)
### Fixes
* Fix session being wrongly considered in progress if it had a certain amount of manual hours (https://git.kucharczyk.xyz/lukas/timetracker/issues/58)
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
## 1.0.3 / 2023-02-20 17:16+01:00
* Add wikidata ID and year for editions
* Add icons for game, edition, purchase filters
* Allow filtering by game, edition, purchase from the session list
* Allow editing filtered entities from session list
## 1.0.2 / 2023-02-18 21:48+01:00
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
* Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48)
* Add support for purchase prices
* Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28)
## 1.0.1 / 2023-01-30 22:17+01:00
* Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46)
* Show markers on smaller graphs to make it clearer which dates the session belong to
* Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47)
## 1.0.0 / 2023-01-20 19:54+01:00 ## 1.0.0 / 2023-01-20 19:54+01:00

View File

@ -6,10 +6,17 @@ RUN npm install && \
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 1.5.1 ENV VERSION_NUMBER 1.0.0
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN apt update && \
apt install -y \
bash \
vim \
curl && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker RUN useradd -m --uid 1000 timetracker
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/
@ -21,7 +28,7 @@ RUN chmod +x /entrypoint.sh
USER timetracker USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin" ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry RUN pip install --no-cache-dir poetry
RUN poetry install RUN poetry install --without dev
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]

View File

@ -1,18 +1,12 @@
all: css migrate all: migrate
initialize: npm css migrate sethookdir loadplatforms initialize: npm migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f) HTMLFILES := $(shell find games/templates -type f)
npm: npm:
npm install npm install
css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
makemigrations: makemigrations:
poetry run python manage.py makemigrations poetry run python manage.py makemigrations

View File

@ -1,109 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
form label {
@apply dark:text-slate-400;
}
.responsive-table {
@apply dark:text-white mx-auto;
}
.responsive-table tr:nth-child(even) {
@apply bg-slate-800
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-slate-900
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
}
@layer utilities {
.max-w-20char {
max-width: 20ch;
}
.max-w-35char {
max-width: 40ch;
}
.max-w-40char {
max-width: 40ch;
}
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
}
#button-container button {
@apply mx-1;
}
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container {
@apply flex space-x-2 justify-center;
}
.basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}

91
common/plots.py Normal file
View File

@ -0,0 +1,91 @@
import base64
from datetime import datetime
from io import BytesIO
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from django.db.models import F, IntegerField, QuerySet, Sum
from django.db.models.functions import TruncDay
from games.models import Session
def key_value_to_value_value(data):
return {data["date"]: data["hours"]}
def playtime_over_time_chart(queryset: QuerySet = Session.objects):
microsecond_in_second = 1000000
result = (
queryset.exclude(timestamp_end__exact=None)
.annotate(date=TruncDay("timestamp_start"))
.values("date")
.annotate(
hours=Sum(
F("duration_calculated"),
output_field=IntegerField(),
)
)
.values("date", "hours")
)
keys = []
values = []
running_total = int(0)
for item in result:
# date_value = datetime.strftime(item["date"], "%d-%m-%Y")
date_value = item["date"]
keys.append(date_value)
running_total += int(item["hours"] / (3600 * microsecond_in_second))
values.append(running_total)
data = [keys, values]
return get_chart(
data,
title="Playtime over time (manual excluded)",
xlabel="Date",
ylabel="Hours",
)
def get_graph():
buffer = BytesIO()
plt.savefig(buffer, format="svg", transparent=True)
buffer.seek(0)
image_png = buffer.getvalue()
graph = base64.b64encode(image_png)
graph = graph.decode("utf-8")
buffer.close()
return graph
def get_chart(data, title="", xlabel="", ylabel=""):
x = data[0]
y = data[1]
plt.style.use("dark_background")
plt.switch_backend("SVG")
fig, ax = plt.subplots()
fig.set_size_inches(10, 4)
ax.plot(x, y)
first = x[0]
last = x[-1]
difference = last - first
if difference.days <= 14:
ax.xaxis.set_major_locator(mdates.DayLocator())
elif difference.days < 60 or len(x) < 60:
ax.xaxis.set_major_locator(mdates.WeekdayLocator())
ax.xaxis.set_minor_locator(mdates.DayLocator())
elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
else:
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
for label in ax.get_xticklabels(which="major"):
label.set(rotation=30, horizontalalignment="right")
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
fig.tight_layout()
chart = get_graph()
return chart

View File

@ -32,48 +32,32 @@ def format_duration(
from the formatting string. For example: from the formatting string. For example:
- 61 seconds as "%s" = 61 seconds - 61 seconds as "%s" = 61 seconds
- 61 seconds as "%m %s" = 1 minutes 1 seconds" - 61 seconds as "%m %s" = 1 minutes 1 seconds"
Format specifiers can include width and precision options:
- %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros)
""" """
minute_seconds = 60 minute_seconds = 60
hour_seconds = 60 * minute_seconds hour_seconds = 60 * minute_seconds
day_seconds = 24 * hour_seconds day_seconds = 24 * hour_seconds
safe_duration = _safe_timedelta(duration) duration = _safe_timedelta(duration)
# we don't need float # we don't need float
seconds_total = int(safe_duration.total_seconds()) seconds_total = int(duration.total_seconds())
# timestamps where end is before start # timestamps where end is before start
if seconds_total < 0: if seconds_total < 0:
seconds_total = 0 seconds_total = 0
days = hours = hours_float = minutes = seconds = 0 days = hours = minutes = seconds = 0
remainder = seconds = seconds_total remainder = seconds = seconds_total
if "%d" in format_string: if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds) days, remainder = divmod(seconds_total, day_seconds)
if re.search(r"%\d*\.?\d*H", format_string): if "%H" in format_string:
hours_float, remainder = divmod(remainder, hour_seconds) hours, remainder = divmod(remainder, hour_seconds)
hours = float(hours_float) + remainder / hour_seconds if "%m" in format_string:
if re.search(r"%\d*\.?\d*m", format_string):
minutes, seconds = divmod(remainder, minute_seconds) minutes, seconds = divmod(remainder, minute_seconds)
literals = { literals = {
"d": str(days), "%d": str(days),
"H": str(hours) if "m" not in format_string else str(hours_float), "%H": str(hours),
"m": str(minutes), "%m": str(minutes),
"s": str(seconds), "%s": str(seconds),
"r": str(seconds_total), "%r": str(seconds_total),
} }
formatted_string = format_string formatted_string = format_string
for pattern, replacement in literals.items(): for pattern, replacement in literals.items():
# Match format specifiers with optional width and precision formatted_string = re.sub(pattern, replacement, formatted_string)
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
if match:
format_spec = match.group(1)
if "." in format_spec:
# Format the number as float if precision is specified
replacement = f"{float(replacement):{format_spec}f}"
else:
# Format the number as integer if no precision is specified
replacement = f"{int(float(replacement)):>{format_spec}}"
# Replace the format specifier with the formatted number
formatted_string = re.sub(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
)
return formatted_string return formatted_string

View File

@ -1,9 +0,0 @@
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
Returns 0 if denominator is 0.
"""
try:
return numerator / denominator
except ZeroDivisionError:
return 0

View File

@ -7,13 +7,5 @@ poetry run python manage.py migrate
echo "Collect static files" echo "Collect static files"
poetry run python manage.py collectstatic --clear --no-input poetry run python manage.py collectstatic --clear --no-input
_term() {
echo "Caught SIGTERM signal!"
kill -SIGTERM "$gunicorn_pid"
}
trap _term SIGTERM
echo "Starting app" echo "Starting app"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$! poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -
wait "$gunicorn_pid"

17
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
env: {
browser: true,
es2021: true
},
extends: ["eslint/recommended", "plugin:react/recommended", "plugin:prettier/recommended"],
overrides: [],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
plugins: ["react"],
rules: {},
parserOptions: {
ecmaFeatures: { jsx: true }
}
};

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

9
frontend/.prettierrc Normal file
View File

@ -0,0 +1,9 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "none",
"bracketSameLine": false,
"singleAttributePerLine": true
}

17
frontend/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Self-hosted time-tracker."/>
<meta name="keywords" content="time, tracking, video games, self-hosted"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: replace with own icon -->
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
<title>Timetracker</title>
</head>
<body class="dark">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

33
frontend/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.13",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1",
"postcss": "^8.4.21",
"prettier": "^2.8.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.7.0",
"tailwindcss": "^3.2.4"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^3.0.0",
"eslint": "^8.32.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.1",
"eslint-plugin-react-hooks": "^4.6.0",
"vite": "^4.0.0"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

0
frontend/src/App.css Normal file
View File

20
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,20 @@
import "./App.css";
import Nav from "./components/Nav";
import { Outlet, useLoaderData } from "react-router-dom";
function App() {
return (
<>
<div className="dark:bg-gray-800 min-h-screen">
<Nav />
<Outlet />
</div>
{/* {% load version %} */}
{/* <span className="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> */}
</>
);
}
export default App;

View File

@ -0,0 +1,71 @@
import { Link } from 'react-router-dom';
function Nav() {
return (
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div className="container flex flex-wrap items-center justify-between mx-auto">
<Link
to="/"
className="flex items-center"
>
<span className="text-4xl"></span>
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">
Timetracker
</span>
</Link>
<div className="w-full md:block md:w-auto">
<ul className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_game' %}"
>
New Game
</a>
</li>
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_platform' %}"
>
New Platform
</a>
</li>
{/* {% if game_available and platform_available %} */}
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_purchase' %}"
>
New Purchase
</a>
</li>
{/* {% endif %} */}
{/* {% if purchase_available %} */}
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_session' %}"
>
New Session
</a>
</li>
{/* {% endif %} */}
{/* {% if session_count > 0 %} */}
<li>
<Link
className="block py-2 pl-3 pr-4 hover:underline"
to="/sessions"
>
All Sessions
</Link>
</li>
{/* {% endif %} */}
</ul>
</div>
</div>
</nav>
);
}
export default Nav;

View File

@ -0,0 +1,162 @@
export default function SessionList() {
const data = [
{
"url": "http://localhost:8000/api/sessions/25/",
"timestamp_start": "2020-01-01T00:00:00+01:00",
"timestamp_end": null,
"duration_manual": "12:00:00",
"duration_calculated": "00:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/26/",
"timestamp_start": "2022-12-31T15:25:00+01:00",
"timestamp_end": "2022-12-31T17:25:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/2/"
},
{
"url": "http://localhost:8000/api/sessions/27/",
"timestamp_start": "2023-01-01T23:00:00+01:00",
"timestamp_end": "2023-01-02T00:28:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:28:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/28/",
"timestamp_start": "2023-01-02T22:08:00+01:00",
"timestamp_end": "2023-01-03T01:08:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "03:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/29/",
"timestamp_start": "2023-01-03T22:36:00+01:00",
"timestamp_end": "2023-01-04T00:12:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:36:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/30/",
"timestamp_start": "2023-01-04T20:35:00+01:00",
"timestamp_end": "2023-01-04T22:36:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:01:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/31/",
"timestamp_start": "2023-01-06T18:48:00+01:00",
"timestamp_end": "2023-01-06T23:39:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "04:51:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/32/",
"timestamp_start": "2023-01-07T23:49:00+01:00",
"timestamp_end": "2023-01-08T01:43:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:54:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/33/",
"timestamp_start": "2023-01-08T16:21:00+01:00",
"timestamp_end": "2023-01-08T18:27:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:06:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/34/",
"timestamp_start": "2023-01-08T19:04:00+01:00",
"timestamp_end": "2023-01-08T22:03:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:59:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/35/",
"timestamp_start": "2023-01-09T19:35:48+01:00",
"timestamp_end": "2023-01-09T22:13:20.519058+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:37:32.519058",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/36/",
"timestamp_start": "2023-01-10T15:50:12+01:00",
"timestamp_end": "2023-01-10T17:03:45.424429+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:13:33.424429",
"note": "",
"purchase": "http://localhost:8000/api/purchases/4/"
}
]
const header = ["url", "timestamp_start", "timestamp_end", "duration_manual", "duration_calculated", "note", "purchase"]
// const header = ["Name", "Platform", "Start", "End", "Duration", "Manage"]
return (
<>
<div id="session-table" className="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
{header.map(column => {
<div className="dark:border-white dark:text-slate-300 text-lg">{column}</div>
})}
{data.map(session => {
<>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.url }
</a>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.timestamp_start }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.timestamp_end }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.duration_manual }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.duration_calculated }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.note }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.purchase }
</a>
</div>
</div>
</>
})}
</div>
</>
)
}

View File

@ -0,0 +1,16 @@
import { useRouteError } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError()
console.error(error)
return (
<div className="container text-center">
<h1 className="text-3xl">Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
)
}

22
frontend/src/index.css Normal file
View File

@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
form label {
@apply dark:text-slate-400;
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
#session-table {
display: grid;
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
}
#button-container button {
@apply mx-1;
}

34
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,34 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
// import { loader as sessionLoader } from './routes/sessions'
import ErrorPage from "./error-page"
import SessionList from './components/SessionList'
// import Session from './routes/sessions'
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <ErrorPage />,
// loader: sessionLoader,
children: [
{
path: "sessions/",
element: <SessionList />
}
]
},
// {
// path: "sessions",
// element: <SessionList />
// }
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)

View File

@ -0,0 +1,17 @@
export async function api(url) {
const response = await fetch(url);
if (response.ok) {
const jsonValue = await response.json();
return Promise.resolve(jsonValue);
} else {
return Promise.reject('Response was not OK.');
}
}
export async function getSession(sessionId) {
return await api(`/api/sessions/${sessionId}/`);
}
export async function getSessionList() {
return await api(`/api/sessions/`);
}

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
fontFamily: {
sans: ["Inter", "sans-serif"],
},
extend: {},
},
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
};

15
frontend/vite.config.js Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true
}
}
}
});

View File

@ -1,11 +1,9 @@
from django.contrib import admin from django.contrib import admin
from games.models import Game, Platform, Purchase, Session, Edition, Device from games.models import Game, Platform, Purchase, Session
# Register your models here. # Register your models here.
admin.site.register(Game) admin.site.register(Game)
admin.site.register(Purchase) admin.site.register(Purchase)
admin.site.register(Platform) admin.site.register(Platform)
admin.site.register(Session) admin.site.register(Session)
admin.site.register(Edition)
admin.site.register(Device)

View File

@ -1,133 +1,38 @@
from django import forms from django import forms
from games.models import Game, Platform, Purchase, Session, Edition, Device from games.models import Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class SessionForm(forms.ModelForm): class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField( purchase = forms.ModelChoiceField(queryset=Purchase.objects.order_by("game__name"))
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
class Meta: class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
"timestamp_end": custom_datetime_widget,
}
model = Session model = Session
fields = [ fields = [
"purchase", "purchase",
"timestamp_start", "timestamp_start",
"timestamp_end", "timestamp_end",
"duration_manual", "duration_manual",
"device",
"note", "note",
] ]
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-platform"] = value.instance.platform.id
return option
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
edition = EditionChoiceField( game = forms.ModelChoiceField(queryset=Game.objects.order_by("name"))
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
)
)
class Meta: class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
}
model = Purchase model = Purchase
fields = [ fields = ["game", "platform", "date_purchased", "date_refunded"]
"edition",
"platform",
"date_purchased",
"date_refunded",
"date_finished",
"price",
"price_currency",
"ownership_type",
"type",
"related_purchase",
"name",
]
class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-name"] = value.instance.name
option["attrs"]["data-year"] = value.instance.year_released
return option
class GameModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
# Use sort_name as the label for the option
return obj.sort_name
class EditionForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
class Meta: class Meta:
model = Game model = Game
fields = ["name", "sort_name", "year_released", "wikidata"] fields = ["name", "wikidata"]
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm): class PlatformForm(forms.ModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ["name", "group"] fields = ["name", "group"]
widgets = {"name": autofocus_input_widget}
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}

View File

@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
]
operations = [
migrations.CreateModel(
name="Edition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
],
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 18:51
from django.db import migrations
def create_edition_of_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
Platform = apps.get_model("games", "Platform")
first_platform = Platform.objects.first()
all_games = Game.objects.all()
all_editions = Edition.objects.all()
for game in all_games:
existing_edition = None
try:
existing_edition = all_editions.objects.get(game=game.id)
except:
pass
if existing_edition == None:
edition = Edition()
edition.id = game.id
edition.game = game
edition.name = game.name
edition.platform = first_platform
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0008_edition"),
]
operations = [migrations.RunPython(create_edition_of_game)]

View File

@ -1,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0009_create_editions"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0010_alter_purchase_game"),
]
operations = [
migrations.RenameField(
model_name="purchase",
old_name="game",
new_name="edition",
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_rename_game_purchase_edition"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="purchase",
name="price_currency",
field=models.CharField(default="USD", max_length=3),
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0012_purchase_price_purchase_price_currency"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="ownership_type",
field=models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
]

View File

@ -1,52 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0013_purchase_ownership_type"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
],
default="pc",
max_length=3,
),
),
],
),
migrations.AddField(
model_name="session",
name="device",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-20 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0014_device_session_device"),
]
operations = [
migrations.AddField(
model_name="edition",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AddField(
model_name="edition",
name="year_released",
field=models.IntegerField(default=2023),
),
]

View File

@ -1,51 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -1,141 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14
from django.db import migrations, models
import django.db.models.deletion
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
replaces = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
("games", "0017_alter_device_type_alter_purchase_platform"),
("games", "0018_auto_20231106_1825"),
("games", "0019_alter_edition_unique_together"),
("games", "0020_game_year"),
("games", "0021_auto_20231106_1909"),
("games", "0022_rename_year_game_year_released"),
]
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.RunPython(
code=rename_duplicates,
),
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(
code=update_game_year,
),
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
]
operations = [
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 17:25
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
class Migration(migrations.Migration):
dependencies = [
("games", "0017_alter_device_type_alter_purchase_platform"),
]
operations = [
migrations.RunPython(rename_duplicates),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 17:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0018_auto_20231106_1825"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
]

View File

@ -1,24 +0,0 @@
from django.db import migrations
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0020_game_year"),
]
operations = [
migrations.RunPython(update_game_year),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0021_auto_20231106_1909"),
]
operations = [
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"games",
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_finished",
field=models.DateField(blank=True, null=True),
),
]

View File

@ -1,39 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Edition = apps.get_model(
"games", "Edition"
) # Replace 'your_app_name' with the actual name of your app
for edition in Edition.objects.all():
name = edition.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
edition.sort_name = sort_name
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0023_purchase_date_finished"),
]
operations = [
migrations.AddField(
model_name="edition",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -1,39 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Game = apps.get_model(
"games", "Game"
) # Replace 'your_app_name' with the actual name of your app
for game in Game.objects.all():
name = game.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
game.sort_name = sort_name
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0024_edition_sort_name"),
]
operations = [
migrations.AddField(
model_name="game",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 08:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0025_game_sort_name"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="type",
field=models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="games.purchase",
),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase
def null_game_name(apps, schema_editor):
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
class Migration(migrations.Migration):
dependencies = [
("games", "0027_purchase_related_purchase"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="name",
field=models.CharField(
blank=True, default="Unknown Name", max_length=255, null=True
),
),
migrations.RunPython(null_game_name),
]

View File

@ -10,160 +10,36 @@ from django.db.models import F, Manager, Sum
class Game(models.Model): class Game(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self):
return self.sort_name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
def games_only(self):
return self.filter(type=Purchase.GAME)
class Purchase(models.Model): class Purchase(models.Model):
PHYSICAL = "ph" game = models.ForeignKey("Game", on_delete=models.CASCADE)
DIGITAL = "di" platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
DIGITALUPGRADE = "du"
RENTED = "re"
BORROWED = "bo"
TRIAL = "tr"
DEMO = "de"
PIRATED = "pi"
OWNERSHIP_TYPES = [
(PHYSICAL, "Physical"),
(DIGITAL, "Digital"),
(DIGITALUPGRADE, "Digital Upgrade"),
(RENTED, "Rented"),
(BORROWED, "Borrowed"),
(TRIAL, "Trial"),
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(
max_length=255, default="Unknown Name", null=True, blank=True
)
related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True
)
def __str__(self): def __str__(self):
additional_info = [ return f"{self.game} ({self.platform})"
self.get_type_display() if self.type != Purchase.GAME else "",
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self):
return self.type == self.GAME
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
super().save(*args, **kwargs)
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255)
def __str__(self): def __str__(self):
return self.name return self.name
class SessionQuerySet(models.QuerySet): class SessionQuerySet(models.QuerySet):
def total_duration_formatted(self): def total_duration(self):
return format_duration(self.total_duration_unformatted())
def total_duration_unformatted(self):
result = self.aggregate( result = self.aggregate(
duration=Sum(F("duration_calculated") + F("duration_manual")) duration=Sum(F("duration_calculated") + F("duration_manual"))
) )
return result["duration"] return format_duration(result["duration"])
class Session(models.Model): class Session(models.Model):
@ -172,19 +48,12 @@ class Session(models.Model):
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True) duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey(
"Device",
on_delete=models.CASCADE,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
def __str__(self): def __str__(self):
mark = ", manual" if self.is_manual() else "" mark = ", manual" if self.duration_manual != None else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
@ -196,54 +65,23 @@ class Session(models.Model):
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
manual = timedelta(0) manual = timedelta(0)
calculated = timedelta(0) calculated = timedelta(0)
if self.is_manual(): if not self.duration_manual in (None, 0, timedelta(0)):
manual = self.duration_manual manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None: if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start calculated = self.timestamp_end - self.timestamp_start
return timedelta(seconds=(manual + calculated).total_seconds()) return timedelta(seconds=(manual + calculated).total_seconds())
def duration_formatted(self) -> str: def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") result = format_duration(self.duration_seconds(), "%H:%m")
return result return result
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
@property @property
def duration_sum(self) -> str: def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted() return Session.objects.all().total_duration()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.timestamp_start != None and self.timestamp_end != None: if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}
)
self.device = default_device
super(Session, self).save(*args, **kwargs) super(Session, self).save(*args, **kwargs)
class Device(models.Model):
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

0
games/serializers.py Normal file
View File

View File

@ -1,5 +1,5 @@
/* /*
! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com
*/ */
/* /*
@ -31,7 +31,6 @@
3. Use a more readable tab size. 3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default. 4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default. 5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
*/ */
html { html {
@ -44,12 +43,10 @@ html {
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 3 */
font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-family: Inter, sans-serif;
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
font-variation-settings: normal;
/* 6 */
} }
/* /*
@ -129,7 +126,7 @@ code,
kbd, kbd,
samp, samp,
pre { pre {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */ /* 1 */
font-size: 1em; font-size: 1em;
/* 2 */ /* 2 */
@ -191,10 +188,6 @@ select,
textarea { textarea {
font-family: inherit; font-family: inherit;
/* 1 */ /* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%; font-size: 100%;
/* 1 */ /* 1 */
font-weight: inherit; font-weight: inherit;
@ -345,14 +338,6 @@ menu {
padding: 0; padding: 0;
} }
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/* /*
Prevent resizing textareas horizontally by default. Prevent resizing textareas horizontally by default.
*/ */
@ -434,7 +419,7 @@ video {
display: none; display: none;
} }
[type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
@ -451,7 +436,7 @@ video {
--tw-shadow: 0 0 #0000; --tw-shadow: 0 0 #0000;
} }
[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { [type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
outline: 2px solid transparent; outline: 2px solid transparent;
outline-offset: 2px; outline-offset: 2px;
--tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
@ -480,11 +465,6 @@ input::placeholder,textarea::placeholder {
::-webkit-date-and-time-value { ::-webkit-date-and-time-value {
min-height: 1.5em; min-height: 1.5em;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
} }
::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
@ -502,7 +482,7 @@ select {
print-color-adjust: exact; print-color-adjust: exact;
} }
[multiple],[size]:where(select:not([size="1"])) { [multiple] {
background-image: initial; background-image: initial;
background-position: initial; background-position: initial;
background-repeat: unset; background-repeat: unset;
@ -619,9 +599,6 @@ select {
--tw-pan-y: ; --tw-pan-y: ;
--tw-pinch-zoom: ; --tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity; --tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ; --tw-ordinal: ;
--tw-slashed-zero: ; --tw-slashed-zero: ;
--tw-numeric-figure: ; --tw-numeric-figure: ;
@ -669,9 +646,6 @@ select {
--tw-pan-y: ; --tw-pan-y: ;
--tw-pinch-zoom: ; --tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity; --tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ; --tw-ordinal: ;
--tw-slashed-zero: ; --tw-slashed-zero: ;
--tw-numeric-figure: ; --tw-numeric-figure: ;
@ -739,10 +713,6 @@ select {
} }
} }
.invisible {
visibility: hidden;
}
.static { .static {
position: static; position: static;
} }
@ -751,33 +721,16 @@ select {
position: fixed; position: fixed;
} }
.absolute { .left-2 {
position: absolute; left: 0.5rem;
}
.relative {
position: relative;
} }
.bottom-2 { .bottom-2 {
bottom: 0.5rem; bottom: 0.5rem;
} }
.left-2 { .clear-both {
left: 0.5rem; clear: both;
}
.right-3 {
right: 0.75rem;
}
.top-3 {
top: 0.75rem;
}
.mx-2 {
margin-left: 0.5rem;
margin-right: 0.5rem;
} }
.mx-auto { .mx-auto {
@ -785,55 +738,28 @@ select {
margin-right: auto; margin-right: auto;
} }
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.my-6 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-10 {
margin-bottom: 2.5rem;
}
.mb-4 { .mb-4 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.mr-4 {
margin-right: 1rem;
}
.mt-4 { .mt-4 {
margin-top: 1rem; margin-top: 1rem;
} }
.block { .mb-3 {
display: block; margin-bottom: 0.75rem;
} }
.inline-block { .mt-10 {
display: inline-block; margin-top: 2.5rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.block {
display: block;
} }
.inline { .inline {
@ -844,72 +770,46 @@ select {
display: flex; display: flex;
} }
.table { .h-6 {
display: table; height: 1.5rem;
}
.hidden {
display: none;
}
.h-4 {
height: 1rem;
} }
.h-5 { .h-5 {
height: 1.25rem; height: 1.25rem;
} }
.h-6 { .h-4 {
height: 1.5rem; height: 1rem;
} }
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
.w-5 { .w-full {
width: 1.25rem; width: 100%;
} }
.w-6 { .w-6 {
width: 1.5rem; width: 1.5rem;
} }
.w-5 {
width: 1.25rem;
}
.w-7 { .w-7 {
width: 1.75rem; width: 1.75rem;
} }
.w-auto { .w-4 {
width: auto; width: 1rem;
}
.w-full {
width: 100%;
} }
.max-w-screen-lg { .max-w-screen-lg {
max-width: 1024px; max-width: 1024px;
} }
.max-w-sm {
max-width: 24rem;
}
.max-w-xs {
max-width: 20rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@ -922,6 +822,10 @@ select {
align-items: center; align-items: center;
} }
.justify-end {
justify-content: flex-end;
}
.justify-center { .justify-center {
justify-content: center; justify-content: center;
} }
@ -930,18 +834,20 @@ select {
justify-content: space-between; justify-content: space-between;
} }
.gap-2 { .gap-4 {
gap: 0.5rem; gap: 1rem;
} }
.self-center { .self-center {
align-self: center; align-self: center;
} }
.truncate { .overflow-hidden {
overflow: hidden; overflow: hidden;
}
.text-ellipsis {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.whitespace-nowrap { .whitespace-nowrap {
@ -956,8 +862,8 @@ select {
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.rounded-sm { .rounded-xl {
border-radius: 0.125rem; border-radius: 0.75rem;
} }
.border-gray-200 { .border-gray-200 {
@ -965,14 +871,9 @@ select {
border-color: rgb(229 231 235 / var(--tw-border-opacity)); border-color: rgb(229 231 235 / var(--tw-border-opacity));
} }
.border-slate-500 { .bg-white {
--tw-border-opacity: 1;
border-color: rgb(100 116 139 / var(--tw-border-opacity));
}
.bg-gray-200 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity)); background-color: rgb(255 255 255 / var(--tw-bg-opacity));
} }
.bg-green-600 { .bg-green-600 {
@ -980,28 +881,27 @@ select {
background-color: rgb(22 163 74 / var(--tw-bg-opacity)); background-color: rgb(22 163 74 / var(--tw-bg-opacity));
} }
.bg-violet-600 { .bg-blue-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(124 58 237 / var(--tw-bg-opacity)); background-color: rgb(37 99 235 / var(--tw-bg-opacity));
} }
.bg-white { .bg-red-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)); background-color: rgb(220 38 38 / var(--tw-bg-opacity));
} }
.p-4 { .p-4 {
padding: 1rem; padding: 1rem;
} }
.px-2 { .p-2 {
padding-left: 0.5rem; padding: 0.5rem;
padding-right: 0.5rem;
} }
.px-4 { .py-2 {
padding-left: 1rem; padding-top: 0.5rem;
padding-right: 1rem; padding-bottom: 0.5rem;
} }
.py-1 { .py-1 {
@ -1009,9 +909,9 @@ select {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
.py-2 { .px-2 {
padding-top: 0.5rem; padding-left: 0.5rem;
padding-bottom: 0.5rem; padding-right: 0.5rem;
} }
.pl-3 { .pl-3 {
@ -1022,21 +922,12 @@ select {
padding-right: 1rem; padding-right: 1rem;
} }
.pt-1 {
padding-top: 0.25rem;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
.font-mono { .text-right {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; text-align: right;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
} }
.text-4xl { .text-4xl {
@ -1044,21 +935,6 @@ select {
line-height: 2.5rem; line-height: 2.5rem;
} }
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-xl { .text-xl {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.75rem; line-height: 1.75rem;
@ -1069,40 +945,33 @@ select {
line-height: 1rem; line-height: 1rem;
} }
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.font-semibold { .font-semibold {
font-weight: 600; font-weight: 600;
} }
.italic {
font-style: italic;
}
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-slate-300 {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity));
}
.text-white { .text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
.text-yellow-300 { .text-slate-300 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(253 224 71 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
} }
.underline { .text-red-400 {
text-decoration-line: underline; --tw-text-opacity: 1;
} color: rgb(248 113 113 / var(--tw-text-opacity));
.decoration-slate-500 {
text-decoration-color: #64748b;
} }
.shadow-md { .shadow-md {
@ -1111,8 +980,10 @@ select {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.filter { .shadow {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.transition { .transition {
@ -1131,88 +1002,14 @@ select {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1); transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
} }
.max-w-20char { .dark form label {
max-width: 20ch;
}
.\[a-zA-Z\:\\-\] {
a-z-a--z: \-;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
:is(.dark form label) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.responsive-table { .dark form input,.dark
margin-left: auto; select,.dark
margin-right: auto; textarea {
}
:is(.dark .responsive-table) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.responsive-table tr:nth-child(even) {
--tw-bg-opacity: 1;
background-color: rgb(30 41 59 / var(--tw-bg-opacity));
}
.responsive-table tbody tr:nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(15 23 42 / var(--tw-bg-opacity));
}
.responsive-table thead th {
border-bottom-width: 2px;
--tw-border-opacity: 1;
border-bottom-color: rgb(100 116 139 / var(--tw-border-opacity));
text-align: left;
font-size: 1.25rem;
line-height: 1.75rem;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
border-left-width: 1px;
--tw-border-opacity: 1;
border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
}
:is(.dark form input),:is(.dark
select),:is(.dark
textarea) {
border-width: 1px; border-width: 1px;
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(15 23 42 / var(--tw-border-opacity)); border-color: rgb(15 23 42 / var(--tw-border-opacity));
@ -1222,29 +1019,9 @@ textarea) {
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
} }
:is(.dark form input:disabled),:is(.dark #session-table {
select:disabled),:is(.dark display: grid;
textarea:disabled) { grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
--tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} }
#button-container button { #button-container button {
@ -1252,93 +1029,19 @@ textarea:disabled) {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
th {
text-align: right;
}
th label {
margin-right: 1rem;
}
.basic-button-container {
display: flex;
justify-content: center;
}
.basic-button-container > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.basic-button {
display: inline-block;
border-radius: 0.25rem;
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 0.625rem;
padding-bottom: 0.625rem;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 500;
text-transform: uppercase;
line-height: 1.25;
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.basic-button:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.basic-button:focus {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.basic-button:active {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover { .hover\:bg-green-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity)); background-color: rgb(21 128 61 / var(--tw-bg-opacity));
} }
.hover\:bg-violet-700:hover { .hover\:bg-blue-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(109 40 217 / var(--tw-bg-opacity)); background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-red-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
} }
.hover\:underline:hover { .hover\:underline:hover {
@ -1361,9 +1064,14 @@ th label {
--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity)); --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
} }
.focus\:ring-violet-500:focus { .focus\:ring-blue-500:focus {
--tw-ring-opacity: 1; --tw-ring-opacity: 1;
--tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity)); --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.focus\:ring-red-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
} }
.focus\:ring-offset-2:focus { .focus\:ring-offset-2:focus {
@ -1374,76 +1082,44 @@ th label {
--tw-ring-offset-color: #bfdbfe; --tw-ring-offset-color: #bfdbfe;
} }
.focus\:ring-offset-violet-200:focus { .dark .dark\:border-white {
--tw-ring-offset-color: #ddd6fe; --tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
} }
.group:hover .group-hover\:block { .dark .dark\:bg-gray-800 {
display: block;
}
:is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:bg-gray-900) { .dark .dark\:bg-gray-900 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:text-slate-500) { .dark .dark\:bg-slate-700 {
--tw-text-opacity: 1; --tw-bg-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity)); background-color: rgb(51 65 85 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:text-slate-600) { .dark .dark\:text-white {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-white) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
@media (min-width: 640px) { .dark .dark\:text-slate-600 {
.sm\:inline { --tw-text-opacity: 1;
display: inline; color: rgb(71 85 105 / var(--tw-text-opacity));
} }
.sm\:table-cell { .dark .dark\:text-slate-400 {
display: table-cell; --tw-text-opacity: 1;
} color: rgb(148 163 184 / var(--tw-text-opacity));
}
.sm\:max-w-md { .dark .dark\:text-slate-300 {
max-width: 28rem; --tw-text-opacity: 1;
} color: rgb(203 213 225 / var(--tw-text-opacity));
.sm\:max-w-xl {
max-width: 36rem;
}
.sm\:px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.sm\:pl-2 {
padding-left: 0.5rem;
}
.sm\:pl-4 {
padding-left: 1rem;
}
.sm\:pl-6 {
padding-left: 1.5rem;
}
.sm\:decoration-2 {
text-decoration-thickness: 2px;
}
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@ -1451,10 +1127,6 @@ th label {
display: block; display: block;
} }
.md\:w-1\/2 {
width: 50%;
}
.md\:w-auto { .md\:w-auto {
width: auto; width: auto;
} }
@ -1462,32 +1134,4 @@ th label {
.md\:flex-row { .md\:flex-row {
flex-direction: row; flex-direction: row;
} }
.md\:px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.md\:py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.md\:max-w-40char {
max-width: 40ch;
}
}
@media (min-width: 1024px) {
.lg\:table-cell {
display: table-cell;
}
.lg\:max-w-3xl {
max-width: 48rem;
}
.lg\:max-w-lg {
max-width: 32rem;
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

View File

@ -1,24 +0,0 @@
import { syncSelectInputUntilChanged } from './utils.js';
let syncData = [
{
"source": "#id_game",
"source_value": "dataset.name",
"target": "#id_name",
"target_value": "value"
},
{
"source": "#id_game",
"source_value": "textContent",
"target": "#id_sort_name",
"target_value": "value"
},
{
"source": "#id_game",
"source_value": "dataset.year",
"target": "#id_year_released",
"target_value": "value"
},
]
syncSelectInputUntilChanged(syncData, "form");

View File

@ -1,12 +0,0 @@
import { syncSelectInputUntilChanged } from './utils.js'
let syncData = [
{
"source": "#id_name",
"source_value": "value",
"target": "#id_sort_name",
"target_value": "value"
}
]
syncSelectInputUntilChanged(syncData, "form")

View File

@ -1,30 +0,0 @@
import {
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenFalse,
} from "./utils.js";
let syncData = [
{
source: "#id_edition",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
getEl("#id_type").onchange = () => {
setupElementHandlers();
};

View File

@ -1,19 +0,0 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date);
} else if (type == "copy") {
const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value = targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}

File diff suppressed because one or more lines are too long

View File

@ -1,170 +0,0 @@
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
* @returns {string}
*/
function toISOUTCString(date) {
function stringAndPad(number) {
return number.toString().padStart(2, 0);
}
const year = date.getFullYear();
const month = stringAndPad(date.getMonth() + 1);
const day = stringAndPad(date.getDate());
const hours = stringAndPad(date.getHours());
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* @description Sync values between source and target elements based on syncData configuration.
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
*/
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const parentElement =
parentSelector === document
? document
: document.querySelector(parentSelector);
if (!parentElement) {
console.error(`The parent selector "${parentSelector}" is not valid.`);
return;
}
// Set up a single change event listener on the document for handling all source changes
parentElement.addEventListener("change", function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the change event target matches the source selector
if (event.target.matches(syncItem.source)) {
const sourceElement = event.target;
const valueToSync = getValueFromProperty(
sourceElement,
syncItem.source_value
);
const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) {
targetElement[syncItem.target_value] = valueToSync;
}
}
});
});
// Set up a single focus event listener on the document for handling all target focuses
parentElement.addEventListener(
"focus",
function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the focus event target matches the target selector
if (event.target.matches(syncItem.target)) {
// Remove the change event listener to stop syncing
// This assumes you want to stop syncing once any target receives focus
// You may need a more sophisticated way to remove listeners if you want to stop
// syncing selectively based on other conditions
document.removeEventListener("change", syncSelectInputUntilChanged);
}
});
},
true
); // Use capture phase to ensure the event is captured during focus, not bubble
}
/**
* @description Retrieve the value from the source element based on the provided property.
* @param {Element} sourceElement - The source HTML element.
* @param {string} property - The property to retrieve the value from.
*/
function getValueFromProperty(sourceElement, property) {
let source = (sourceElement instanceof HTMLSelectElement) ? sourceElement.selectedOptions[0] : sourceElement
if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey];
} else if (property in source) {
return source[property];
} else {
console.error(`Property ${property} is not valid for the option element.`);
return null;
}
}
/**
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1))
}
else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector)
}
else {
return document.getElementsByTagName(selector)
}
}
/**
* @description Applies different behaviors to elements based on multiple conditional configurations.
* Each configuration is an array containing a condition function, an array of target element selectors,
* and two callback functions for handling matched and unmatched conditions.
* @param {...Array} configs Each configuration is an array of the form:
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/
function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) {
targetElements.forEach(elementName => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn1(el);
}
});
} else {
targetElements.forEach(elementName => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn2(el);
}
});
}
});
}
function disableElementsWhenFalse(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value != targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };

View File

@ -1,43 +0,0 @@
function elt(type, props, ...children) {
let dom = document.createElement(type);
if (props) Object.assign(dom, props);
for (let child of children) {
if (typeof child != "string") dom.appendChild(child);
else dom.appendChild(document.createTextNode(child));
}
return dom;
}
/**
* @param {Node} targetNode
*/
function addToggleButton(targetNode) {
let manualToggleButton = elt(
"td",
{},
elt(
"div",
{ className: "basic-button" },
elt(
"button",
{
onclick: (event) => {
let textInputField = elt("input", { type: "text", id: targetNode.id });
targetNode.replaceWith(textInputField);
event.target.addEventListener("click", (event) => {
textInputField.replaceWith(targetNode);
});
},
},
"Toggle manual"
)
)
);
targetNode.parentElement.appendChild(manualToggleButton);
}
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));
});

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@ -1,25 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %} {% block title %}{{ title }}{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data" class="mx-auto">
<table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }} {{ form.as_p }}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %} <input type="submit" value="Submit"/>
{% if script_name %} </form>
<script type="module" src="{% static 'js/'|add:script_name %}"></script> {% endblock content %}
{% endif %}
{% endblock scripts %}

View File

@ -1,29 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" name="submit" value="Submit"/></td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Purchase"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,29 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" name="submit" value="Submit"/></td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Edition"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,29 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" name="submit" value="Submit"/></td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Session"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,37 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{% for field in form %}
<tr>
<th>{{ field.label_tag }}</th>
{% if field.name == "note" %}
<td>{{ field }}</td>
{% else %}
<td>{{ field }}</td>
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container">
<button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button>
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
<button class="basic-button" data-target="{{field.name}}" data-type="copy">Copy</button>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% load static %}
<script type="module" src="{% static 'js/add_session.js' %}"></script>
{% endblock content %}

View File

@ -9,53 +9,30 @@
<meta name="keywords" content="time, tracking, video games, self-hosted"/> <meta name="keywords" content="time, tracking, video games, self-hosted"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title> <title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
<script src="{% static 'js/htmx.min.js' %}"></script> <link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
</head> </head>
<body class="dark"> <body class="dark">
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
<div class="dark:bg-gray-800 min-h-screen"> <div class="dark:bg-gray-800 min-h-screen">
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div class="container flex flex-wrap items-center justify-between mx-auto"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center"> <a href="{% url 'index' %}" class="flex items-center">
<span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span> <span class="text-4xl"></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a> </a>
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul <ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li class="relative group"> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap"> {% if game_available and platform_available %}
{% if purchase_available %} <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_device' %}">Device</a></li> {% endif %}
{% endif %} {% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_game' %}">Game</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
{% if game_available and platform_available %} {% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_edition' %}">Edition</a></li>
{% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_platform' %}">Platform</a></li>
{% if edition_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_purchase' %}">Purchase</a></li>
{% endif %}
{% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_session' %}">Session</a></li>
{% endif %}
</ul>
</li>
{% if session_count > 0 %} {% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
</li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{% endif %} {% endif %}
</ul> </ul>
@ -66,7 +43,6 @@
</div> </div>
{% load version %} {% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
{% block scripts %}{% endblock scripts %}
</body> </body>
</html> </html>

View File

@ -1,26 +0,0 @@
{% comment %}
title
text
{% endcomment %}
<a
href="{{ link }}"
title="{{ title }}"
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
>
{% comment %} <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
{% endcomment %}
{{ text }}
</a>

View File

@ -1,26 +0,0 @@
{% comment %}
title
text
{% endcomment %}
<button
type="button"
title="{{ title }}"
autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
{{ text }}
</button>

View File

@ -1,21 +0,0 @@
<a href="{{ edit_url }}">
<button
type="button"
title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z"
/>
<path
d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z"
/>
</svg>
</button>
</a>

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center"> <div class="text-slate-300 mx-auto max-w-screen-lg text-center">
{% if session_count > 0 %} {% if session_count > 0 %}
You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}. You have played a total of {{ session_count }} sessions for a total of {{ total_duration }}.
{% elif not game_available or not platform_available %} {% elif not game_available or not platform_available %}
There are no games in the database. Start by clicking "New Game" and "New Platform". There are no games in the database. Start by clicking "New Game" and "New Platform".
{% elif not purchase_available %} {% elif not purchase_available %}
@ -14,4 +14,4 @@
You haven't played any games yet. Click "New Session" to add one now. You haven't played any games yet. Click "New Session" to add one now.
{% endif %} {% endif %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -1,77 +1,81 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% block title %}Sessions{% endblock title %}
{% block title %}{{ title }}{% endblock title %}
{% block content %} {% block content %}
<div class="text-center text-xl mb-4 dark:text-slate-400">
{% if dataset.count >= 1 %} {% if dataset.count >= 2 %}
<div class="mx-auto text-center my-4"> <img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" />
<a {% endif %}
id="last-session-start" {% if dataset.count >= 1 %}
href="{% url 'start_session_same_as_last' last.id %}" <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
hx-get="{% url 'start_session_same_as_last' last.id %}" {% endif %}
hx-indicator="#indicator" {% if purchase or platform or game %}
hx-swap="afterbegin" <a class="text-red-400 inline" href="{% url 'list_sessions' %}"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline">
hx-target=".responsive-table tbody" <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
hx-select=".responsive-table tbody tr:first-child" </svg>
onClick="document.querySelector('#last-session-start').classList.add('invisible')" </a><span>Filtering by "{% firstof purchase platform game %}"</span>
class="{% if last.timestamp_end == null %}invisible{% endif %}" {% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %}
> {% endif %}
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %} {% if dataset.count >= 1 %}
</a> <a class="clear-both" href="{% url 'start_session' last.purchase.id %}">
</div> <button type="button" title="Track last tracked" class="mt-10 py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg ">
{% endif %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
<table class="responsive-table"> </svg>
<thead> {{ last.purchase }}
<tr> </button>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th> </a>
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th> {% endif %}
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th> </div>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th> <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
</tr> <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
</thead> <div class="dark:border-white dark:text-slate-300 text-lg">Platform</div>
<tbody> <div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div>
{% for data in dataset %} <div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div>
<tr> <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
<td <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char" {% for data in dataset %}
> <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_purchase' data.purchase.id %}">{{ data.purchase.game }}</a></div>
<a <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">{{ data.purchase.platform }}</a></div>
class="underline decoration-slate-500 sm:decoration-2" <div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
href="{% url 'view_game' data.purchase.edition.game.id %}"> <div class="dark:text-slate-400 text-center">
{{ data.purchase.edition }} {% if data.unfinished %}
</a> <span class="text-red-400">Not finished yet.</span>
</td> {% elif data.duration_manual %}
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell"> --
{{ data.timestamp_start | date:"d/m/Y H:i" }} {% else %}
</td> {{ data.timestamp_end | date:"d/m/Y H:i" }}
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell"> {% endif %}
{% if data.unfinished %} </div>
<a <div class="dark:text-slate-400 flex">{{ data.duration_formatted }}{% if data.duration_manual %} <svg title="Added manually" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 ml-1 self-center">
href="{% url 'update_session' data.id %}" <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
hx-get="{% url 'update_session' data.id %}" </svg>
hx-swap="outerHTML" {% endif %}</div>
hx-target=".responsive-table tbody tr:first-child" <div id="button-container" class="flex justify-end">
hx-select=".responsive-table tbody tr:first-child" {% if data.unfinished %}
hx-indicator="#indicator" <a href="{% url 'update_session' data.id %}">
onClick="document.querySelector('#last-session-start').classList.remove('invisible')" <button type="button" title="Set to finished" class="py-1 px-2 flex justify-center items-center bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<span class="text-yellow-300">Finish now?</span> <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</a> </svg>
{% elif data.duration_manual %} </button>
-- </a>
{% else %} {% endif %}
{{ data.timestamp_end | date:"d/m/Y H:i" }} <button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
{% endif %} <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
</td> <path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
{{ data.duration_formatted }} </svg>
</td> </button>
</tr> <a href="{% url 'delete_session' data.id %}">
{% endfor %} <button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center bg-red-600 hover:bg-red-700 focus:ring-red-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
</tbody> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
</table> <path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" />
{% endblock content %} </svg>
</button>
</a>
</div>
{% endfor %}
</div>
{% endblock content %}

View File

@ -1,199 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% load static %}
{% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="flex flex-column flex-wrap justify-center">
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
</tr>
</tbody>
</table>
</div>
</div>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for game in top_10_games_by_playtime %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for item in total_playtime_per_platform %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">
{{ purchase.edition.name }}
{% if purchase.type != "game" %}
({{ purchase.name }}, {{ purchase.get_type_display }})
{% endif %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}

View File

@ -1,100 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% load static %}
{% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<h1 class="text-4xl flex items-center">
{{ game.name }}
<span class="dark:text-slate-500">(#{{ game.pk }})</span>
{% url 'edit_game' game.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</h1>
<h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) </h2>
<hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
<ul>
{% for edition in editions %}
<li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %}
<span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
</a>
</span>
{% endif %}
{% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul>
{% for purchase in purchases %}
<li class="sm:pl-2 flex items-center">
{{ purchase.platform }}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
{% if purchase.related_purchases %}
<li>
<ul>
{% for related_purchase in purchase.related_purchases %}
<li class="sm:pl-6 flex items-center">
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
{% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
</li>
{% endif %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions
<span class="dark:text-slate-500">
({{ sessions.count }})
</span>
{% url 'start_game_session' game.id as add_session_link %}
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
</h1>
<ul>
{% for session in sessions %}
<li class="sm:pl-2 flex items-center">
{{ session.timestamp_start | date:"d/m/Y" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Notes <span class="dark:text-slate-500">({{ sessions_with_notes.count }})</span></h1>
<ul>
{% for session in sessions_with_notes %}
<li class="sm:pl-2">
<ul>
<li class="block dark:text-slate-500">
<span class="flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
{% url 'edit_session' session.id as edit_session_url %}
{% include 'components/edit_button.html' with edit_url=edit_session_url %}
</span>
</li>
<li class="sm:pl-4 italic">
{{ session.note|linebreaks }}
</li>
</ul>
</li>
{% endfor %}
</ul>
</div>
{% endblock content %}

View File

@ -4,59 +4,25 @@ from games import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path(
"list-sessions/recent",
views.list_sessions,
{"filter": "recent"},
name="list_sessions_recent",
),
path("add-game/", views.add_game, name="add_game"), path("add-game/", views.add_game, name="add_game"),
path("add-platform/", views.add_platform, name="add_platform"), path("add-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"), path("add-session/", views.add_session, name="add_session"),
path(
"add-session-for-purchase/<int:purchase_id>",
views.add_session,
name="add_session_for_purchase",
),
path( path(
"update-session/by-session/<int:session_id>", "update-session/by-session/<int:session_id>",
views.update_session, views.update_session,
name="update_session", name="update_session",
), ),
path( path(
"start-session-same-as-last/<int:last_session_id>", "start-session/<int:purchase_id>",
views.start_session_same_as_last, views.start_session,
name="start_session_same_as_last", name="start_session",
), ),
path( path(
"start-session/<int:game_id>", "delete_session/by-id/<int:session_id>",
views.start_game_session, views.delete_session,
name="start_game_session", name="delete_session",
), ),
# path(
# "delete_session/by-id/<int:session_id>",
# views.delete_session,
# name="delete_session",
# ),
path("add-purchase/", views.add_purchase, name="add_purchase"), path("add-purchase/", views.add_purchase, name="add_purchase"),
path(
"add-purchase-for-edition/<int:edition_id>",
views.add_purchase,
name="add_purchase_for_edition",
),
path("add-edition/", views.add_edition, name="add_edition"),
path(
"add-edition-for-game/<int:game_id>",
views.add_edition,
name="add_edition_for_game",
),
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
path("game/<int:game_id>/view", views.view_game, name="view_game"),
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
path("add-device/", views.add_device, name="add_device"),
path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
path("list-sessions/", views.list_sessions, name="list_sessions"), path("list-sessions/", views.list_sessions, name="list_sessions"),
path( path(
"list-sessions/by-purchase/<int:purchase_id>", "list-sessions/by-purchase/<int:purchase_id>",
@ -76,22 +42,4 @@ urlpatterns = [
{"filter": "game"}, {"filter": "game"},
name="list_sessions_by_game", name="list_sessions_by_game",
), ),
path(
"list-sessions/by-edition/<int:edition_id>",
views.list_sessions,
{"filter": "edition"},
name="list_sessions_by_edition",
),
path(
"list-sessions/by-ownership/<str:ownership_type>",
views.list_sessions,
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
path("stats/", views.stats, name="stats_current_year"),
path(
"stats/<int:year>",
views.stats,
name="stats_by_year",
),
] ]

View File

@ -1,73 +1,43 @@
from common.time import format_duration, now as now_with_tz from datetime import datetime
from common.utils import safe_division
from datetime import datetime, timedelta
from django.conf import settings
from django.db.models import Sum, F, Count
from django.db.models.functions import TruncDate
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse
from typing import Callable, Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from .forms import ( from common.plots import playtime_over_time_chart
GameForm, from common.time import now as now_with_tz
PlatformForm, from django.conf import settings
PurchaseForm, from django.shortcuts import redirect, render
SessionForm,
EditionForm, from .forms import GameForm, PlatformForm, PurchaseForm, SessionForm
DeviceForm, from .models import Game, Platform, Purchase, Session
)
from .models import Game, Platform, Purchase, Session, Edition
def model_counts(request): def model_counts(request):
return { return {
"game_available": Game.objects.count() != 0, "game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.count() != 0, "platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0, "purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(), "session_count": Session.objects.count(),
} }
def stats_dropdown_year_range(request): def add_session(request):
result = {
"stats_dropdown_year_range": range(
datetime.now(ZoneInfo(settings.TIME_ZONE)).year, 1999, -1
)
}
return result
def add_session(request, purchase_id=None):
context = {} context = {}
initial = {"timestamp_start": now_with_tz()} initial = {}
now = now_with_tz()
initial["timestamp_start"] = now
last = Session.objects.all().last() last = Session.objects.all().last()
if last != None: if last != None:
initial["purchase"] = last.purchase initial["purchase"] = last.purchase
if request.method == "POST": form = SessionForm(request.POST or None, initial=initial)
form = SessionForm(request.POST or None, initial=initial) if form.is_valid():
if form.is_valid(): form.save()
form.save() return redirect("list_sessions")
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
}
)
else:
form = SessionForm(initial=initial)
context["title"] = "Add New Session" context["title"] = "Add New Session"
context["form"] = form context["form"] = form
return render(request, "add_session.html", context) return render(request, "add.html", context)
def update_session(request, session_id=None): def update_session(request, session_id=None):
@ -77,181 +47,20 @@ def update_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def use_custom_redirect( def start_session(request, purchase_id=None):
func: Callable[..., HttpResponse] session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()})
) -> Callable[..., HttpResponse]: session.save()
""" return redirect("list_sessions")
Will redirect to "return_path" session variable if set.
"""
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = func(request, *args, **kwargs)
if isinstance(response, HttpResponseRedirect) and (
next_url := request.session.get("return_path")
):
return HttpResponseRedirect(next_url)
return response
return wrapper
@use_custom_redirect def delete_session(request, session_id=None):
def edit_session(request, session_id=None):
context = {}
session = Session.objects.get(id=session_id) session = Session.objects.get(id=session_id)
form = SessionForm(request.POST or None, instance=session) session.delete()
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add_session.html", context)
@use_custom_redirect
def edit_purchase(request, purchase_id=None):
context = {}
purchase = Purchase.objects.get(id=purchase_id)
form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Purchase"
context["form"] = form
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@use_custom_redirect
def edit_game(request, game_id=None):
context = {}
purchase = Game.objects.get(id=game_id)
form = GameForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Game"
context["form"] = form
return render(request, "add.html", context)
def view_game(request, game_id=None):
context = {}
game = Game.objects.get(id=game_id)
context["title"] = "View Game"
context["game"] = game
context["editions"] = Edition.objects.filter(game_id=game_id)
game_purchases = (
Purchase.objects.filter(edition__game_id=game_id)
.filter(type=Purchase.GAME)
.order_by("date_purchased")
)
for purchase in game_purchases:
purchase.related_purchases = Purchase.objects.exclude(
type=Purchase.GAME
).filter(related_purchase=purchase.id)
context["purchases"] = game_purchases
context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
)
context["session_average"] = round(
(context["total_hours"]) / int(context["sessions"].count()), 1
)
# here first and last is flipped
# because sessions are ordered from newest to oldest
# so the most recent are on top
playrange_start = context["sessions"].last().timestamp_start.strftime("%b %Y")
playrange_end = context["sessions"].first().timestamp_start.strftime("%b %Y")
context["playrange"] = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
@use_custom_redirect
def edit_platform(request, platform_id=None):
context = {}
purchase = Platform.objects.get(id=platform_id)
form = PlatformForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Platform"
context["form"] = form
return render(request, "add.html", context)
@use_custom_redirect
def edit_edition(request, edition_id=None):
context = {}
edition = Edition.objects.get(id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Edition"
context["form"] = form
return render(request, "add.html", context)
@use_custom_redirect
def start_game_session(request, game_id: int):
last_session = (
Session.objects.filter(purchase__edition__game_id=game_id)
.order_by("-timestamp_start")
.first()
)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(),
"device": last_session.device,
}
)
session.save()
return redirect("list_sessions") return redirect("list_sessions")
def start_session_same_as_last(request, last_session_id: int): def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""):
last_session = Session.objects.get(id=last_session_id)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(),
"device": last_session.device,
}
)
session.save()
return redirect("list_sessions")
# def delete_session(request, session_id=None):
# session = Session.objects.get(id=session_id)
# session.delete()
# return redirect("list_sessions")
def list_sessions(
request,
filter="",
purchase_id="",
platform_id="",
game_id="",
edition_id="",
ownership_type: str = "",
):
context = {} context = {}
context["title"] = "Sessions"
if filter == "purchase": if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id) dataset = Session.objects.filter(purchase=purchase_id)
@ -259,262 +68,53 @@ def list_sessions(
elif filter == "platform": elif filter == "platform":
dataset = Session.objects.filter(purchase__platform=platform_id) dataset = Session.objects.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id) context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition":
dataset = Session.objects.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game": elif filter == "game":
dataset = Session.objects.filter(purchase__edition__game=game_id) dataset = Session.objects.filter(purchase__game=game_id)
context["game"] = Game.objects.get(id=game_id) context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type":
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
current_year = datetime.now().year
first_day_of_year = datetime(current_year, 1, 1)
dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start")
context["title"] = "This year"
else: else:
# by default, sort from newest to oldest # by default, sort from newest to oldest
dataset = Session.objects.all().order_by("-timestamp_start") dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset: for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta( if session.timestamp_end == None and session.duration_manual.seconds == 0:
seconds=0
):
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted() context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet # cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last() context["last"] = Session.objects.all().order_by("timestamp_start").last()
# charts are always oldest->newest
if Session.objects.count() >= 2:
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
def stats(request, year: int = 0): def add_purchase(request):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = now_with_tz().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.filter(
price_currency__exact=selected_currency
)
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
date_finished__isnull=True
).filter(
type=Purchase.GAME
) # do not count DLC etc.
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished.count(), this_year_purchases_refunded.count()
)
* 100
)
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished"
)
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.intersection(
purchases_finished_this_year
).order_by("date_finished")
)
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year)
.intersection(purchases_finished_this_year)
.count()
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter(
edition__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded.count())
),
"all_finished_this_year": purchases_finished_this_year.order_by(
"date_finished"
),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
"date_finished"
),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
"date_finished"
),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"refunded_percent": int(
safe_division(
this_year_purchases_refunded.count(),
this_year_purchases_with_currency.count(),
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
"date_purchased"
),
"backlog_decrease_count": backlog_decrease_count,
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
def add_purchase(request, edition_id=None):
context = {} context = {}
initial = {"date_purchased": now_with_tz()} now = datetime.now()
initial = {"date_purchased": now}
if request.method == "POST": form = PurchaseForm(request.POST or None, initial=initial)
form = PurchaseForm(request.POST or None, initial=initial) if form.is_valid():
if form.is_valid(): form.save()
purchase = form.save() return redirect("index")
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
)
)
else:
return redirect("index")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
}
)
else:
form = PurchaseForm(initial=initial)
context["form"] = form context["form"] = form
context["title"] = "Add New Purchase" context["title"] = "Add New Purchase"
context["script_name"] = "add_purchase.js" return render(request, "add.html", context)
return render(request, "add_purchase.html", context)
def add_game(request): def add_game(request):
context = {} context = {}
form = GameForm(request.POST or None) form = GameForm(request.POST or None)
if form.is_valid(): if form.is_valid():
game = form.save() form.save()
if "submit_and_redirect" in request.POST: return redirect("index")
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("index")
context["form"] = form context["form"] = form
context["title"] = "Add New Game" context["title"] = "Add New Game"
context["script_name"] = "add_game.js" return render(request, "add.html", context)
return render(request, "add_game.html", context)
def add_edition(request, game_id=None):
context = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = Game.objects.get(id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form
context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.html", context)
def add_platform(request): def add_platform(request):
@ -529,17 +129,8 @@ def add_platform(request):
return render(request, "add.html", context) return render(request, "add.html", context)
def add_device(request):
context = {}
form = DeviceForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
def index(request): def index(request):
return redirect("list_sessions_recent") context = {}
context["total_duration"] = Session().duration_sum
context["title"] = "Index"
return render(request, "index.html", context)

View File

@ -1,7 +1,6 @@
{ {
"devDependencies": { "dependencies": {
"@tailwindcss/forms": "^0.5.6", "@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.9"
"tailwindcss": "^3.3.3"
} }
} }

523
poetry.lock generated
View File

@ -96,6 +96,81 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "contourpy"
version = "1.0.7"
description = "Python library for calculating contours of 2D quadrilateral grids"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:95c3acddf921944f241b6773b767f1cbce71d03307270e2d769fd584d5d1092d"},
{file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc1464c97579da9f3ab16763c32e5c5d5bb5fa1ec7ce509a4ca6108b61b84fab"},
{file = "contourpy-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8acf74b5d383414401926c1598ed77825cd530ac7b463ebc2e4f46638f56cce6"},
{file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c71fdd8f1c0f84ffd58fca37d00ca4ebaa9e502fb49825484da075ac0b0b803"},
{file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e9486bf1bb979d95d5cffed40689cb595abb2b841f2991fc894b3452290e8"},
{file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87f4d8941a9564cda3f7fa6a6cd9b32ec575830780677932abdec7bcb61717b0"},
{file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9e20e5a1908e18aaa60d9077a6d8753090e3f85ca25da6e25d30dc0a9e84c2c6"},
{file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a877ada905f7d69b2a31796c4b66e31a8068b37aa9b78832d41c82fc3e056ddd"},
{file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6381fa66866b0ea35e15d197fc06ac3840a9b2643a6475c8fff267db8b9f1e69"},
{file = "contourpy-1.0.7-cp310-cp310-win32.whl", hash = "sha256:3c184ad2433635f216645fdf0493011a4667e8d46b34082f5a3de702b6ec42e3"},
{file = "contourpy-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:3caea6365b13119626ee996711ab63e0c9d7496f65641f4459c60a009a1f3e80"},
{file = "contourpy-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed33433fc3820263a6368e532f19ddb4c5990855e4886088ad84fd7c4e561c71"},
{file = "contourpy-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:38e2e577f0f092b8e6774459317c05a69935a1755ecfb621c0a98f0e3c09c9a5"},
{file = "contourpy-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ae90d5a8590e5310c32a7630b4b8618cef7563cebf649011da80874d0aa8f414"},
{file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130230b7e49825c98edf0b428b7aa1125503d91732735ef897786fe5452b1ec2"},
{file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58569c491e7f7e874f11519ef46737cea1d6eda1b514e4eb5ac7dab6aa864d02"},
{file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d43960d809c4c12508a60b66cb936e7ed57d51fb5e30b513934a4a23874fae"},
{file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:152fd8f730c31fd67fe0ffebe1df38ab6a669403da93df218801a893645c6ccc"},
{file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9056c5310eb1daa33fc234ef39ebfb8c8e2533f088bbf0bc7350f70a29bde1ac"},
{file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a9d7587d2fdc820cc9177139b56795c39fb8560f540bba9ceea215f1f66e1566"},
{file = "contourpy-1.0.7-cp311-cp311-win32.whl", hash = "sha256:4ee3ee247f795a69e53cd91d927146fb16c4e803c7ac86c84104940c7d2cabf0"},
{file = "contourpy-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:5caeacc68642e5f19d707471890f037a13007feba8427eb7f2a60811a1fc1350"},
{file = "contourpy-1.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fd7dc0e6812b799a34f6d12fcb1000539098c249c8da54f3566c6a6461d0dbad"},
{file = "contourpy-1.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0f9d350b639db6c2c233d92c7f213d94d2e444d8e8fc5ca44c9706cf72193772"},
{file = "contourpy-1.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e96a08b62bb8de960d3a6afbc5ed8421bf1a2d9c85cc4ea73f4bc81b4910500f"},
{file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:031154ed61f7328ad7f97662e48660a150ef84ee1bc8876b6472af88bf5a9b98"},
{file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e9ebb4425fc1b658e13bace354c48a933b842d53c458f02c86f371cecbedecc"},
{file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efb8f6d08ca7998cf59eaf50c9d60717f29a1a0a09caa46460d33b2924839dbd"},
{file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6c180d89a28787e4b73b07e9b0e2dac7741261dbdca95f2b489c4f8f887dd810"},
{file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b8d587cc39057d0afd4166083d289bdeff221ac6d3ee5046aef2d480dc4b503c"},
{file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:769eef00437edf115e24d87f8926955f00f7704bede656ce605097584f9966dc"},
{file = "contourpy-1.0.7-cp38-cp38-win32.whl", hash = "sha256:62398c80ef57589bdbe1eb8537127321c1abcfdf8c5f14f479dbbe27d0322e66"},
{file = "contourpy-1.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:57119b0116e3f408acbdccf9eb6ef19d7fe7baf0d1e9aaa5381489bc1aa56556"},
{file = "contourpy-1.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30676ca45084ee61e9c3da589042c24a57592e375d4b138bd84d8709893a1ba4"},
{file = "contourpy-1.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e927b3868bd1e12acee7cc8f3747d815b4ab3e445a28d2e5373a7f4a6e76ba1"},
{file = "contourpy-1.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:366a0cf0fc079af5204801786ad7a1c007714ee3909e364dbac1729f5b0849e5"},
{file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ba9bb365446a22411f0673abf6ee1fea3b2cf47b37533b970904880ceb72f3"},
{file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71b0bf0c30d432278793d2141362ac853859e87de0a7dee24a1cea35231f0d50"},
{file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7281244c99fd7c6f27c1c6bfafba878517b0b62925a09b586d88ce750a016d2"},
{file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b6d0f9e1d39dbfb3977f9dd79f156c86eb03e57a7face96f199e02b18e58d32a"},
{file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7f6979d20ee5693a1057ab53e043adffa1e7418d734c1532e2d9e915b08d8ec2"},
{file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5dd34c1ae752515318224cba7fc62b53130c45ac6a1040c8b7c1a223c46e8967"},
{file = "contourpy-1.0.7-cp39-cp39-win32.whl", hash = "sha256:c5210e5d5117e9aec8c47d9156d1d3835570dd909a899171b9535cb4a3f32693"},
{file = "contourpy-1.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:60835badb5ed5f4e194a6f21c09283dd6e007664a86101431bf870d9e86266c4"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ce41676b3d0dd16dbcfabcc1dc46090aaf4688fd6e819ef343dbda5a57ef0161"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a011cf354107b47c58ea932d13b04d93c6d1d69b8b6dce885e642531f847566"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31a55dccc8426e71817e3fe09b37d6d48ae40aae4ecbc8c7ad59d6893569c436"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69f8ff4db108815addd900a74df665e135dbbd6547a8a69333a68e1f6e368ac2"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efe99298ba37e37787f6a2ea868265465410822f7bea163edcc1bd3903354ea9"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a1e97b86f73715e8670ef45292d7cc033548266f07d54e2183ecb3c87598888f"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc331c13902d0f50845099434cd936d49d7a2ca76cb654b39691974cb1e4812d"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24847601071f740837aefb730e01bd169fbcaa610209779a78db7ebb6e6a7051"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abf298af1e7ad44eeb93501e40eb5a67abbf93b5d90e468d01fc0c4451971afa"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:64757f6460fc55d7e16ed4f1de193f362104285c667c112b50a804d482777edd"},
{file = "contourpy-1.0.7.tar.gz", hash = "sha256:d8165a088d31798b59e91117d1f5fc3df8168d8b48c4acc10fc0df0d0bdbcc5e"},
]
[package.dependencies]
numpy = ">=1.16"
[package.extras]
bokeh = ["bokeh", "chromedriver", "selenium"]
docs = ["furo", "sphinx-copybutton"]
mypy = ["contourpy[bokeh]", "docutils-stubs", "mypy (==0.991)", "types-Pillow"]
test = ["Pillow", "matplotlib", "pytest"]
test-no-images = ["pytest"]
[[package]] [[package]]
name = "cssbeautifier" name = "cssbeautifier"
version = "1.14.7" version = "1.14.7"
@ -112,6 +187,18 @@ editorconfig = ">=0.12.2"
jsbeautifier = "*" jsbeautifier = "*"
six = ">=1.13.0" six = ">=1.13.0"
[[package]]
name = "cycler"
version = "0.11.0"
description = "Composable style cycles"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"},
{file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"},
]
[[package]] [[package]]
name = "django" name = "django"
version = "4.1.5" version = "4.1.5"
@ -133,6 +220,21 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"] argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"] bcrypt = ["bcrypt"]
[[package]]
name = "django-cors-headers"
version = "3.13.0"
description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "django-cors-headers-3.13.0.tar.gz", hash = "sha256:f9dc6b4e3f611c3199700b3e5f3398c28757dcd559c2f82932687f3d0443cfdf"},
{file = "django_cors_headers-3.13.0-py3-none-any.whl", hash = "sha256:37e42883b5f1f2295df6b4bba96eb2417a14a03270cb24b2a07f021cd4487cf4"},
]
[package.dependencies]
Django = ">=3.2"
[[package]] [[package]]
name = "django-extensions" name = "django-extensions"
version = "3.2.1" version = "3.2.1"
@ -148,6 +250,36 @@ files = [
[package.dependencies] [package.dependencies]
Django = ">=3.2" Django = ">=3.2"
[[package]]
name = "django-rest-framework"
version = "0.1.0"
description = "alias."
category = "main"
optional = false
python-versions = "*"
files = [
{file = "django-rest-framework-0.1.0.tar.gz", hash = "sha256:47a8f496fa69e3b6bd79f68dd7a1527d907d6b77f009e9db7cf9bb21cc565e4a"},
]
[package.dependencies]
djangorestframework = "*"
[[package]]
name = "djangorestframework"
version = "3.14.0"
description = "Web APIs for Django, made easy."
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08"},
{file = "djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8"},
]
[package.dependencies]
django = ">=3.0"
pytz = "*"
[[package]] [[package]]
name = "djhtml" name = "djhtml"
version = "1.5.2" version = "1.5.2"
@ -215,6 +347,32 @@ files = [
[package.extras] [package.extras]
test = ["pytest (>=6)"] test = ["pytest (>=6)"]
[[package]]
name = "fonttools"
version = "4.38.0"
description = "Tools to manipulate font files"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "fonttools-4.38.0-py3-none-any.whl", hash = "sha256:820466f43c8be8c3009aef8b87e785014133508f0de64ec469e4efb643ae54fb"},
{file = "fonttools-4.38.0.zip", hash = "sha256:2bb244009f9bf3fa100fc3ead6aeb99febe5985fa20afbfbaa2f8946c2fbdaf1"},
]
[package.extras]
all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=14.0.0)", "xattr", "zopfli (>=0.1.4)"]
graphite = ["lz4 (>=1.7.4.2)"]
interpolatable = ["munkres", "scipy"]
lxml = ["lxml (>=4.0,<5)"]
pathops = ["skia-pathops (>=0.5.0)"]
plot = ["matplotlib"]
repacker = ["uharfbuzz (>=0.23.0)"]
symfont = ["sympy"]
type1 = ["xattr"]
ufo = ["fs (>=2.2.0,<3)"]
unicode = ["unicodedata2 (>=14.0.0)"]
woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "20.1.0" version = "20.1.0"
@ -337,6 +495,84 @@ files = [
editorconfig = ">=0.12.2" editorconfig = ">=0.12.2"
six = ">=1.13.0" six = ">=1.13.0"
[[package]]
name = "kiwisolver"
version = "1.4.4"
description = "A fast implementation of the Cassowary constraint solver"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6"},
{file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c"},
{file = "kiwisolver-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32"},
{file = "kiwisolver-1.4.4-cp310-cp310-win32.whl", hash = "sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408"},
{file = "kiwisolver-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004"},
{file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6"},
{file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2"},
{file = "kiwisolver-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4"},
{file = "kiwisolver-1.4.4-cp311-cp311-win32.whl", hash = "sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e"},
{file = "kiwisolver-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686"},
{file = "kiwisolver-1.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c"},
{file = "kiwisolver-1.4.4-cp37-cp37m-win32.whl", hash = "sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3"},
{file = "kiwisolver-1.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166"},
{file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454"},
{file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0"},
{file = "kiwisolver-1.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c"},
{file = "kiwisolver-1.4.4-cp38-cp38-win32.whl", hash = "sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191"},
{file = "kiwisolver-1.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766"},
{file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8"},
{file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897"},
{file = "kiwisolver-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9"},
{file = "kiwisolver-1.4.4-cp39-cp39-win32.whl", hash = "sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea"},
{file = "kiwisolver-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b"},
{file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a"},
{file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d"},
{file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a"},
{file = "kiwisolver-1.4.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b"},
{file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"},
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "2.1.1" version = "2.1.1"
@ -387,6 +623,68 @@ files = [
{file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
] ]
[[package]]
name = "matplotlib"
version = "3.6.3"
description = "Python plotting package"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "matplotlib-3.6.3-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:80c166a0e28512e26755f69040e6bf2f946a02ffdb7c00bf6158cca3d2b146e6"},
{file = "matplotlib-3.6.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eb9421c403ffd387fbe729de6d9a03005bf42faba5e8432f4e51e703215b49fc"},
{file = "matplotlib-3.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5223affa21050fb6118353c1380c15e23aedfb436bf3e162c26dc950617a7519"},
{file = "matplotlib-3.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00c248ab6b92bea3f8148714837937053a083ff03b4c5e30ed37e28fc0e7e56"},
{file = "matplotlib-3.6.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca94f0362f6b6f424b555b956971dcb94b12d0368a6c3e07dc7a40d32d6d873d"},
{file = "matplotlib-3.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59400cc9451094b7f08cc3f321972e6e1db4cd37a978d4e8a12824bf7fd2f03b"},
{file = "matplotlib-3.6.3-cp310-cp310-win32.whl", hash = "sha256:57ad1aee29043163374bfa8990e1a2a10ff72c9a1bfaa92e9c46f6ea59269121"},
{file = "matplotlib-3.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:1fcc4cad498533d3c393a160975acc9b36ffa224d15a6b90ae579eacee5d8579"},
{file = "matplotlib-3.6.3-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:d2cfaa7fd62294d945b8843ea24228a27c8e7c5b48fa634f3c168153b825a21b"},
{file = "matplotlib-3.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c3f08df2ac4636249b8bc7a85b8b82c983bef1441595936f62c2918370ca7e1d"},
{file = "matplotlib-3.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff2aa84e74f80891e6bcf292ebb1dd57714ffbe13177642d65fee25384a30894"},
{file = "matplotlib-3.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11011c97d62c1db7bc20509572557842dbb8c2a2ddd3dd7f20501aa1cde3e54e"},
{file = "matplotlib-3.6.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c235bf9be052347373f589e018988cad177abb3f997ab1a2e2210c41562cc0c"},
{file = "matplotlib-3.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bebcff4c3ed02c6399d47329f3554193abd824d3d53b5ca02cf583bcd94470e2"},
{file = "matplotlib-3.6.3-cp311-cp311-win32.whl", hash = "sha256:d5f18430f5cfa5571ab8f4c72c89af52aa0618e864c60028f11a857d62200cba"},
{file = "matplotlib-3.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:dfba7057609ca9567b9704626756f0142e97ec8c5ba2c70c6e7bd1c25ef99f06"},
{file = "matplotlib-3.6.3-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:9fb8fb19d03abf3c5dab89a8677e62c4023632f919a62b6dd1d6d2dbf42cd9f5"},
{file = "matplotlib-3.6.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:bbf269e1d24bc25247095d71c7a969813f7080e2a7c6fa28931a603f747ab012"},
{file = "matplotlib-3.6.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:994637e2995b0342699b396a320698b07cd148bbcf2dd2fa2daba73f34dd19f2"},
{file = "matplotlib-3.6.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:77b384cee7ab8cf75ffccbfea351a09b97564fc62d149827a5e864bec81526e5"},
{file = "matplotlib-3.6.3-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:73b93af33634ed919e72811c9703e1105185cd3fb46d76f30b7f4cfbbd063f89"},
{file = "matplotlib-3.6.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:debeab8e2ab07e5e3dac33e12456da79c7e104270d2b2d1df92b9e40347cca75"},
{file = "matplotlib-3.6.3-cp38-cp38-win32.whl", hash = "sha256:acc3b1a4bddbf56fe461e36fb9ef94c2cb607fc90d24ccc650040bfcc7610de4"},
{file = "matplotlib-3.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:1183877d008c752d7d535396096c910f4663e4b74a18313adee1213328388e1e"},
{file = "matplotlib-3.6.3-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:6adc441b5b2098a4b904bbf9d9e92fb816fef50c55aa2ea6a823fc89b94bb838"},
{file = "matplotlib-3.6.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:6d81b11ede69e3a751424b98dc869c96c10256b2206bfdf41f9c720eee86844c"},
{file = "matplotlib-3.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:29f17b7f2e068dc346687cbdf80b430580bab42346625821c2d3abf3a1ec5417"},
{file = "matplotlib-3.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f56a7252eee8f3438447f75f5e1148a1896a2756a92285fe5d73bed6deebff4"},
{file = "matplotlib-3.6.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbddfeb1495484351fb5b30cf5bdf06b3de0bc4626a707d29e43dfd61af2a780"},
{file = "matplotlib-3.6.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:809119d1cba3ece3c9742eb01827fe7a0e781ea3c5d89534655a75e07979344f"},
{file = "matplotlib-3.6.3-cp39-cp39-win32.whl", hash = "sha256:e0a64d7cc336b52e90f59e6d638ae847b966f68582a7af041e063d568e814740"},
{file = "matplotlib-3.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:79e501eb847f4a489eb7065bb8d3187117f65a4c02d12ea3a19d6c5bef173bcc"},
{file = "matplotlib-3.6.3-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2787a16df07370dcba385fe20cdd0cc3cfaabd3c873ddabca78c10514c799721"},
{file = "matplotlib-3.6.3-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68d94a436f62b8a861bf3ace82067a71bafb724b4e4f9133521e4d8012420dd7"},
{file = "matplotlib-3.6.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b409b2790cf8d7c1ef35920f01676d2ae7afa8241844e7aa5484fdf493a9a0"},
{file = "matplotlib-3.6.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:faff486b36530a836a6b4395850322e74211cd81fc17f28b4904e1bd53668e3e"},
{file = "matplotlib-3.6.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:38d38cb1ea1d80ee0f6351b65c6f76cad6060bbbead015720ba001348ae90f0c"},
{file = "matplotlib-3.6.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12f999661589981e74d793ee2f41b924b3b87d65fd929f6153bf0f30675c59b1"},
{file = "matplotlib-3.6.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01b7f521a9a73c383825813af255f8c4485d1706e4f3e2ed5ae771e4403a40ab"},
{file = "matplotlib-3.6.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:9ceebaf73f1a3444fa11014f38b9da37ff7ea328d6efa1652241fe3777bfdab9"},
{file = "matplotlib-3.6.3.tar.gz", hash = "sha256:1f4d69707b1677560cd952544ee4962f68ff07952fb9069ff8c12b56353cb8c9"},
]
[package.dependencies]
contourpy = ">=1.0.1"
cycler = ">=0.10"
fonttools = ">=4.22.0"
kiwisolver = ">=1.0.1"
numpy = ">=1.19"
packaging = ">=20.0"
pillow = ">=6.2.0"
pyparsing = ">=2.2.1"
python-dateutil = ">=2.7"
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "0.991" version = "0.991"
@ -450,11 +748,49 @@ files = [
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
] ]
[[package]]
name = "numpy"
version = "1.24.1"
description = "Fundamental package for array computing in Python"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "numpy-1.24.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:179a7ef0889ab769cc03573b6217f54c8bd8e16cef80aad369e1e8185f994cd7"},
{file = "numpy-1.24.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b09804ff570b907da323b3d762e74432fb07955701b17b08ff1b5ebaa8cfe6a9"},
{file = "numpy-1.24.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b739841821968798947d3afcefd386fa56da0caf97722a5de53e07c4ccedc7"},
{file = "numpy-1.24.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e3463e6ac25313462e04aea3fb8a0a30fb906d5d300f58b3bc2c23da6a15398"},
{file = "numpy-1.24.1-cp310-cp310-win32.whl", hash = "sha256:b31da69ed0c18be8b77bfce48d234e55d040793cebb25398e2a7d84199fbc7e2"},
{file = "numpy-1.24.1-cp310-cp310-win_amd64.whl", hash = "sha256:b07b40f5fb4fa034120a5796288f24c1fe0e0580bbfff99897ba6267af42def2"},
{file = "numpy-1.24.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7094891dcf79ccc6bc2a1f30428fa5edb1e6fb955411ffff3401fb4ea93780a8"},
{file = "numpy-1.24.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e418681372520c992805bb723e29d69d6b7aa411065f48216d8329d02ba032"},
{file = "numpy-1.24.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e274f0f6c7efd0d577744f52032fdd24344f11c5ae668fe8d01aac0422611df1"},
{file = "numpy-1.24.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0044f7d944ee882400890f9ae955220d29b33d809a038923d88e4e01d652acd9"},
{file = "numpy-1.24.1-cp311-cp311-win32.whl", hash = "sha256:442feb5e5bada8408e8fcd43f3360b78683ff12a4444670a7d9e9824c1817d36"},
{file = "numpy-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:de92efa737875329b052982e37bd4371d52cabf469f83e7b8be9bb7752d67e51"},
{file = "numpy-1.24.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b162ac10ca38850510caf8ea33f89edcb7b0bb0dfa5592d59909419986b72407"},
{file = "numpy-1.24.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:26089487086f2648944f17adaa1a97ca6aee57f513ba5f1c0b7ebdabbe2b9954"},
{file = "numpy-1.24.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caf65a396c0d1f9809596be2e444e3bd4190d86d5c1ce21f5fc4be60a3bc5b36"},
{file = "numpy-1.24.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0677a52f5d896e84414761531947c7a330d1adc07c3a4372262f25d84af7bf7"},
{file = "numpy-1.24.1-cp38-cp38-win32.whl", hash = "sha256:dae46bed2cb79a58d6496ff6d8da1e3b95ba09afeca2e277628171ca99b99db1"},
{file = "numpy-1.24.1-cp38-cp38-win_amd64.whl", hash = "sha256:6ec0c021cd9fe732e5bab6401adea5a409214ca5592cd92a114f7067febcba0c"},
{file = "numpy-1.24.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28bc9750ae1f75264ee0f10561709b1462d450a4808cd97c013046073ae64ab6"},
{file = "numpy-1.24.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:84e789a085aabef2f36c0515f45e459f02f570c4b4c4c108ac1179c34d475ed7"},
{file = "numpy-1.24.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e669fbdcdd1e945691079c2cae335f3e3a56554e06bbd45d7609a6cf568c700"},
{file = "numpy-1.24.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef85cf1f693c88c1fd229ccd1055570cb41cdf4875873b7728b6301f12cd05bf"},
{file = "numpy-1.24.1-cp39-cp39-win32.whl", hash = "sha256:87a118968fba001b248aac90e502c0b13606721b1343cdaddbc6e552e8dfb56f"},
{file = "numpy-1.24.1-cp39-cp39-win_amd64.whl", hash = "sha256:ddc7ab52b322eb1e40521eb422c4e0a20716c271a306860979d450decbb51b8e"},
{file = "numpy-1.24.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed5fb71d79e771ec930566fae9c02626b939e37271ec285e9efaf1b5d4370e7d"},
{file = "numpy-1.24.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad2925567f43643f51255220424c23d204024ed428afc5aad0f86f3ffc080086"},
{file = "numpy-1.24.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cfa1161c6ac8f92dea03d625c2d0c05e084668f4a06568b77a25a89111621566"},
{file = "numpy-1.24.1.tar.gz", hash = "sha256:2386da9a471cc00a1f47845e27d916d5ec5346ae9696e01a8a34760858fe9dd2"},
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "22.0" version = "22.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@ -462,6 +798,54 @@ files = [
{file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"}, {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"},
] ]
[[package]]
name = "pandas"
version = "1.5.2"
description = "Powerful data structures for data analysis, time series, and statistics"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "pandas-1.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e9dbacd22555c2d47f262ef96bb4e30880e5956169741400af8b306bbb24a273"},
{file = "pandas-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e2b83abd292194f350bb04e188f9379d36b8dfac24dd445d5c87575f3beaf789"},
{file = "pandas-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2552bffc808641c6eb471e55aa6899fa002ac94e4eebfa9ec058649122db5824"},
{file = "pandas-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fc87eac0541a7d24648a001d553406f4256e744d92df1df8ebe41829a915028"},
{file = "pandas-1.5.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0d8fd58df5d17ddb8c72a5075d87cd80d71b542571b5f78178fb067fa4e9c72"},
{file = "pandas-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:4aed257c7484d01c9a194d9a94758b37d3d751849c05a0050c087a358c41ad1f"},
{file = "pandas-1.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:375262829c8c700c3e7cbb336810b94367b9c4889818bbd910d0ecb4e45dc261"},
{file = "pandas-1.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc3cd122bea268998b79adebbb8343b735a5511ec14efb70a39e7acbc11ccbdc"},
{file = "pandas-1.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4f5a82afa4f1ff482ab8ded2ae8a453a2cdfde2001567b3ca24a4c5c5ca0db3"},
{file = "pandas-1.5.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8092a368d3eb7116e270525329a3e5c15ae796ccdf7ccb17839a73b4f5084a39"},
{file = "pandas-1.5.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6257b314fc14958f8122779e5a1557517b0f8e500cfb2bd53fa1f75a8ad0af2"},
{file = "pandas-1.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:82ae615826da838a8e5d4d630eb70c993ab8636f0eff13cb28aafc4291b632b5"},
{file = "pandas-1.5.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:457d8c3d42314ff47cc2d6c54f8fc0d23954b47977b2caed09cd9635cb75388b"},
{file = "pandas-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c009a92e81ce836212ce7aa98b219db7961a8b95999b97af566b8dc8c33e9519"},
{file = "pandas-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:71f510b0efe1629bf2f7c0eadb1ff0b9cf611e87b73cd017e6b7d6adb40e2b3a"},
{file = "pandas-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a40dd1e9f22e01e66ed534d6a965eb99546b41d4d52dbdb66565608fde48203f"},
{file = "pandas-1.5.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae7e989f12628f41e804847a8cc2943d362440132919a69429d4dea1f164da0"},
{file = "pandas-1.5.2-cp38-cp38-win32.whl", hash = "sha256:530948945e7b6c95e6fa7aa4be2be25764af53fba93fe76d912e35d1c9ee46f5"},
{file = "pandas-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:73f219fdc1777cf3c45fde7f0708732ec6950dfc598afc50588d0d285fddaefc"},
{file = "pandas-1.5.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9608000a5a45f663be6af5c70c3cbe634fa19243e720eb380c0d378666bc7702"},
{file = "pandas-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:315e19a3e5c2ab47a67467fc0362cb36c7c60a93b6457f675d7d9615edad2ebe"},
{file = "pandas-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e18bc3764cbb5e118be139b3b611bc3fbc5d3be42a7e827d1096f46087b395eb"},
{file = "pandas-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0183cb04a057cc38fde5244909fca9826d5d57c4a5b7390c0cc3fa7acd9fa883"},
{file = "pandas-1.5.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:344021ed3e639e017b452aa8f5f6bf38a8806f5852e217a7594417fb9bbfa00e"},
{file = "pandas-1.5.2-cp39-cp39-win32.whl", hash = "sha256:e7469271497960b6a781eaa930cba8af400dd59b62ec9ca2f4d31a19f2f91090"},
{file = "pandas-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:c218796d59d5abd8780170c937b812c9637e84c32f8271bbf9845970f8c1351f"},
{file = "pandas-1.5.2.tar.gz", hash = "sha256:220b98d15cee0b2cd839a6358bd1f273d0356bf964c1a1aeb32d47db0215488b"},
]
[package.dependencies]
numpy = [
{version = ">=1.21.0", markers = "python_version >= \"3.10\""},
{version = ">=1.23.2", markers = "python_version >= \"3.11\""},
]
python-dateutil = ">=2.8.1"
pytz = ">=2020.1"
[package.extras]
test = ["hypothesis (>=5.5.3)", "pytest (>=6.0)", "pytest-xdist (>=1.31)"]
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "0.10.3" version = "0.10.3"
@ -474,6 +858,97 @@ files = [
{file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"}, {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"},
] ]
[[package]]
name = "pillow"
version = "9.4.0"
description = "Python Imaging Library (Fork)"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "Pillow-9.4.0-1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1"},
{file = "Pillow-9.4.0-1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12"},
{file = "Pillow-9.4.0-1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd"},
{file = "Pillow-9.4.0-1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9"},
{file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
{file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
{file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
{file = "Pillow-9.4.0-2-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:9d9a62576b68cd90f7075876f4e8444487db5eeea0e4df3ba298ee38a8d067b0"},
{file = "Pillow-9.4.0-2-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:87708d78a14d56a990fbf4f9cb350b7d89ee8988705e58e39bdf4d82c149210f"},
{file = "Pillow-9.4.0-2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:8a2b5874d17e72dfb80d917213abd55d7e1ed2479f38f001f264f7ce7bae757c"},
{file = "Pillow-9.4.0-2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:83125753a60cfc8c412de5896d10a0a405e0bd88d0470ad82e0869ddf0cb3848"},
{file = "Pillow-9.4.0-2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:9e5f94742033898bfe84c93c831a6f552bb629448d4072dd312306bab3bd96f1"},
{file = "Pillow-9.4.0-2-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:013016af6b3a12a2f40b704677f8b51f72cb007dac785a9933d5c86a72a7fe33"},
{file = "Pillow-9.4.0-2-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:99d92d148dd03fd19d16175b6d355cc1b01faf80dae93c6c3eb4163709edc0a9"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a3049a10261d7f2b6514d35bbb7a4dfc3ece4c4de14ef5876c4b7a23a0e566d"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16a8df99701f9095bea8a6c4b3197da105df6f74e6176c5b410bc2df2fd29a57"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:94cdff45173b1919350601f82d61365e792895e3c3a3443cf99819e6fbf717a5"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ed3e4b4e1e6de75fdc16d3259098de7c6571b1a6cc863b1a49e7d3d53e036070"},
{file = "Pillow-9.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5b2f8a31bd43e0f18172d8ac82347c8f37ef3e0b414431157718aa234991b28"},
{file = "Pillow-9.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:09b89ddc95c248ee788328528e6a2996e09eaccddeeb82a5356e92645733be35"},
{file = "Pillow-9.4.0-cp310-cp310-win32.whl", hash = "sha256:f09598b416ba39a8f489c124447b007fe865f786a89dbfa48bb5cf395693132a"},
{file = "Pillow-9.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6e78171be3fb7941f9910ea15b4b14ec27725865a73c15277bc39f5ca4f8391"},
{file = "Pillow-9.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:3fa1284762aacca6dc97474ee9c16f83990b8eeb6697f2ba17140d54b453e133"},
{file = "Pillow-9.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eaef5d2de3c7e9b21f1e762f289d17b726c2239a42b11e25446abf82b26ac132"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4dfdae195335abb4e89cc9762b2edc524f3c6e80d647a9a81bf81e17e3fb6f0"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6abfb51a82e919e3933eb137e17c4ae9c0475a25508ea88993bb59faf82f3b35"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451f10ef963918e65b8869e17d67db5e2f4ab40e716ee6ce7129b0cde2876eab"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:6663977496d616b618b6cfa43ec86e479ee62b942e1da76a2c3daa1c75933ef4"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60e7da3a3ad1812c128750fc1bc14a7ceeb8d29f77e0a2356a8fb2aa8925287d"},
{file = "Pillow-9.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:19005a8e58b7c1796bc0167862b1f54a64d3b44ee5d48152b06bb861458bc0f8"},
{file = "Pillow-9.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f715c32e774a60a337b2bb8ad9839b4abf75b267a0f18806f6f4f5f1688c4b5a"},
{file = "Pillow-9.4.0-cp311-cp311-win32.whl", hash = "sha256:b222090c455d6d1a64e6b7bb5f4035c4dff479e22455c9eaa1bdd4c75b52c80c"},
{file = "Pillow-9.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba6612b6548220ff5e9df85261bddc811a057b0b465a1226b39bfb8550616aee"},
{file = "Pillow-9.4.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:5f532a2ad4d174eb73494e7397988e22bf427f91acc8e6ebf5bb10597b49c493"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dd5a9c3091a0f414a963d427f920368e2b6a4c2f7527fdd82cde8ef0bc7a327"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef21af928e807f10bf4141cad4746eee692a0dd3ff56cfb25fce076ec3cc8abe"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:847b114580c5cc9ebaf216dd8c8dbc6b00a3b7ab0131e173d7120e6deade1f57"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:653d7fb2df65efefbcbf81ef5fe5e5be931f1ee4332c2893ca638c9b11a409c4"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:46f39cab8bbf4a384ba7cb0bc8bae7b7062b6a11cfac1ca4bc144dea90d4a9f5"},
{file = "Pillow-9.4.0-cp37-cp37m-win32.whl", hash = "sha256:7ac7594397698f77bce84382929747130765f66406dc2cd8b4ab4da68ade4c6e"},
{file = "Pillow-9.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:46c259e87199041583658457372a183636ae8cd56dbf3f0755e0f376a7f9d0e6"},
{file = "Pillow-9.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:0e51f608da093e5d9038c592b5b575cadc12fd748af1479b5e858045fff955a9"},
{file = "Pillow-9.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:765cb54c0b8724a7c12c55146ae4647e0274a839fb6de7bcba841e04298e1011"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:519e14e2c49fcf7616d6d2cfc5c70adae95682ae20f0395e9280db85e8d6c4df"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d197df5489004db87d90b918033edbeee0bd6df3848a204bca3ff0a903bef837"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0845adc64fe9886db00f5ab68c4a8cd933ab749a87747555cec1c95acea64b0b"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:e1339790c083c5a4de48f688b4841f18df839eb3c9584a770cbd818b33e26d5d"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:a96e6e23f2b79433390273eaf8cc94fec9c6370842e577ab10dabdcc7ea0a66b"},
{file = "Pillow-9.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7cfc287da09f9d2a7ec146ee4d72d6ea1342e770d975e49a8621bf54eaa8f30f"},
{file = "Pillow-9.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d7081c084ceb58278dd3cf81f836bc818978c0ccc770cbbb202125ddabec6628"},
{file = "Pillow-9.4.0-cp38-cp38-win32.whl", hash = "sha256:df41112ccce5d47770a0c13651479fbcd8793f34232a2dd9faeccb75eb5d0d0d"},
{file = "Pillow-9.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:7a21222644ab69ddd9967cfe6f2bb420b460dae4289c9d40ff9a4896e7c35c9a"},
{file = "Pillow-9.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0f3269304c1a7ce82f1759c12ce731ef9b6e95b6df829dccd9fe42912cc48569"},
{file = "Pillow-9.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cb362e3b0976dc994857391b776ddaa8c13c28a16f80ac6522c23d5257156bed"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2e0f87144fcbbe54297cae708c5e7f9da21a4646523456b00cc956bd4c65815"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28676836c7796805914b76b1837a40f76827ee0d5398f72f7dcc634bae7c6264"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0884ba7b515163a1a05440a138adeb722b8a6ae2c2b33aea93ea3118dd3a899e"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:53dcb50fbdc3fb2c55431a9b30caeb2f7027fcd2aeb501459464f0214200a503"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:e8c5cf126889a4de385c02a2c3d3aba4b00f70234bfddae82a5eaa3ee6d5e3e6"},
{file = "Pillow-9.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c6b1389ed66cdd174d040105123a5a1bc91d0aa7059c7261d20e583b6d8cbd2"},
{file = "Pillow-9.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0dd4c681b82214b36273c18ca7ee87065a50e013112eea7d78c7a1b89a739153"},
{file = "Pillow-9.4.0-cp39-cp39-win32.whl", hash = "sha256:6d9dfb9959a3b0039ee06c1a1a90dc23bac3b430842dcb97908ddde05870601c"},
{file = "Pillow-9.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:54614444887e0d3043557d9dbc697dbb16cfb5a35d672b7a0fcc1ed0cf1c600b"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b9b752ab91e78234941e44abdecc07f1f0d8f51fb62941d32995b8161f68cfe5"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3b56206244dc8711f7e8b7d6cad4663917cd5b2d950799425076681e8766286"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aabdab8ec1e7ca7f1434d042bf8b1e92056245fb179790dc97ed040361f16bfd"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:db74f5562c09953b2c5f8ec4b7dfd3f5421f31811e97d1dbc0a7c93d6e3a24df"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e9d7747847c53a16a729b6ee5e737cf170f7a16611c143d95aa60a109a59c336"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b52ff4f4e002f828ea6483faf4c4e8deea8d743cf801b74910243c58acc6eda3"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:575d8912dca808edd9acd6f7795199332696d3469665ef26163cd090fa1f8bfa"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c4ed2ff6760e98d262e0cc9c9a7f7b8a9f61aa4d47c58835cdaf7b0b8811bb"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e621b0246192d3b9cb1dc62c78cfa4c6f6d2ddc0ec207d43c0dedecb914f152a"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8f127e7b028900421cad64f51f75c051b628db17fb00e099eb148761eed598c9"},
{file = "Pillow-9.4.0.tar.gz", hash = "sha256:a1c2d7780448eb93fbcc3789bf3916aa5720d942e37945f4056680317f1cd23e"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinxext-opengraph"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "2.6.2" version = "2.6.2"
@ -506,6 +981,21 @@ files = [
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"] testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main"
optional = false
python-versions = ">=3.6.8"
files = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.2.0" version = "7.2.0"
@ -530,6 +1020,33 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "python-dateutil"
version = "2.8.2"
description = "Extensions to the standard Python datetime module"
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pytz"
version = "2022.7.1"
description = "World timezone definitions, modern and historical"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"},
{file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"},
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0" version = "6.0"
@ -699,7 +1216,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
description = "Python 2 and 3 compatibility utilities" description = "Python 2 and 3 compatibility utilities"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [ files = [
@ -832,4 +1349,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "7b531f96813f56c815a432a814e0aa75600946f37fe8a009d2b5edd8c9ce5d09" content-hash = "7a7ba3831802cf91b722b956817a0606b7b8f97724b1a23e5e581657fb34ea19"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.1" version = "1.0.0"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
@ -12,6 +12,10 @@ python = "^3.10"
django = "^4.1.4" django = "^4.1.4"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
pandas = "^1.5.2"
matplotlib = "^3.6.3"
django-rest-framework = "^0.1.0"
django-cors-headers = "^3.13.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.12.0" black = "^22.12.0"

View File

@ -1,19 +0,0 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
darkMode: 'class',
content: ["./games/**/*.{html,js}"],
theme: {
extend: {
fontFamily: {
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
}
},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/forms')
],
}

View File

@ -1,89 +0,0 @@
import django
import os
from django.test import TestCase
from django.urls import reverse
from datetime import datetime
from zoneinfo import ZoneInfo
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Game, Edition, Purchase, Session, Platform
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class PathWorksTest(TestCase):
def setUp(self) -> None:
pl = Platform(name="Test Platform")
pl.save()
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition", platform=pl)
e.save()
p = Purchase(
edition=e,
platform=pl,
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.testSession = s
return super().setUp()
def test_add_device_returns_200(self):
url = reverse("add_device")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_platform_returns_200(self):
url = reverse("add_platform")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_game_returns_200(self):
url = reverse("add_game")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_edition_returns_200(self):
url = reverse("add_edition")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_purchase_returns_200(self):
url = reverse("add_purchase")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_session_returns_200(self):
url = reverse("add_session")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_session_returns_200(self):
id = self.testSession.id
url = reverse("edit_session", args=[id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_view_game_returns_200(self):
url = reverse("view_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_game_returns_200(self):
url = reverse("edit_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_list_sessions_returns_200(self):
url = reverse("list_sessions")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View File

@ -1,38 +0,0 @@
import django
import os
from django.test import TestCase
from django.db import models
from datetime import datetime
from zoneinfo import ZoneInfo
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Game, Edition, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class FormatDurationTest(TestCase):
def setUp(self) -> None:
return super().setUp()
def test_duration_format(self):
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition")
e.save()
p = Purchase(
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.assertEqual(
s.duration_formatted(),
"02:40",
)

View File

@ -6,6 +6,7 @@ from common.time import format_duration
class FormatDurationTest(unittest.TestCase): class FormatDurationTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
return super().setUp() return super().setUp()
def test_only_days(self): def test_only_days(self):
@ -18,21 +19,6 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%H hours") result = format_duration(delta, "%H hours")
self.assertEqual(result, "1 hours") self.assertEqual(result, "1 hours")
def test_only_hours_fractional(self):
delta = timedelta(hours=1)
result = format_duration(delta, "%.1H hours")
self.assertEqual(result, "1.0 hours")
def test_less_than_hour_with_precision(self):
delta = timedelta(hours=0.5)
result = format_duration(delta, "%.1H hours")
self.assertEqual(result, "0.5 hours")
def test_less_than_hour_without_precision(self):
delta = timedelta(hours=0.5)
result = format_duration(delta, "%H hours")
self.assertEqual(result, "0 hours")
def test_overflow_hours(self): def test_overflow_hours(self):
delta = timedelta(hours=25) delta = timedelta(hours=25)
result = format_duration(delta, "%H hours") result = format_duration(delta, "%H hours")
@ -83,16 +69,6 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%r seconds") result = format_duration(delta, "%r seconds")
self.assertEqual(result, "0 seconds") self.assertEqual(result, "0 seconds")
def test_specific(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%H:%m")
self.assertEqual(result, "2:40")
def test_specific_precise_if_unncessary(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%02.0H:%02.0m")
self.assertEqual(result, "02:40")
def test_all_at_once(self): def test_all_at_once(self):
delta = timedelta(days=50, hours=10, minutes=34, seconds=24) delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
result = format_duration( result = format_duration(

View File

@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
import os import os
from pathlib import Path from pathlib import Path
from corsheaders.defaults import default_headers, default_methods
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -38,13 +39,16 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"rest_framework",
"corsheaders",
] ]
# if DEBUG: if DEBUG:
INSTALLED_APPS.append("django_extensions") INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin") INSTALLED_APPS.append("django.contrib.admin")
MIDDLEWARE = [ MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
@ -68,7 +72,6 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range",
], ],
}, },
}, },
@ -150,3 +153,24 @@ if _csrf_trusted_origins:
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",") CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
else: else:
CSRF_TRUSTED_ORIGINS = [] CSRF_TRUSTED_ORIGINS = []
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly"
]
}
FRONTEND_ROOT = os.path.abspath(os.path.join(BASE_DIR, "..", "frontend", "dist"))
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = list(default_headers) + [
"Accept-Language",
"Connection",
"Host",
"Origin",
"Referer",
"Sec-Fetch-Dest",
"Sec-Fetch-Mode",
"Sec-Fetch-Site",
]

View File

@ -17,12 +17,65 @@ from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from rest_framework import routers, serializers, viewsets
from games.models import Game, Purchase, Platform, Session
class GameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Game
fields = "__all__"
class PlatformSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Platform
fields = "__all__"
class PurchaseSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Purchase
fields = "__all__"
class SessionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Session
fields = "__all__"
class GameViewSet(viewsets.ModelViewSet):
queryset = Game.objects.all()
serializer_class = GameSerializer
class PlatformViewSet(viewsets.ModelViewSet):
queryset = Platform.objects.all()
serializer_class = PlatformSerializer
class PurchaseViewSet(viewsets.ModelViewSet):
queryset = Purchase.objects.all()
serializer_class = PurchaseSerializer
class SessionViewSet(viewsets.ModelViewSet):
queryset = Session.objects.all()
serializer_class = SessionSerializer
router = routers.DefaultRouter()
router.register(r"games", GameViewSet)
router.register(r"platforms", PlatformViewSet)
router.register(r"purchases", PurchaseViewSet)
router.register(r"sessions", SessionViewSet)
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker")), path("api/", include(router.urls)),
path("tracker/", include("games.urls")), path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
] ]
# if settings.DEBUG: if settings.DEBUG:
# urlpatterns.append(path("admin/", admin.site.urls)) urlpatterns.append(path("admin/", admin.site.urls))
urlpatterns.append(path("admin/", admin.site.urls))