18 Commits

Author SHA1 Message Date
ac8cd6534a Version 1.0.2
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 21:48:55 +01:00
51d8e953c0 Allow editing editions 2023-02-18 21:47:25 +01:00
2eec677f41 Allow editing purchases 2023-02-18 21:44:19 +01:00
f2eb14d3ef Fix session starting 2023-02-18 21:43:51 +01:00
c337d2200f Fix ownership display
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 21:12:44 +01:00
8a8b05b0bd Add support for device info
Closes #49
2023-02-18 21:12:18 +01:00
9446065271 Add support for purchase ownership information
Closes #48
2023-02-18 20:57:03 +01:00
755093845d Add support for prices on purchases 2023-02-18 20:56:23 +01:00
d4ab0596da Re-introduce index view
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 20:50:36 +01:00
8dcbe2f0ad Fix displaying of "filterying by..." text 2023-02-18 20:50:19 +01:00
25bc74eff1 Add support for game editions (#28) 2023-02-18 20:49:46 +01:00
8a7d083fb2 Fix make css 2023-02-18 20:35:59 +01:00
8296ebcf31 Fix redirect to index
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-06 17:23:44 +01:00
04a4f2e0be Order homepage sessions from newest
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-31 16:37:44 +01:00
4070b4e46e Version 1.0.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-30 22:17:47 +01:00
4892218c83 Show only last 30 days on homepage
Fixes #47
2023-01-30 22:16:28 +01:00
6b00a950ce Show markers on smaller graphs 2023-01-30 22:01:27 +01:00
feee9d6dac Make it possible to edit sessions
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #46
2023-01-30 17:38:44 +01:00
41 changed files with 484 additions and 639 deletions

View File

@ -25,7 +25,9 @@ steps:
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
auto_tag: true tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when: when:
branch: branch:
exclude: exclude:

View File

@ -1,6 +1,15 @@
## Unrelease ## 1.0.2 / 2023-02-18 21:48+01:00
* Add React * 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,7 +6,7 @@ RUN npm install && \
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 1.0.0 ENV VERSION_NUMBER 1.0.2
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

View File

@ -1,12 +1,18 @@
all: migrate all: css migrate
initialize: npm migrate sethookdir loadplatforms initialize: npm css 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

@ -64,7 +64,7 @@ def get_chart(data, title="", xlabel="", ylabel=""):
plt.switch_backend("SVG") plt.switch_backend("SVG")
fig, ax = plt.subplots() fig, ax = plt.subplots()
fig.set_size_inches(10, 4) fig.set_size_inches(10, 4)
ax.plot(x, y) lines = ax.plot(x, y, "-o")
first = x[0] first = x[0]
last = x[-1] last = x[-1]
difference = last - first difference = last - first
@ -76,7 +76,11 @@ def get_chart(data, title="", xlabel="", ylabel=""):
elif difference.days < 720: elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator()) ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator()) ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
for line in lines:
line.set_marker("")
else: else:
for line in lines:
line.set_marker("")
ax.xaxis.set_major_locator(mdates.YearLocator()) ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator()) ax.xaxis.set_minor_locator(mdates.MonthLocator())

View File

@ -1,17 +0,0 @@
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
View File

@ -1,24 +0,0 @@
# 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?

View File

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

View File

@ -1,17 +0,0 @@
<!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>

View File

@ -1,33 +0,0 @@
{
"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

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

View File

View File

@ -1,20 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,162 +0,0 @@
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

@ -1,16 +0,0 @@
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>
)
}

View File

@ -1,34 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,12 +0,0 @@
/** @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")],
};

View File

@ -1,15 +0,0 @@
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,10 +1,12 @@
from django import forms from django import forms
from games.models import Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session, Edition, Device
class SessionForm(forms.ModelForm): class SessionForm(forms.ModelForm):
purchase = forms.ModelChoiceField(queryset=Purchase.objects.order_by("game__name")) purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name")
)
class Meta: class Meta:
model = Session model = Session
@ -13,17 +15,32 @@ class SessionForm(forms.ModelForm):
"timestamp_start", "timestamp_start",
"timestamp_end", "timestamp_end",
"duration_manual", "duration_manual",
"device",
"note", "note",
] ]
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
game = forms.ModelChoiceField(queryset=Game.objects.order_by("name")) edition = forms.ModelChoiceField(queryset=Edition.objects.order_by("name"))
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
class Meta: class Meta:
model = Purchase model = Purchase
fields = ["game", "platform", "date_purchased", "date_refunded"] fields = [
"edition",
"platform",
"date_purchased",
"date_refunded",
"price",
"price_currency",
"ownership_type",
]
class EditionForm(forms.ModelForm):
class Meta:
model = Edition
fields = ["game", "name", "platform"]
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
@ -36,3 +53,9 @@ class PlatformForm(forms.ModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ["name", "group"] fields = ["name", "group"]
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]

View File

@ -0,0 +1,41 @@
# 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

@ -0,0 +1,34 @@
# 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

@ -0,0 +1,21 @@
# 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

@ -0,0 +1,18 @@
# 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

@ -0,0 +1,23 @@
# 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

@ -0,0 +1,31 @@
# 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

@ -0,0 +1,52 @@
# 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

@ -16,14 +16,47 @@ class Game(models.Model):
return self.name return self.name
class Purchase(models.Model): class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE) game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
def __str__(self):
return self.name
class Purchase(models.Model):
PHYSICAL = "ph"
DIGITAL = "di"
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"),
]
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE) platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = 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
)
def __str__(self): def __str__(self):
return f"{self.game} ({self.platform})" return f"{self.edition} ({self.platform}, {self.get_ownership_type_display()})"
class Platform(models.Model): class Platform(models.Model):
@ -48,6 +81,7 @@ 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)
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
@ -85,3 +119,23 @@ class Session(models.Model):
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
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"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

View File

View File

@ -17,7 +17,7 @@
<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 'index' %}" class="flex items-center"> <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"></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>
@ -27,10 +27,14 @@
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
{% if game_available and platform_available %} {% if game_available and platform_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li>
{% endif %}
{% if edition_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
{% endif %} {% endif %}
{% if purchase_available %} {% if purchase_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
{% endif %} {% endif %}
{% if session_count > 0 %} {% if session_count > 0 %}
<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>

View File

@ -1,6 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% 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"> <div class="text-center text-xl mb-4 dark:text-slate-400">
@ -10,15 +10,18 @@
{% if dataset.count >= 1 %} {% if dataset.count >= 1 %}
<div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div> <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
{% endif %} {% endif %}
{% if purchase or platform or game %} {% if purchase or platform or edition %}
<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"> <span class="block">
<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">
<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" /> <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" />
</svg> </svg>
</a><span>Filtering by "{% firstof purchase platform game %}"</span> </a>Filtering by "{% firstof purchase platform edition %}"
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %} </span>
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_edition' purchase.edition.id %}">See all platforms</a>{% endif %}
{% endif %} {% endif %}
{% if dataset.count >= 1 %} {% if dataset.count >= 1 %}
<a class="clear-both" href="{% url 'start_session' last.purchase.id %}"> <a href="{% url 'start_session' last.id %}">
<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 "> <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 ">
<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"> <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" /> <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" />
@ -36,7 +39,7 @@
<div class="dark:border-white dark:text-slate-300 text-lg">Duration</div> <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
<div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div> <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
{% for data in dataset %} {% 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> <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.edition }}</a></div>
<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> <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>
<div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div> <div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
<div class="dark:text-slate-400 text-center"> <div class="dark:text-slate-400 text-center">
@ -62,12 +65,14 @@
</button> </button>
</a> </a>
{% endif %} {% endif %}
<a href="{% url 'edit_session' data.id %}">
<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 "> <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 ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <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="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" /> <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> </svg>
</button> </button>
</a>
<a href="{% url 'delete_session' data.id %}"> <a href="{% url 'delete_session' data.id %}">
<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 "> <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 ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">

View File

@ -4,6 +4,12 @@ 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"),
@ -13,7 +19,7 @@ urlpatterns = [
name="update_session", name="update_session",
), ),
path( path(
"start-session/<int:purchase_id>", "start-session/<int:last_session_id>",
views.start_session, views.start_session,
name="start_session", name="start_session",
), ),
@ -23,6 +29,11 @@ urlpatterns = [
name="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-edition/", views.add_edition, name="add_edition"),
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
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>",
@ -37,9 +48,9 @@ urlpatterns = [
name="list_sessions_by_platform", name="list_sessions_by_platform",
), ),
path( path(
"list-sessions/by-game/<int:game_id>", "list-sessions/by-edition/<int:edition_id>",
views.list_sessions, views.list_sessions,
{"filter": "game"}, {"filter": "edition"},
name="list_sessions_by_game", name="list_sessions_by_edition",
), ),
] ]

View File

@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from common.plots import playtime_over_time_chart from common.plots import playtime_over_time_chart
@ -6,13 +6,21 @@ from common.time import now as now_with_tz
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from .forms import GameForm, PlatformForm, PurchaseForm, SessionForm from .forms import (
from .models import Game, Platform, Purchase, Session GameForm,
PlatformForm,
PurchaseForm,
SessionForm,
EditionForm,
DeviceForm,
)
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(),
@ -47,8 +55,51 @@ def update_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def start_session(request, purchase_id=None): def edit_session(request, session_id=None):
session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()}) context = {}
session = Session.objects.get(id=session_id)
form = SessionForm(request.POST or None, instance=session)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add.html", context)
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
return render(request, "add.html", context)
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)
def start_session(request, last_session_id: int):
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() session.save()
return redirect("list_sessions") return redirect("list_sessions")
@ -59,8 +110,9 @@ def delete_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""): def list_sessions(request, filter="", purchase_id="", platform_id="", edition_id=""):
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)
@ -68,9 +120,14 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""
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 == "game": elif filter == "edition":
dataset = Session.objects.filter(purchase__game=game_id) dataset = Session.objects.filter(purchase__edition=edition_id)
context["game"] = Game.objects.get(id=game_id) context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "recent":
dataset = Session.objects.filter(
timestamp_start__gte=datetime.now() - timedelta(days=30)
).order_by("-timestamp_start")
context["title"] = "Last 30 days"
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")
@ -117,6 +174,18 @@ def add_game(request):
return render(request, "add.html", context) return render(request, "add.html", context)
def add_edition(request):
context = {}
form = EditionForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Edition"
return render(request, "add.html", context)
def add_platform(request): def add_platform(request):
context = {} context = {}
form = PlatformForm(request.POST or None) form = PlatformForm(request.POST or None)
@ -129,8 +198,17 @@ def add_platform(request):
return render(request, "add.html", context) return render(request, "add.html", context)
def index(request): def add_device(request):
context = {} context = {}
context["total_duration"] = Session().duration_sum form = DeviceForm(request.POST or None)
context["title"] = "Index" if form.is_valid():
return render(request, "index.html", context) form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
def index(request):
return redirect("list_sessions_recent")

View File

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

54
poetry.lock generated
View File

@ -220,21 +220,6 @@ 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"
@ -250,36 +235,6 @@ 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"
@ -873,13 +828,6 @@ files = [
{file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"}, {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-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-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_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-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_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
@ -1349,4 +1297,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 = "7a7ba3831802cf91b722b956817a0606b7b8f97724b1a23e5e581657fb34ea19" content-hash = "bf02e951b8c14fbe6f2709b6df7f9664f99ad5fbf7195d8e7b18c8574d00e683"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.0.0" version = "1.0.2"
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"
@ -14,8 +14,6 @@ gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
pandas = "^1.5.2" pandas = "^1.5.2"
matplotlib = "^3.6.3" 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"

14
tailwind.config.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
darkMode: 'class',
content: ["./**/*.{html,js}"],
theme: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/forms')
],
}

View File

@ -12,7 +12,6 @@ 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
@ -39,8 +38,6 @@ 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:
@ -48,7 +45,6 @@ if DEBUG:
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",
@ -153,24 +149,3 @@ 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,64 +17,10 @@ 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("api/", include(router.urls)), path("", RedirectView.as_view(url="/tracker")),
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), path("tracker/", include("games.urls")),
] ]
if settings.DEBUG: if settings.DEBUG: