Add initial project structure and configuration files

This commit is contained in:
Joakim Hellsén 2026-03-22 20:02:16 +01:00
commit 988d131c49
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
13 changed files with 688 additions and 20 deletions

31
.env.example Normal file
View file

@ -0,0 +1,31 @@
# Django Configuration
# Set to False in production
DEBUG=True
# Django Secret Key
# Generate a new secret key for production: python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
DJANGO_SECRET_KEY=your-secret-key-here
# Email Configuration
# SMTP Host (examples below)
EMAIL_HOST=smtp.gmail.com
# SMTP Port (common ports: 587 for TLS, 465 for SSL, 25 for unencrypted)
EMAIL_PORT=587
# Email credentials
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password-here
# Connection security
# Use TLS (True for most providers like Gmail, Outlook)
EMAIL_USE_TLS=True
# Use SSL (False for most providers, True for some older configurations)
EMAIL_USE_SSL=False
# Connection timeout in seconds
EMAIL_TIMEOUT=10
# Redis Configuration
REDIS_URL_CACHE=unix:///var/run/redis/redis.sock?db=0
REDIS_URL_CELERY=unix:///var/run/redis/redis.sock?db=1

76
.gitignore vendored
View file

@ -1,7 +1,6 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*.py[codz]
*$py.class
# C extensions
@ -47,7 +46,7 @@ htmlcov/
nosetests.xml
coverage.xml
*.cover
*.py,cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
@ -86,32 +85,45 @@ ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
poetry.lock
poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
@ -119,11 +131,25 @@ __pypackages__/
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
@ -160,5 +186,31 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml

51
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,51 @@
repos:
- repo: https://github.com/asottile/add-trailing-comma
rev: v4.0.0
hooks:
- id: add-trailing-comma
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-ast
- id: check-builtin-literals
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-merge-conflict
- id: check-toml
- id: check-vcs-permalinks
- id: end-of-file-fixer
- id: mixed-line-ending
- id: name-tests-test
args: [--pytest-test-first]
- id: trailing-whitespace
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.30.0
hooks:
- id: django-upgrade
args: [--target-version, "6.0"]
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.36.4
hooks:
- id: djlint-reformat-django
- id: djlint-django
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.7
hooks:
- id: ruff-check
args: ["--fix", "--exit-non-zero-on-fix"]
- id: ruff-format
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
args: ["--py311-plus"]
- repo: https://github.com/rhysd/actionlint
rev: v1.7.11
hooks:
- id: actionlint

16
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"args": [
"runserver"
],
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/manage.py"
}
]
}

View file

@ -1,3 +1,95 @@
# panso.se
Price tracker for Swedish stores
# panso
Get notified when a new drop is available on Twitch
## TL;DR (Arch Linux + PostgreSQL)
Install and initialize Postgres, then start the service:
```bash
sudo pacman -S postgresql
sudo -u postgres initdb -D /var/lib/postgres/data
sudo systemctl enable --now postgresql
```
Create a local role and database:
```bash
sudo -u postgres createuser -P panso
sudo -u postgres createdb -O panso panso
```
Point Django at the unix socket used by Arch (`/run/postgresql`):
```bash
POSTGRES_USER=panso
POSTGRES_PASSWORD=your_password
POSTGRES_DB=panso
POSTGRES_HOST=/run/postgresql
POSTGRES_PORT=5432
```
### Linux (Systemd)
```bash
sudo useradd --create-home --home-dir /home/panso --shell /bin/fish panso
sudo passwd panso
# sudo usermod -aG wheel panso
su - panso
git clone https://git.lovinator.space/TheLovinator/panso.se.git
cd panso
uv sync --no-dev
# Modify .env with the correct database credentials and other settings, then run migrations:
uv run python manage.py migrate
```
Install the systemd service from the repo:
```bash
sudo install -m 0644 tools/systemd/panso.socket /etc/systemd/system/panso.socket
sudo install -m 0644 tools/systemd/panso.service /etc/systemd/system/panso.service
```
Enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now panso.socket
sudo systemctl enable --now panso.service
curl --unix-socket /run/panso/panso.sock https://panso.se
```
Install and enable timers:
```bash
sudo install -m 0644 tools/systemd/panso-backup.{service,timer} /etc/systemd/system/
sudo install -m 0644 tools/systemd/panso-import-drops.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now panso-backup.timer panso-import-drops.timer
```
## Development
```bash
uv run python manage.py createsuperuser
uv run python manage.py makemigrations
uv run python manage.py migrate
uv run python manage.py collectstatic
uv run python manage.py runserver
uv run pytest
```
## Celery
Start a worker:
```bash
uv run celery -A config worker --loglevel=info
uv run celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler
```

0
config/__init__.py Normal file
View file

205
config/settings.py Normal file
View file

@ -0,0 +1,205 @@
import logging
import os
import sys
from pathlib import Path
from typing import Any
import sentry_sdk
from dotenv import load_dotenv
from platformdirs import user_data_dir
logger: logging.Logger = logging.getLogger("panso.settings")
load_dotenv(verbose=True)
DEBUG: bool = (
os.getenv("DEBUG", "False").lower() in {"true", "1", "t"} or "runserver" in sys.argv
)
TESTING: bool = (
os.getenv("TESTING", "False").lower() in {"true", "1", "t"}
or "test" in sys.argv
or "PYTEST_VERSION" in os.environ
)
def get_data_dir() -> Path:
r"""Get the directory where the application data will be stored.
This directory is created if it does not exist.
Returns:
Path: The directory where the application data will be stored.
For example, on Windows, it might be:
`C:\Users\lovinator\AppData\Roaming\TheLovinator\Panso`
In this directory, application data such as media and static files will be stored.
"""
data_dir: str = user_data_dir(
appname="Panso",
appauthor="TheLovinator",
roaming=True,
ensure_exists=True,
)
return Path(data_dir)
DATA_DIR: Path = get_data_dir()
ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")]
BASE_DIR: Path = Path(__file__).resolve().parent.parent
ROOT_URLCONF = "config.urls"
SECRET_KEY: str = os.getenv("DJANGO_SECRET_KEY", default="")
if not SECRET_KEY:
logger.error("DJANGO_SECRET_KEY environment variable is not set.")
sys.exit(1)
DEFAULT_FROM_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
EMAIL_HOST: str = os.getenv(key="EMAIL_HOST", default="smtp.gmail.com")
EMAIL_HOST_PASSWORD: str | None = os.getenv(key="EMAIL_HOST_PASSWORD", default=None)
EMAIL_HOST_USER: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
EMAIL_PORT: int = int(os.getenv(key="EMAIL_PORT", default=str(587)))
EMAIL_SUBJECT_PREFIX = "[Panso.se] "
EMAIL_TIMEOUT: int = int(os.getenv(key="EMAIL_TIMEOUT", default=str(10)))
EMAIL_USE_LOCALTIME = True
EMAIL_USE_TLS: bool = os.getenv(key="EMAIL_USE_TLS", default="True") == "True"
EMAIL_USE_SSL: bool = os.getenv(key="EMAIL_USE_SSL", default="False") == "True"
SERVER_EMAIL: str | None = os.getenv(key="EMAIL_HOST_USER", default=None)
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/accounts/login/"
LOGOUT_REDIRECT_URL = "/"
ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_AUTHENTICATION_METHOD = "username"
ACCOUNT_EMAIL_REQUIRED = False
MEDIA_ROOT: Path = DATA_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True)
MEDIA_URL = "/media/"
STATIC_ROOT: Path = DATA_DIR / "staticfiles"
STATIC_ROOT.mkdir(exist_ok=True)
STATIC_URL = "/static/"
STATICFILES_DIRS: list[Path] = [BASE_DIR / "static"]
TIME_ZONE = "UTC"
WSGI_APPLICATION = "config.wsgi.application"
INTERNAL_IPS: list[str] = []
if DEBUG:
INTERNAL_IPS = ["127.0.0.1", "localhost"] # pyright: ignore[reportConstantRedefinition]
ALLOWED_HOSTS: list[str] = [".localhost", "127.0.0.1", "[::1]", "testserver"]
if not DEBUG:
ALLOWED_HOSTS = ["panso.se"] # pyright: ignore[reportConstantRedefinition]
LOGGING: dict[str, Any] = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}},
"loggers": {
"": {"handlers": ["console"], "level": "INFO", "propagate": True},
"panso": {"handlers": ["console"], "level": "DEBUG", "propagate": False},
"django": {"handlers": ["console"], "level": "INFO", "propagate": False},
"django.utils.autoreload": {
"handlers": ["console"],
"level": "INFO",
"propagate": True,
},
},
}
INSTALLED_APPS: list[str] = [
# Django built-in apps
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.staticfiles",
"django.contrib.postgres",
# Internal apps
# Third-party apps
"django_celery_results",
"django_celery_beat",
]
MIDDLEWARE: list[str] = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
]
TEMPLATES: list[dict[str, Any]] = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
],
},
},
]
DATABASES: dict[str, dict[str, Any]] = (
{"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
if TESTING
else {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("POSTGRES_DB", "panso"),
"USER": os.getenv("POSTGRES_USER", "panso"),
"PASSWORD": os.getenv("POSTGRES_PASSWORD", ""),
"HOST": os.getenv("POSTGRES_HOST", "localhost"),
"PORT": int(os.getenv("POSTGRES_PORT", str(5432))),
"CONN_MAX_AGE": int(os.getenv("CONN_MAX_AGE", str(60))),
"CONN_HEALTH_CHECKS": os.getenv("CONN_HEALTH_CHECKS", "True") == "True",
"OPTIONS": {
"connect_timeout": int(os.getenv("DB_CONNECT_TIMEOUT", str(10))),
},
},
}
)
if not TESTING:
INSTALLED_APPS = [*INSTALLED_APPS, "debug_toolbar", "silk"] # pyright: ignore[reportConstantRedefinition]
MIDDLEWARE = [ # pyright: ignore[reportConstantRedefinition]
"debug_toolbar.middleware.DebugToolbarMiddleware",
"silk.middleware.SilkyMiddleware",
*MIDDLEWARE,
]
if not DEBUG:
sentry_sdk.init(
dsn="https://1aa1ac672090fb795783de0e90a2b19f@o4505228040339456.ingest.us.sentry.io/4511055670738944",
send_default_pii=True,
enable_logs=True,
traces_sample_rate=1.0,
profile_session_sample_rate=1.0,
profile_lifecycle="trace",
)
REDIS_URL_CACHE: str = os.getenv(
key="REDIS_URL_CACHE",
default="redis://localhost:6379/0",
)
REDIS_URL_CELERY: str = os.getenv(
key="REDIS_URL_CELERY",
default="redis://localhost:6379/1",
)
CACHES: dict[str, dict[str, str]] = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": REDIS_URL_CACHE,
},
}
CELERY_BROKER_URL: str = REDIS_URL_CELERY
CELERY_RESULT_BACKEND = "django-db"
CELERY_RESULT_EXTENDED = True
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"

6
config/urls.py Normal file
View file

@ -0,0 +1,6 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from django.urls.resolvers import URLResolver
urlpatterns: list[URLResolver] = []

14
config/wsgi.py Normal file
View file

@ -0,0 +1,14 @@
import os
from typing import TYPE_CHECKING
from django.core.wsgi import get_wsgi_application
if TYPE_CHECKING:
from django.core.handlers.wsgi import WSGIHandler
os.environ.setdefault(
key="DJANGO_SETTINGS_MODULE",
value="config.settings",
)
application: WSGIHandler = get_wsgi_application()

24
manage.py Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main() -> None:
"""Run administrative tasks.
Raises:
ImportError: If Django is not installed or cannot be imported.
"""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line # noqa: PLC0415
except ImportError as exc:
msg = "Couldn't import Django. Don't forget you have to use 'uv' to run this project."
raise ImportError(msg) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

134
pyproject.toml Normal file
View file

@ -0,0 +1,134 @@
[project]
name = "Panso"
version = "0.1.0"
description = "Price tracker for Swedish stores"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"celery[redis]",
"colorama",
"dateparser",
"django-auto-prefetch",
"django-celery-beat",
"django-celery-results",
"django-debug-toolbar",
"django-silk",
"django",
"flower",
"gunicorn",
"hiredis",
"httpx",
"index-now-for-python",
"json-repair",
"pillow",
"platformdirs",
"psycopg[binary]",
"pydantic",
"pygments",
"python-dotenv",
"redis",
"sentry-sdk",
"setproctitle",
"sitemap-parser",
"tqdm",
]
[dependency-groups]
dev = [
"django-stubs",
"djlint",
"hypothesis[django]",
"pytest-cov",
"pytest-django",
"pytest-randomly",
"pytest-xdist[psutil]",
"pytest",
]
[tool.ruff]
fix = true
preview = true
unsafe-fixes = true
format.docstring-code-format = true
format.preview = true
lint.future-annotations = true
lint.isort.force-single-line = true
lint.pycodestyle.ignore-overlong-task-comments = true
lint.pydocstyle.convention = "google"
lint.select = ["ALL"]
# Don't automatically remove unused variables
lint.unfixable = ["F841"]
lint.ignore = [
"ANN002", # Checks that function *args arguments have type annotations.
"ANN003", # Checks that function **kwargs arguments have type annotations.
"C901", # Checks for functions with a high McCabe complexity.
"CPY001", # Checks for the absence of copyright notices within Python files.
"D100", # Checks for undocumented public module definitions.
"D104", # Checks for undocumented public package definitions.
"D105", # Checks for undocumented magic method definitions.
"D106", # Checks for undocumented public class definitions, for nested classes.
"E501", # Checks for lines that exceed the specified maximum character length.
"ERA001", # Checks for commented-out Python code.
"FIX002", # Checks for "TODO" comments.
"PLR0911", # Checks for functions or methods with too many return statements.
"PLR0912", # Checks for functions or methods with too many branches, including (nested) if, elif, and else branches, for loops, try-except clauses, and match and case statements.
"PLR6301", # Checks for the presence of unused self parameter in methods definitions.
"RUF012", # Checks for mutable default values in class attributes.
"ARG001", # Checks for the presence of unused arguments in function definitions.
# Conflicting lint rules when using Ruff's formatter
# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
"COM812", # Checks for the absence of trailing commas.
"COM819", # Checks for the presence of prohibited trailing commas.
"D206", # Checks for docstrings that are indented with tabs.
"D300", # Checks for docstrings that use '''triple single quotes''' instead of """triple double quotes""".
"E111", # Checks for indentation with a non-multiple of 4 spaces.
"E114", # Checks for indentation of comments with a non-multiple of 4 spaces.
"E117", # Checks for over-indented code.
"ISC001", # Checks for implicitly concatenated strings on a single line.
"ISC002", # Checks for implicitly concatenated strings that span multiple lines.
"Q000", # Checks for inline strings that use single quotes or double quotes, depending on the value of the lint.flake8-quotes.inline-quotes option.
"Q001", # Checks for multiline strings that use single quotes or double quotes, depending on the value of the lint.flake8-quotes.multiline-quotes setting.
"Q002", # Checks for docstrings that use single quotes or double quotes, depending on the value of the lint.flake8-quotes.docstring-quotes setting.
"Q003", # Checks for strings that include escaped quotes, and suggests changing the quote style to avoid the need to escape them.
"W191", # Checks for indentation that uses tabs.
]
[tool.ruff.lint.per-file-ignores]
"**/tests/**" = [
"ARG",
"FBT",
"PLR0904",
"PLR2004",
"PLR6301",
"S101",
"S105",
"S106",
"S311",
"SLF001",
]
"**/migrations/**" = ["RUF012"]
[tool.djlint]
profile = "django"
ignore = "H021,H030"
blank_line_after_tag = "load,extends"
close_void_tags = true
format_css = true
format_js = true
indent = 2
max_line_length = 119
[tool.djlint.css]
indent_size = 2
[tool.djlint.js]
indent_size = 2
[tool.uv.sources]
sitemap-parser = { git = "https://github.com/TheLovinator1/sitemap-parser.git" }

View file

@ -0,0 +1,32 @@
[Unit]
Description=Panso
Requires=panso.socket
After=network.target
[Service]
Type=simple
User=panso
Group=panso
WorkingDirectory=/home/panso/panso
EnvironmentFile=/home/panso/panso/.env
RuntimeDirectory=panso
UMask=0077
ExecStart=/usr/bin/uv run gunicorn config.wsgi:application --bind unix:/run/panso/panso.sock --workers 13 --name panso --max-requests-jitter 50 --max-requests 1200
ExecReload=/bin/kill -s HUP $MAINPID
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=no
ReadWritePaths=/home/panso/panso /run/panso
PrivateDevices=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictRealtime=yes
LockPersonality=yes
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,11 @@
[Unit]
Description=Panso Socket
[Socket]
ListenStream=/run/panso/panso.sock
SocketUser=panso
SocketGroup=panso
SocketMode=0660
[Install]
WantedBy=sockets.target