Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[flake8]
# From https://github.com/4teamwork/ftw-buildouts/blob/master/pycodestyle.cfg
ignore = E121,E122,E123,E125,E126,E127,E128,E203,E301,W503,W606
max-line-length = 125
exclude = .git,__pycache__,venv,.tox,manage.py,include,lib,javascript,lib,node_modules,scss,bin,.eggs,wsgi.py,wsgi.py,src,migrations,docs,gever,bootstrap.py
17 changes: 16 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: django_features_testing
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout
uses: actions/checkout@v3
Expand All @@ -22,10 +35,12 @@ jobs:
with:
poetry-version: 2.1
- name: Install dependencies
run: poetry install --only=dev
run: poetry install
- name: isort
run: poetry run isort --check-only --quiet --settings pyproject.toml .
- name: flake8
run: poetry run flake8
- name: black
run: poetry run black --check --config pyproject.toml .
- name: test
run: DJANGO_CONFIGURATION=Testing poetry run pytest
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ A collection of fearures used in our Django-based web applications

[Changelog](CHANGELOG.md)

## Installation

``` bash
pip install ftw-django-features
```

## Usage

Add desired app to `INSTALLED_APPS` in your Django project.

Available apps:
- `django_features.system_message`

## Development

Installing dependencies, assuming you have poetry installed:
Expand Down
Empty file added app/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions app/settings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__all__ = [
"Development",
"Testing",
]

from .development import Development
from .testing import Testing
145 changes: 145 additions & 0 deletions app/settings/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import os
from pathlib import Path

from configurations import Configuration
from configurations import values
from django.utils.translation import gettext_lazy as _


class Base(Configuration):
DEBUG = False

@property
def INSTALLED_APPS(self) -> list[str]:
installed_apps = [
"modeltranslation",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sessions",
"django_extensions",
"django_linear_migrations",
"constance",
"rest_framework",
"django_features.system_message",
]
return installed_apps

BASE_DIR = Path(__file__).parent.parent.parent
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

@property
def MIDDLEWARE(self) -> list[str]:
middlewares = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
return middlewares

AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
ROOT_URLCONF = "app.urls"

TIME_ZONE = "Europe/Zurich"
USE_I18N = True
USE_L10N = True
USE_TZ = True

LANGUAGE_CODE = "de"
LANGUAGES = [
("de", _("Deutsch")),
("en", _("Englisch")),
("fr", _("Französisch")),
]
LOCALE_PATHS = [
BASE_DIR / "django_features" / "locale",
]

DATABASE_NAME = values.Value("django_features")
DATABASE_ENGINE = values.Value("django.db.backends.postgresql")
DATABASE_USER = values.Value("")
DATABASE_PASSWORD = values.Value("")
DATABASE_HOST = values.Value("")
DATABASE_PORT = values.Value("")
DATABASE_OPTIONS = values.DictValue({})

@property
def DATABASES(self) -> dict:
return {
"default": {
"ENGINE": self.DATABASE_ENGINE,
"NAME": self.DATABASE_NAME,
"USER": self.DATABASE_USER,
"PASSWORD": self.DATABASE_PASSWORD,
"HOST": self.DATABASE_HOST,
"PORT": self.DATABASE_PORT,
"OPTIONS": self.DATABASE_OPTIONS,
"ATOMIC_REQUESTS": True,
}
}

TEMPLATES = [
{
"APP_DIRS": True,
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]

@property
def STATIC_ROOT(self) -> str:
default_value = os.path.join(self.BASE_DIR, "public/static")
return values.Value(default=default_value, environ_name="STATIC_ROOT")

@property
def STATIC_URL(self) -> str:
value = values.Value("/static/", environ_name="STATIC_URL")
value = value.strip("/")
return "/" + value + "/"

REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication"
],
"DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
}

CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend"

@property
def CONSTANCE_CONFIG(self) -> dict:
return {
"SYSTEM_MESSAGE_PERMISSION": (
"",
"Django permission to manage system messages.",
str,
),
"ENABLE_SYSTEM_MESSAGE": (False, "Enables the system info feature.", bool),
}

@property
def CONSTANCE_CONFIG_FIELDSETS(self) -> dict:
return {
"Miscellaneous": {
"fields": ("ENABLE_SYSTEM_MESSAGE", "SYSTEM_MESSAGE_PERMISSION"),
"collapse": True,
},
}

SECRET_KEY = values.SecretValue()
6 changes: 6 additions & 0 deletions app/settings/development.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from app.settings.base import Base


class Development(Base):
DEBUG = True
SECRET_KEY = "secret"
15 changes: 15 additions & 0 deletions app/settings/testing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from configurations import values

from app.settings.base import Base


class Testing(Base):
SECRET_KEY = "secret"

DATABASE_NAME = values.Value("django_features_testing")
DATABASE_ENGINE = values.Value("django.db.backends.postgresql")
DATABASE_USER = values.Value("postgres")
DATABASE_PASSWORD = values.Value("postgres")
DATABASE_HOST = values.Value("127.0.0.1")
DATABASE_PORT = values.Value("5432")
DATABASE_OPTIONS = values.DictValue({})
30 changes: 30 additions & 0 deletions app/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Any

from django.contrib.auth import get_user_model
from django.test import TransactionTestCase
from rest_framework.test import APIClient


User = get_user_model()


class APITestCase(TransactionTestCase):
reset_sequences = True

def setUp(self) -> None:
super().setUp()
self.anonymous_client = APIClient()
self.client = APIClient()
self.login("kathi.barfuss")
self.session = self.client.session

def get_or_create_user(self, username: str) -> tuple[Any, bool]:
user, created = User.objects.get_or_create(username=username)
if created:
user.set_password("secret")
user.save()
return user, created

def login(self, username: str, password: str = "secret") -> None:
self.user, _ = self.get_or_create_user(username=username)
self.client.login(username=username, password=password)
11 changes: 11 additions & 0 deletions app/tests/factories/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from typing import Any

from factory.django import DjangoModelFactory


class BaseFactory(DjangoModelFactory):
class Meta:
abstract = True

def __new__(cls, *args: Any, **kwargs: Any) -> Any:
return super().__new__(*args, **kwargs) # type: ignore
26 changes: 26 additions & 0 deletions app/tests/factories/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import datetime

from factory import SubFactory
from pytz import UTC

from app.tests.factories import BaseFactory
from django_features.system_message import models


class SystemMessageTypeFactory(BaseFactory):
class Meta:
model = models.SystemMessageType

name = "Info"
icon = "information"


class SystemMessageFactory(BaseFactory):
class Meta:
model = models.SystemMessage

background_color = "#008DCC"
begin = datetime.datetime(2025, 1, 1, tzinfo=UTC)
text = "Hello World!"
title = "System Info"
type = SubFactory(SystemMessageTypeFactory) # type: ignore
64 changes: 64 additions & 0 deletions app/tests/test_system_message_viewset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import datetime

import pytz
from constance.test import override_config
from freezegun import freeze_time
from pluck import pluck

from app.tests import APITestCase
from app.tests.factories.factories import SystemMessageFactory


class SystemInfoViewSetTest(APITestCase):
@override_config(ENABLE_SYSTEM_MESSAGE=True)
def test_filter_active_system_infos(self) -> None:
past = SystemMessageFactory(
title="past",
begin=datetime.datetime(2024, 1, 1, tzinfo=pytz.UTC),
end=datetime.datetime(2024, 12, 31, 23, 59, 59, tzinfo=pytz.UTC),
)
active_1 = SystemMessageFactory(
title="active 1",
begin=datetime.datetime(2025, 1, 1, 0, 0, 0, tzinfo=pytz.UTC),
)
active_2 = SystemMessageFactory(
title="active 2",
begin=datetime.datetime(2025, 1, 1, 0, 0, 0, tzinfo=pytz.UTC),
end=datetime.datetime(2025, 1, 1, 0, 0, 0, tzinfo=pytz.UTC),
)
future = SystemMessageFactory(
title="future",
begin=datetime.datetime(2025, 1, 1, 0, 0, 1, tzinfo=pytz.UTC),
)

with freeze_time(datetime.datetime(2025, 1, 1, 0, 0, 0, tzinfo=pytz.UTC)):
response = self.client.get("/api/system_message?active=true")

data = response.json()
self.assertEqual(2, len(data))
self.assertEqual([active_2.title, active_1.title], pluck(data, "title"))

with freeze_time(datetime.datetime(2025, 1, 1, 0, 0, 0, tzinfo=pytz.UTC)):
response = self.client.get("/api/system_message?active=false")

data = response.json()
self.assertEqual(2, len(data))
self.assertEqual([past.title, future.title], pluck(data, "title"))

@override_config(ENABLE_SYSTEM_MESSAGE=True)
def test_filter_dismissed_system_infos(self) -> None:
info_1 = SystemMessageFactory(title="dismissed")
info_2 = SystemMessageFactory(title="not")
info_1.dismissed_users.add(self.user)

response = self.client.get("/api/system_message?dismissed=false")

data = response.json()
self.assertEqual(1, len(data))
self.assertEqual([info_2.title], pluck(data, "title"))

response = self.client.get("/api/system_message?dismissed=true")

data = response.json()
self.assertEqual(1, len(data))
self.assertEqual([info_1.title], pluck(data, "title"))
15 changes: 15 additions & 0 deletions app/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include
from django.urls import path
from django.views import generic

from django_features.system_message.routers import system_message_router


urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include(system_message_router.urls)),
path("", generic.RedirectView.as_view(url="./admin/")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
10 changes: 10 additions & 0 deletions bin/i18n_update
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

python manage.py makemessages --all --no-wrap --add-location file --ignore venv --ignore lib
if [[ `uname` == "Darwin" ]]; then
sed -i '' '/"POT-Creation-Date.*/d' django_features/locale/**/LC_MESSAGES/django.po
else
sed -i '/"POT-Creation-Date.*/d' django_features/locale/**/LC_MESSAGES/django.po
fi
python manage.py compilemessages --ignore venv --ignore lib
Loading