Compare commits

..

15 commits

Author SHA1 Message Date
bronsen
f0f727fd99 [tests] WIP
Some checks failed
ci/woodpecker/push/workflow/1 Pipeline failed
ci/woodpecker/push/workflow/2 Pipeline failed
re #3
2025-03-15 23:14:27 +01:00
bronsen
a0a2b1ea53 [codestyle] rename variable 2025-03-15 22:48:43 +01:00
bronsen
7c825e15cf [test] widen scope of random-name fixture 2025-03-15 22:33:14 +01:00
bronsen
16503bea7a [codestyle] add type hint to fixture, indicating it is a function that we need to call 2025-03-15 22:30:34 +01:00
bronsen
246c22ce39 [codestyle] shortly explain why we wrap .create() in a transaction 2025-03-15 22:28:46 +01:00
bronsen
aa5d19c5e5 [collector] ensure endpoint only accepts POST requests 2025-03-15 22:26:36 +01:00
bronsen
4605dedc69 [codestyle] compact test functions a little bit 2025-03-15 22:26:01 +01:00
bronsen
051f0b964e [test] add new fixture to generate random strings of given length 2025-03-15 22:25:13 +01:00
bronsen
86ccdd58ee [logs] set up and use structlog in our app 2025-03-15 21:57:13 +01:00
bronsen
2e31cf8047 [settings] remove authenticator settings: we don't have users 2025-03-15 21:56:21 +01:00
bronsen
4d96f60b46 [settings] more robust detection of deployment
Retrieve it only once from env, make it an uppercase string
2025-03-15 21:55:43 +01:00
bronsen
e175071d3b [urls] move urls of collector up to top level 2025-03-15 21:54:37 +01:00
bronsen
2f8db3c67d [codestyle] taplo wants "pythonpath" to be a list of strings 2025-03-15 21:54:11 +01:00
bronsen
f27dc5a0d3 [test] tell pytest about the logot capturer
...which we do not yet use at this point
2025-03-15 21:53:41 +01:00
bronsen
2163a425fd [dependencies] add structlog packages, also for django and pytest 2025-03-15 21:52:57 +01:00
12 changed files with 138 additions and 34 deletions

View file

@ -1,3 +1,7 @@
import random
import string
from typing import Callable
import pytest
from django.test import Client
@ -10,3 +14,12 @@ def enable_db_access_for_all_tests(db):
@pytest.fixture(scope="session", name="session")
def fixture_longlived_client() -> Client:
return Client()
@pytest.fixture(name="random_name", scope="session")
def fixture_random_name_generator() -> Callable[[int], str]:
def name_generator(length: int = 7) -> str:
name = "".join(random.choices(string.ascii_letters, k=length))
return name
return name_generator

View file

@ -1,20 +1,21 @@
from typing import Callable
import pytest
from django.test import Client
from django.urls import reverse
from hypothesis import given, strategies as st
from logot import Logot, logged
from .models import Teil
names = st.text(alphabet=st.characters(exclude_categories=["C"]), min_size=1)
@given(data=names)
def test_submitted_data_ends_up_in_database(data, session: Client):
with pytest.raises(Teil.DoesNotExist):
Teil.objects.get(name=data)
response = session.post(reverse("collector:enter"), data={"new_name": data})
assert response.status_code == 302
assert Teil.objects.get(name=data)
@ -25,11 +26,46 @@ def test_entering_same_name_twice_does_not_change_database_entry(data, session:
response = session.post(reverse("collector:enter"), data={"new_name": data})
assert response.status_code == 302
assert Teil.objects.filter(name=data).count() == 1
response = session.post(reverse("collector:enter"), data={"new_name": data})
assert response.status_code == 302
assert Teil.objects.filter(name=data).count() == 1
@pytest.mark.parametrize(
"http_method,expected_status",
[
("GET", 405),
("PATCH", 405),
("POST", 302),
("PUT", 405),
],
)
def test_enter_endpoint_accepts_only_post_requests(
client: Client,
http_method: str,
expected_status: int,
random_name: Callable[[int], str],
):
client_method = getattr(client, http_method.lower())
response = client_method(
reverse("collector:enter"), data={"new_name": random_name(8)}
)
assert response.status_code == expected_status
@pytest.mark.xfail("WIP, kein Plan warum nichts gecaptured zu werden scheint")
def test_enter_endpoints_emits_expected_logs(
client: Client, logot: Logot, random_name: Callable[[int], str]
):
same_name = random_name(10)
response = client.post(reverse("collector:enter"), data={"new_name": same_name})
assert response.status_code == 302
logot.assert_logged(logged.info("New Teil entered"))
response = client.post(reverse("collector:enter"), data={"new_name": same_name})
assert response.status_code == 302
logot.assert_logged(logged.warning("Teil already existed"))

View file

@ -1,4 +1,4 @@
import logging
import structlog
from typing import Any
from django.db import transaction
@ -6,10 +6,11 @@ from django.db.models import QuerySet
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.urls import reverse
from django.views import generic
from django.views.decorators.http import require_http_methods
from .models import Teil
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
DEFAULT_TEILE_NUMBER = 10
@ -44,11 +45,16 @@ class DetailView(generic.DetailView):
return context
@require_http_methods(["POST"])
def enter(request: HttpRequest) -> HttpResponse:
try:
# if .create() failed, the transaction is rolled back and we are in a
# clean (database) state to handle exceptions
with transaction.atomic():
Teil.objects.create(name=request.POST["new_name"])
teil = Teil.objects.create(name=request.POST["new_name"])
except Exception:
logger.warning("Teil already existed")
else:
logger.info("New Teil entered", teil_id=teil.pk)
return HttpResponseRedirect(reverse("collector:list"))

View file

@ -8,14 +8,17 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
import environ
import os
from pathlib import Path
import environ
import structlog
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env(
DEBUG=(bool, False),
DEPLOYMENT=(str, "Dev"),
DEPLOYMENT=(str, "DEV"),
)
env.read_env(env.str("ENV_PATH", BASE_DIR / ".env")) # pyright: ignore[reportArgumentType]
@ -23,6 +26,7 @@ env.read_env(env.str("ENV_PATH", BASE_DIR / ".env")) # pyright: ignore[reportAr
SECRET_KEY = env("SECRET_KEY")
DEBUG = env("DEBUG")
DEPLOYMENT = str(env("DEPLOYMENT")).upper()
ALLOWED_HOSTS = []
SESSION_COOKIE_SECURE = True
@ -31,6 +35,7 @@ CSRF_COOKIE_SECURE = True
# Application definition
INSTALLED_APPS = [
"django_structlog",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
@ -39,7 +44,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"collector.apps.CollectorConfig",
]
if env("DEPLOYMENT") == "Dev":
if DEPLOYMENT == "DEV":
INSTALLED_APPS += [
"django_extensions",
"debug_toolbar",
@ -47,6 +52,7 @@ if env("DEPLOYMENT") == "Dev":
SHELL_PLUS = "ipython"
MIDDLEWARE = [
"django_structlog.middlewares.RequestMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
@ -55,7 +61,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
if env("DEPLOYMENT") == "Dev":
if DEPLOYMENT == "DEV":
MIDDLEWARE += [
"debug_toolbar.middleware.DebugToolbarMiddleware",
]
@ -89,23 +95,6 @@ DATABASES = {
"default": env.db(),
}
# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/
LANGUAGE_CODE = "en-us"
@ -120,3 +109,50 @@ STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGGING = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"logftm_formatter": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.processors.LogfmtRenderer(sort_keys=True, key_order=["level", "event"]),
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "logftm_formatter",
},
},
"loggers": {
"django_structlog": {
"handlers": ["console"],
"level": "DEBUG",
},
"root": {
"handlers": ["console"],
"level": "DEBUG",
},
},
}
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.filter_by_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
os.makedirs(BASE_DIR / "logs", exist_ok=True)
# DJANGO_STRUCTLOG_COMMAND_LOGGING_ENABLED = True

View file

@ -20,7 +20,7 @@ from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("collector/", include("collector.urls")),
path("", include("collector.urls")),
path("admin/", admin.site.urls),
]

View file

@ -12,7 +12,7 @@ authors = [
]
[tool.pytest.ini_options]
pythonpath = "."
pythonpath = ["."]
python_files = [
"test_*.py",
"tests.py",
@ -23,6 +23,7 @@ addopts = [
"--no-migrations",
"--capture=sys",
]
logot_capturer = "logot.structlog.StructlogCapturer"
[build-system]
requires = ["pdm-backend"]

View file

@ -4,5 +4,6 @@ django-debug-toolbar
django-stubs
ipython
pip-compile-multi
rich
ruff
uv

View file

@ -1,4 +1,4 @@
# SHA1:8a2089b98bf40ef6e9739b167a6c3bdc35a7c180
# SHA1:dabc24f4b9a41d5fae3314c9a05a98434dd126e3
#
# This file is autogenerated by pip-compile-multi
# To update, run:
@ -17,7 +17,9 @@ executing==2.2.0
ipython==9.0.2
ipython-pygments-lexers==1.1.1
jedi==0.19.2
markdown-it-py==3.0.0
matplotlib-inline==0.1.7
mdurl==0.1.2
parso==0.8.4
pexpect==4.9.0
pip==25.0.1
@ -28,6 +30,7 @@ ptyprocess==0.7.0
pure-eval==0.2.3
pygments==2.19.1
pyproject-hooks==1.2.0
rich==13.9.4
ruff==0.11.0
setuptools==76.0.0
stack-data==0.6.3

View file

@ -1,4 +1,6 @@
django
django-environ
django-extensions
django-structlog[commands]
psycopg[binary,pool]
structlog>24,<25

View file

@ -1,4 +1,4 @@
# SHA1:0534e06bf8de6836b424e914cfbef002223d6f48
# SHA1:48c3af154036b224a6b21f175ea0a949febaf6f5
#
# This file is autogenerated by pip-compile-multi
# To update, run:
@ -9,8 +9,12 @@ asgiref==3.8.1
django==5.1.7
django-environ==0.12.0
django-extensions==3.2.3
django-ipware==7.0.1
django-structlog==9.0.1
psycopg==3.2.6
psycopg-binary==3.2.6
psycopg-pool==3.2.6
python-ipware==3.0.0
sqlparse==0.5.3
structlog==24.4.0
typing-extensions==4.12.2

View file

@ -3,3 +3,4 @@
hypothesis[django]
pytest
pytest-django
logot[pytest,structlog]

View file

@ -1,4 +1,4 @@
# SHA1:9bf147356ad56dbe073ef0973141bdfb94631c9b
# SHA1:40e4b481355fc16525f8944d39904264b6e382a6
#
# This file is autogenerated by pip-compile-multi
# To update, run:
@ -7,8 +7,9 @@
#
-r prod.txt
attrs==25.3.0
hypothesis==6.129.1
hypothesis==6.129.2
iniconfig==2.0.0
logot==1.3.0
packaging==24.2
pluggy==1.5.0
pytest==8.3.5