Compare commits

...

18 Commits

Author SHA1 Message Date
8b1722d1f0 membershipworks: Hide EventExt.registrations from admin
All checks were successful
Ruff / ruff (push) Successful in 27s
Test / test (push) Successful in 4m24s
2024-05-08 12:48:10 -04:00
df4c5564c4 membershipworks: Remove unnecessary admin.display() function 2024-05-08 12:46:56 -04:00
1310e72e3f membershipworks: Convert EventExt.details_timestamp to GeneratedField
was waiting on Django 5.0.5 to fix
https://code.djangoproject.com/ticket/35350
2024-05-08 12:45:34 -04:00
281c882a82 Bump dependencies 2024-05-08 12:32:49 -04:00
7236b55467 Add CSRF_TRUSTED_ORIGINS to environment-settable settings 2024-05-05 23:21:05 -04:00
9c2084903f membershipworks: Add view for event registrations 2024-05-05 23:20:47 -04:00
25fbe3d352 membershipworks: Add breadcrumbs for "my events" and event details page 2024-05-05 23:20:43 -04:00
04ca92b5fe Make LOGGING setting configurable via environment variable
All checks were successful
Ruff / ruff (push) Successful in 29s
Test / test (push) Successful in 5m10s
2024-05-04 20:34:36 -04:00
0944dd7992 Fix various type issues 2024-05-04 18:03:22 -04:00
9658366d72 Fix mypy for django-configurations
still have a lot of bad typing, but at least it runs again
2024-05-03 12:37:48 -04:00
a2c0707263 Bump dependencies 2024-05-03 12:37:48 -04:00
785a445b43 Add support for loading configurations values from systemd credentials 2024-05-03 12:37:48 -04:00
ee2d63f784 membershipworks: Add support for scraping event registration data 2024-05-03 12:37:48 -04:00
12eb4038bc Disable the Tasks app
it has not been used in production for some time
2024-05-03 12:37:48 -04:00
c2b1da743c membershipworks: Remove commented out line 2024-05-03 12:37:48 -04:00
99060a8a43 Convert settings to use django-configurations 2024-05-03 12:37:48 -04:00
59cade1cfd ci: Only install the dev dev-dependencies group for testing 2024-05-03 12:37:48 -04:00
f8a4b425af Bump dependencies 2024-05-03 12:37:48 -04:00
41 changed files with 979 additions and 546 deletions

View File

@ -2,7 +2,7 @@ name: Test
on: [ push, pull_request ]
env:
DJANGO_SETTINGS_MODULE: cmsmanage.settings.ci
DJANGO_CONFIGURATION: CI
jobs:
test:
@ -28,6 +28,6 @@ jobs:
sudo apt-get update &&
sudo apt-get -y install build-essential python3-dev libldap2-dev libsasl2-dev mariadb-client
- name: Install python dependencies
run: pdm sync -d
run: pdm sync -d -G dev
- name: Run tests
run: pdm run -v ./manage.py test

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ __pycache__/
/__pypackages__/
/markdownx/
/media/
/settings.*.env

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
@ -14,13 +14,13 @@ repos:
- id: djlint-reformat-django
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
rev: v0.4.2
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/pdm-project/pdm
rev: 2.12.4
rev: 2.15.1
hooks:
- id: pdm-lock-check

View File

@ -9,8 +9,9 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
import os
from django.core.asgi import get_asgi_application
from configurations.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cmsmanage.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "DEV")
application = get_asgi_application()

View File

@ -0,0 +1,12 @@
# https://github.com/typeddjango/django-stubs/pull/180#issuecomment-820062352
import os
from configurations import importer
from mypy_django_plugin import main
def plugin(version):
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cmsmanage.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Dev")
importer.install()
return main.plugin(version)

327
cmsmanage/settings.py Normal file
View File

@ -0,0 +1,327 @@
import os
from pathlib import Path
from django.core import validators
from configurations import Configuration, values
BASE_DIR = Path(__file__).resolve().parent.parent
class Base(Configuration):
@classmethod
def pre_setup(cls):
super().pre_setup()
# load systemd credentials, as per https://systemd.io/CREDENTIALS/
credentials_directory = os.getenv("CREDENTIALS_DIRECTORY")
if credentials_directory is not None:
for credential in Path(credentials_directory).iterdir():
if credential.name.isupper():
os.environ.setdefault(credential.name, credential.read_text())
@classmethod
def setup(cls):
super().setup()
cls.DATABASES["default"]["OPTIONS"] = {"charset": "utf8mb4"}
INSTALLED_APPS = [
"dal",
"dal_select2",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_admin_logs",
"django_object_actions",
"widget_tweaks",
# "markdownx",
# "recurrence",
"rest_framework",
"rest_framework.authtoken",
"django_q",
"django_nh3",
"django_tables2",
"django_filters",
"django_db_views",
"django_mysql",
"django_sendfile",
"django_bootstrap5",
# "tasks.apps.TasksConfig",
"rentals.apps.RentalsConfig",
"membershipworks.apps.MembershipworksConfig",
"paperwork.apps.PaperworkConfig",
"doorcontrol.apps.DoorControlConfig",
"dashboard.apps.DashboardConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "cmsmanage.urls"
TEMPLATES = [
{
"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",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WSGI_APPLICATION = "cmsmanage.wsgi.application"
# Default URL to redirect to after authentication
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/auth/login/"
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/New_York"
USE_I18N = False
USE_L10N = True
USE_TZ = True
USE_DEPRECATED_PYTZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
LOGGING = values.DictValue(
{
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"my_console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"": {
"handlers": ["my_console"],
"level": "WARNING",
},
},
}
)
MEDIA_ROOT = "media"
MEDIA_URL = "media/"
SENDFILE_ROOT = str(BASE_DIR / "media" / "protected")
SERVER_EMAIL = "cmsmanage <cmsmanage@claremontmakerspace.org>"
EMAIL_SUBJECT_PREFIX = "[cmsmanage] "
DEFAULT_FROM_EMAIL = SERVER_EMAIL
# Django Rest Framework
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions
"DEFAULT_PERMISSION_CLASSES": [
"cmsmanage.drf_permissions.DjangoModelPermissionsWithView"
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
],
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",
}
# Django Q
Q_CLUSTER = {
"name": "cmsmanage",
"orm": "default",
"retry": 60 * 6,
"timeout": 60 * 5,
"catch_up": False,
"error_reporter": {"admin_email": {}},
"ack_failures": True,
"max_attempts": 1,
"ALT_CLUSTERS": {
"internal": {
"retry": 60 * 60,
"timeout": 60 * 59,
},
},
}
# Django-Tables2
DJANGO_TABLES2_TEMPLATE = "django_tables2/bootstrap5-responsive.html"
DJANGO_TABLES2_TABLE_ATTRS = {"class": "table mx-auto w-auto"}
SECRET_KEY = values.SecretValue(environ_required=True)
# CMSManage specific stuff
WIKI_URL = values.URLValue("https://wiki.claremontmakerspace.org")
class NonCIBase(Base):
"""required for all but CI"""
CSRF_TRUSTED_ORIGINS = values.ListValue([])
DATABASES = values.DatabaseURLValue(environ_required=True)
EMAIL = values.EmailURLValue(environ_required=True)
# TODO: should validate emails
ADMINS = values.SingleNestedTupleValue(environ_required=True)
MEMBERSHIPWORKS_USERNAME = values.EmailValue(
environ_required=True, environ_prefix=None
)
MEMBERSHIPWORKS_PASSWORD = values.SecretValue(
environ_required=True, environ_prefix=None
)
HID_DOOR_USERNAME = values.Value(environ_required=True, environ_prefix=None)
HID_DOOR_PASSWORD = values.SecretValue(environ_prefix=None)
# TODO: should validate emails (but EmailValidator doesn't handle name parts)
INVOICE_HANDLERS = values.ListValue(
environ_required=True, environ_prefix="CMSMANAGE"
)
# arguments for https://udm-rest-client.readthedocs.io/en/latest/udm_rest_client.html#udm_rest_client.udm.UDM
UCS = values.DictValue(environ_required=True, environ_prefix="CMSMANAGE")
class LDAPURLValue(values.ValidationMixin, values.Value):
message = "Cannot interpret LDAP URL value {0!r}"
validator = validators.URLValidator(schemes=["ldap", "ldaps"])
class Prod(NonCIBase):
import ldap
from django_auth_ldap.config import LDAPGroupQuery, LDAPSearch, PosixGroupType
DOTENV = BASE_DIR / "settings.dev.env"
DEBUG = False
# LDAP Authentication
# https://django-auth-ldap.readthedocs.io/en/latest/
AUTHENTICATION_BACKENDS = [
"django_auth_ldap.backend.LDAPBackend",
"django.contrib.auth.backends.ModelBackend",
]
AUTH_LDAP_USER_SEARCH = LDAPSearch(
"cn=users,dc=sawtooth,dc=claremontmakerspace,dc=org",
ldap.SCOPE_SUBTREE,
"(uid=%(user)s)",
)
AUTH_LDAP_USER_ATTR_MAP = {
"first_name": "givenName",
"last_name": "sn",
"email": "mail",
}
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
"is_staff": (
LDAPGroupQuery(
"cn=MW_CMS Staff,cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
)
| LDAPGroupQuery(
"cn=MW_Database Admin,cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
)
| LDAPGroupQuery(
"cn=MW_Database Access,cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
)
)
}
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
"cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org",
ldap.SCOPE_SUBTREE,
"(objectClass=posixGroup)",
)
AUTH_LDAP_GROUP_TYPE = PosixGroupType()
AUTH_LDAP_MIRROR_GROUPS = True
SENDFILE_BACKEND = "django_sendfile.backends.nginx"
SENDFILE_URL = "/media/protected"
AUTH_LDAP_SERVER_URI = LDAPURLValue(None, environ_required=True)
AUTH_LDAP_BIND_DN = values.Value(environ_required=True)
AUTH_LDAP_BIND_PASSWORD = values.SecretValue()
ALLOWED_HOSTS = values.ListValue([], environ_required=True)
STATIC_ROOT = values.PathValue(environ_required=True)
def configure_hypothesis_profiles():
from hypothesis import HealthCheck, Verbosity, settings
settings.register_profile("ci", suppress_health_check=(HealthCheck.too_slow,))
settings.register_profile("dev", max_examples=20)
settings.register_profile("debug", max_examples=10, verbosity=Verbosity.verbose)
class Dev(NonCIBase):
@classmethod
def post_setup(cls):
import os
from hypothesis import settings
super().post_setup()
configure_hypothesis_profiles()
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
DOTENV = BASE_DIR / "settings.dev.env"
DEBUG = values.BooleanValue(True)
INTERNAL_IPS = ["127.0.0.1"]
INSTALLED_APPS = NonCIBase.INSTALLED_APPS + ["debug_toolbar", "django_extensions"]
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + Base.MIDDLEWARE
EMAIL = values.EmailURLValue("smtp://localhost:1025") # for local `mailpit`
SENDFILE_BACKEND = "django_sendfile.backends.development"
class CI(Base):
@classmethod
def post_setup(cls):
from hypothesis import settings
super().post_setup()
configure_hypothesis_profiles()
settings.load_profile("ci")
SECRET_KEY = "aed7jee2kai1we9eithae0gaegh9ohthoh4phahk5bau4Ahxaijo3aicheex3qua"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"HOST": "mariadb",
"NAME": "CMS_Database",
"USER": "root",
"PASSWORD": "whatever",
"OPTIONS": {
"charset": "utf8mb4",
},
}
}

View File

@ -1,2 +0,0 @@
dev.py
prod.py

View File

@ -1,163 +0,0 @@
"""
Django settings for cmsmanage project.
Generated by 'django-admin startproject' using Django 3.1.3.
For more information on this file, see
https://docs.djangoproject.com/en/3.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
ALLOWED_HOSTS: list[str] = []
# Application definition
INSTALLED_APPS = [
"dal",
"dal_select2",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_admin_logs",
"django_object_actions",
"widget_tweaks",
"markdownx",
"recurrence",
"rest_framework",
"rest_framework.authtoken",
"django_q",
"django_nh3",
"django_tables2",
"django_filters",
"django_db_views",
"django_mysql",
"django_sendfile",
"django_bootstrap5",
"tasks.apps.TasksConfig",
"rentals.apps.RentalsConfig",
"membershipworks.apps.MembershipworksConfig",
"paperwork.apps.PaperworkConfig",
"doorcontrol.apps.DoorControlConfig",
"dashboard.apps.DashboardConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "cmsmanage.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [Path(BASE_DIR) / "templates"],
"APP_DIRS": True,
"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",
],
},
},
]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
WSGI_APPLICATION = "cmsmanage.wsgi.application"
# Default URL to redirect to after authentication
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/auth/login/"
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/New_York"
USE_I18N = False
USE_L10N = True
USE_TZ = True
USE_DEPRECATED_PYTZ = False
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"my_console": {
"class": "logging.StreamHandler",
},
},
"loggers": {
"": {
"handlers": ["my_console"],
"level": "WARNING",
},
},
}
MEDIA_ROOT = "media"
MEDIA_URL = "media/"
SENDFILE_ROOT = str(Path(__file__).parents[2] / "media" / "protected")
WIKI_URL = "https://wiki.claremontmakerspace.org"
# Django Rest Framework
REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions
"DEFAULT_PERMISSION_CLASSES": [
"cmsmanage.drf_permissions.DjangoModelPermissionsWithView"
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
],
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",
}
# Django Q
Q_CLUSTER = {
"name": "cmsmanage",
"orm": "default",
"retry": 60 * 6,
"timeout": 60 * 5,
"catch_up": False,
"error_reporter": {"admin_email": {}},
"ack_failures": True,
"max_attempts": 1,
"ALT_CLUSTERS": {
"internal": {
"retry": 60 * 60,
"timeout": 60 * 59,
},
},
}
# Django-Tables2
DJANGO_TABLES2_TEMPLATE = "django_tables2/bootstrap5-responsive.html"
DJANGO_TABLES2_TABLE_ATTRS = {"class": "table mx-auto w-auto"}

View File

@ -1,38 +0,0 @@
from hypothesis import settings
from .base import * # noqa: F403
from .hypothesis import configure_hypothesis_profiles
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "aed7jee2kai1we9eithae0gaegh9ohthoh4phahk5bau4Ahxaijo3aicheex3qua"
# Database
# https://docs.djangoproject.com/en/3.1/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"HOST": "mariadb",
"NAME": "CMS_Database",
"USER": "root",
"PASSWORD": "whatever",
"OPTIONS": {
"charset": "utf8mb4",
},
},
"membershipworks": {
"ENGINE": "django.db.backends.mysql",
"HOST": "mariadb",
"NAME": "membershipworks",
"USER": "root",
"PASSWORD": "whatever",
"OPTIONS": {
"charset": "utf8mb4",
},
},
}
configure_hypothesis_profiles()
settings.load_profile("ci")

View File

@ -1,25 +0,0 @@
from .dev_base import * # noqa: F403
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "+>+4?MO:*@`KFF?($O}F+<dI/oE'/8V?s%d?fpL5_UF_703}*0&g04BFPqkl&`Tz"
EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = "./email-messages" # change this to a proper location
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "default",
},
# Currently a separate database, for historical reasons
"membershipworks": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "membershipworks",
},
}

View File

@ -1,25 +0,0 @@
import os
from hypothesis import settings
from .base import * # noqa: F403
from .hypothesis import configure_hypothesis_profiles
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
INTERNAL_IPS = ["127.0.0.1"]
INSTALLED_APPS.append("debug_toolbar") # noqa: F405
INSTALLED_APPS.append("django_extensions") # noqa: F405
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") # noqa: F405
configure_hypothesis_profiles()
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))
SENDFILE_BACKEND = "django_sendfile.backends.development"

View File

@ -1,7 +0,0 @@
from hypothesis import HealthCheck, Verbosity, settings
def configure_hypothesis_profiles():
settings.register_profile("ci", suppress_health_check=(HealthCheck.too_slow,))
settings.register_profile("dev", max_examples=20)
settings.register_profile("debug", max_examples=10, verbosity=Verbosity.verbose)

View File

@ -1,53 +0,0 @@
import ldap
from django_auth_ldap.config import LDAPGroupQuery, LDAPSearch, PosixGroupType
from .base import * # noqa: F403
DEBUG = False
# LDAP Authentication
# https://django-auth-ldap.readthedocs.io/en/latest/
# "AUTH_LDAP_SERVER_URI", "AUTH_LDAP_BIND_DN", and "AUTH_LDAP_BIND_PASSWORD" set in prod.py
AUTHENTICATION_BACKENDS = [
"django_auth_ldap.backend.LDAPBackend",
"django.contrib.auth.backends.ModelBackend",
]
AUTH_LDAP_USER_SEARCH = LDAPSearch(
"cn=users,dc=sawtooth,dc=claremontmakerspace,dc=org",
ldap.SCOPE_SUBTREE,
"(uid=%(user)s)",
)
AUTH_LDAP_USER_ATTR_MAP = {
"first_name": "givenName",
"last_name": "sn",
"email": "mail",
}
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
"is_staff": (
LDAPGroupQuery(
"cn=MW_CMS Staff,cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
)
| LDAPGroupQuery(
"cn=MW_Database Admin,cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
)
| LDAPGroupQuery(
"cn=MW_Database Access,cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org"
)
)
}
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
"cn=groups,dc=sawtooth,dc=claremontmakerspace,dc=org",
ldap.SCOPE_SUBTREE,
"(objectClass=posixGroup)",
)
AUTH_LDAP_GROUP_TYPE = PosixGroupType()
AUTH_LDAP_MIRROR_GROUPS = True
SENDFILE_BACKEND = "django_sendfile.backends.nginx"
SENDFILE_URL = "/media/protected"

View File

@ -31,7 +31,7 @@ router.registry.extend(membershipworks_router.registry)
urlpatterns = [
path("", include("dashboard.urls")),
path("tasks/", include("tasks.urls")),
# path("tasks/", include("tasks.urls")),
path("rentals/", include("rentals.urls")),
path("membershipworks/", include("membershipworks.urls")),
path("paperwork/", include("paperwork.urls")),
@ -59,7 +59,7 @@ urlpatterns = [
),
),
path("api-auth/", include("rest_framework.urls")),
path("markdownx/", include("markdownx.urls")),
# path("markdownx/", include("markdownx.urls")),
]
if settings.DEBUG:

View File

@ -9,8 +9,9 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
import os
from django.core.wsgi import get_wsgi_application
from configurations.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cmsmanage.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Dev")
application = get_wsgi_application()

View File

@ -220,7 +220,7 @@ class HIDEvent(models.Model):
)
@classmethod
def from_xml_attributes(cls, door: Door, attrib: dict[str:str]):
def from_xml_attributes(cls, door: Door, attrib: dict[str, str]):
field_lookup = {
field.column: field.attname for field in HIDEvent._meta.get_fields()
}
@ -287,7 +287,7 @@ class HIDEvent(models.Model):
def __str__(self):
return f"{self.door.name} {self.timestamp} - {self.description}"
def decoded_card_number(self) -> str:
def decoded_card_number(self) -> str | None:
"""Requires annotations from `with_decoded_card_number`"""
if self.raw_card_number is None:
return None

View File

@ -1,5 +1,6 @@
import dataclasses
import logging
from typing import TypedDict
from django_q.tasks import async_task
@ -11,10 +12,20 @@ from membershipworks.models import Member
logger = logging.getLogger(__name__)
class CardholderAttribs(TypedDict):
forename: str
middleName: str
surname: str
email: str
phone: str
custom1: str
custom2: str
@dataclasses.dataclass
class DoorMember:
door: Door
attribs: dict[str, str]
attribs: CardholderAttribs
credentials: set[Credential]
schedules: set[str]
cardholderID: str | None = None
@ -33,7 +44,7 @@ class DoorMember:
else:
credentials = set()
reasons_and_schedules = {}
reasons_and_schedules: dict[str, str] = {}
if (
member.is_active
or member.flags.filter(name="Misc. Access", type="folder").exists()
@ -112,6 +123,9 @@ class DoorMember:
all_members: list["DoorMember"],
old_credentials: set[Credential] = set(),
):
# cardholderID should be set on a member before this is called
assert self.cardholderID is not None
other_assigned_cards = {
card for m in all_members if m != self for card in m.credentials
}
@ -187,7 +201,7 @@ def update_door(door: Door, dry_run: bool = False):
cardholders = {
member.membershipworks_id: member
for member in [
DoorMember.from_cardholder(ch, door.controller)
DoorMember.from_cardholder(ch, door)
for ch in door.controller.get_cardholders()
]
}

View File

@ -12,7 +12,7 @@ from django.views.generic.list import ListView
import django_filters
import django_tables2 as tables
from django_filters.views import BaseFilterView
from django_mysql.models import GroupConcat
from django_mysql.models.aggregates import GroupConcat
from django_mysql.models.functions import ConcatWS
from django_tables2 import SingleTableMixin
from django_tables2.export.views import ExportMixin
@ -30,7 +30,7 @@ from .tables import (
REPORTS = []
def register_report(cls: "BaseAccessReport"):
def register_report(cls: "type[BaseAccessReport]"):
REPORTS.append(cls)
return cls
@ -55,7 +55,7 @@ class BaseAccessReport(
filterset_class = AccessReportFilterSet
_report_name = None
_report_name: str
@classmethod
def _report_types(cls):
@ -76,9 +76,9 @@ class BaseAccessReport(
def _selected_report(self):
return self._report_name
def get_paginate_by(self, queryset) -> int:
def get_paginate_by(self, queryset) -> int | None:
if "items_per_page" in self.request.GET:
return int(self.request.GET.get("items_per_page"))
return int(self.request.GET["items_per_page"])
return super().get_paginate_by(queryset)
def get_queryset(self):

View File

@ -7,9 +7,11 @@ import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cmsmanage.settings.dev")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cmsmanage.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Dev")
try:
from django.core.management import execute_from_command_line
from configurations.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "

View File

@ -108,11 +108,6 @@ class EventMeetingTimeInline(admin.TabularInline):
readonly_fields = ["duration"]
# TODO: remove when switched to GeneratedField
@admin.display()
def duration(self, obj):
return obj.duration
@admin.register(EventInstructor)
class EventInstructorAdmin(admin.ModelAdmin):
@ -144,7 +139,7 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
show_facets = admin.ShowFacets.ALWAYS
search_fields = ["eid", "title", "url"]
date_hierarchy = "start"
exclude = ["url", "details"]
exclude = ["url", "details", "registrations"]
autocomplete_fields = ["instructor"]
change_actions = ["fetch_details"]
actions = ["fetch_details"]
@ -160,7 +155,7 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
else:
fields.append(field.name)
fields.insert(fields.index("end") + 1, "duration")
fields.append("_details_timestamp")
fields.append("details_timestamp")
return fields
@admin.display(ordering="title")
@ -178,10 +173,6 @@ class EventAdmin(DjangoObjectActions, admin.ModelAdmin):
obj.url,
)
@admin.display(description="Last details fetch")
def _details_timestamp(self, obj):
return naturaltime(obj.details_timestamp)
@takes_instance_or_queryset
def fetch_details(self, request, queryset):
scrape_event_details(queryset)

View File

@ -28,6 +28,8 @@ def make_multipart_email(
def make_instructor_email(
invoice: EventInvoice, pdf: bytes, event_url: str
) -> EmailMessage:
if invoice.event.instructor is None or invoice.event.instructor.member is None:
raise ValueError("Event Instructor not defined or is not member")
template = loader.get_template(
"membershipworks/email/event_invoice_instructor.dj.html"
)

View File

@ -2,6 +2,7 @@ import csv
import datetime
from enum import Enum
from io import StringIO
from typing import Any
import requests
@ -67,6 +68,13 @@ staticFlags = {
}
class NotAuthenticatedError(Exception):
def __init__(self) -> None:
super().__init__(
"Not authenticated to membershipworks, please call .login() first"
)
class MembershipWorksRemoteError(Exception):
def __init__(self, reason, r):
super().__init__(
@ -104,7 +112,7 @@ class MembershipWorks:
def _inject_auth(self, kwargs):
# TODO: should probably be a decorator or something
if self.auth_token is None:
raise RuntimeError("Not Logged in to MembershipWorks")
raise NotAuthenticatedError()
# add auth token to params
if "params" not in kwargs:
kwargs["params"] = {}
@ -126,6 +134,8 @@ class MembershipWorks:
Is this terrible? Yes. Also, not dissimilar to how MW does it
in all.js.
"""
if not self.org_info:
raise NotAuthenticatedError()
fields = staticFlags.copy()
# TODO: this will take the later option, if the same field
@ -148,7 +158,9 @@ class MembershipWorks:
This is terrible, and there might be a better way to do this.
"""
ret = {"folders": {}, "levels": {}, "addons": {}, "labels": {}}
if not self.org_info:
raise NotAuthenticatedError()
ret: dict[str, Any] = {"folders": {}, "levels": {}, "addons": {}, "labels": {}}
for dek in self.org_info["dek"]:
# TODO: there must be a better way. this is stupid
@ -242,8 +254,8 @@ class MembershipWorks:
def get_events_list(
self,
start_date: datetime.datetime = None,
end_date: datetime.datetime = None,
start_date: datetime.datetime | None = None,
end_date: datetime.datetime | None = None,
categories=False,
):
"""Retrive a list of events between `start_date` and `end_date`, optionally including category information"""
@ -269,6 +281,22 @@ class MembershipWorks:
)
return r.json()
def get_event_registrations(self, event_id: str):
r = self._get_v1(
BASE_URL + "/v1/csv",
params={
"_rt": "946702800", # unknown
"evt": event_id,
},
)
if r.status_code != requests.codes.ok:
raise MembershipWorksRemoteError("csv generation", r)
if r.text[0] == "\ufeff":
r.encoding = r.encoding + "-sig"
return list(csv.DictReader(StringIO(r.text)))
def get_event_by_url(self, url: str):
"""Retrieve a specific event by its url"""
r = self.sess.get(

View File

@ -0,0 +1,22 @@
# Generated by Django 5.0.4 on 2024-04-30 05:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("membershipworks", "0016_eventinvoice"),
]
operations = [
migrations.AddField(
model_name="eventext",
name="registrations",
field=models.JSONField(blank=True, null=True),
),
migrations.AlterField(
model_name="eventinvoice",
name="pdf",
field=models.FileField(upload_to="protected/invoices/%Y/%m/%d/"),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.0.6 on 2024-05-08 16:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("membershipworks", "0017_eventext_registrations_alter_eventinvoice_pdf"),
]
operations = [
migrations.AddField(
model_name="eventext",
name="details_timestamp",
field=models.GeneratedField(
db_persist=False,
expression=models.Func(
models.Func(models.F("details___ts"), function="FROM_UNIXTIME"),
template="CONVERT_TZ(%(expressions)s, @@session.time_zone, 'UTC')",
),
output_field=models.DateTimeField(),
verbose_name="Last details fetch",
),
),
]

View File

@ -1,6 +1,7 @@
import uuid
from datetime import datetime
from typing import Optional
from datetime import datetime, timedelta
from decimal import Decimal
from typing import TYPE_CHECKING, TypedDict
import django.core.mail.message
from django.conf import settings
@ -15,6 +16,7 @@ from django.db.models import (
Func,
OuterRef,
Q,
QuerySet,
Subquery,
Sum,
Value,
@ -26,12 +28,13 @@ from django.utils import timezone
import nh3
from django_db_views.db_view import DBView
from django_stubs_ext import WithAnnotations
class BaseModel(models.Model):
_api_names_override = {}
_date_fields = {}
_allowed_missing_fields = []
_api_names_override: dict[str, str] = {}
_date_fields: dict[str, str | None] = {}
_allowed_missing_fields: list[str] = []
class Meta:
abstract = True
@ -274,9 +277,10 @@ class Member(BaseModel):
]
@classmethod
def from_user(cls, user) -> Optional["Member"]:
def from_user(cls, user) -> "Member | None":
if hasattr(user, "ldap_user"):
return cls.objects.get(uid=user.ldap_user.attrs["employeeNumber"][0])
return None
def sanitized_mailbox(self, use_volunteer=False) -> str:
if use_volunteer and self.volunteer_email:
@ -447,7 +451,7 @@ class EventInstructor(models.Model):
return str(self.member) if self.member else self.name
class EventExtQuerySet(models.QuerySet["EventExt"]):
class EventExtQuerySet(models.QuerySet["EventExtAnnotated"]):
def summarize(self, aggregate: bool = False):
method = self.aggregate if aggregate else self.annotate
return method(
@ -465,7 +469,7 @@ class EventExtQuerySet(models.QuerySet["EventExt"]):
net_revenue__sum=Sum("net_revenue", filter=F("occurred")),
)
def with_financials(self):
def with_financials(self) -> "QuerySet[EventExtAnnotatedWithFinancials]":
return self.annotate(
**{
field: Subquery(
@ -495,40 +499,27 @@ class EventExtQuerySet(models.QuerySet["EventExt"]):
)
class EventExtManager(models.Manager["EventExt"]):
def get_queryset(self) -> models.QuerySet["EventExt"]:
return (
super()
.get_queryset()
.annotate(
meetings=Subquery(
EventMeetingTime.objects.filter(event=OuterRef("pk"))
.values("event__pk")
.annotate(d=Count("pk"))
.values("d")[:1],
output_field=models.IntegerField(),
),
duration=Subquery(
EventMeetingTime.objects.filter(event=OuterRef("pk"))
.values("event__pk")
.annotate(d=Sum("duration"))
.values("d")[:1],
output_field=models.DurationField(),
),
person_hours=ExpressionWrapper(
ExpressionWrapper(F("duration"), models.IntegerField())
* F("count"),
models.DurationField(),
),
# TODO: this could be a GeneratedField, but that
# currently breaks saving when the primary key is
# provided (Django 5.0.1)
details_timestamp=Func(
Func(F("details___ts"), function="FROM_UNIXTIME"),
template="CONVERT_TZ(%(expressions)s, @@session.time_zone, 'UTC')",
output_field=models.DateTimeField(),
),
)
class EventExtManager(models.Manager):
def get_queryset(self) -> EventExtQuerySet:
return EventExtQuerySet(self.model, using=self._db).annotate(
meetings=Subquery(
EventMeetingTime.objects.filter(event=OuterRef("pk"))
.values("event__pk")
.annotate(d=Count("pk"))
.values("d")[:1],
output_field=models.IntegerField(),
),
duration=Subquery(
EventMeetingTime.objects.filter(event=OuterRef("pk"))
.values("event__pk")
.annotate(d=Sum("duration"))
.values("d")[:1],
output_field=models.DurationField(),
),
person_hours=ExpressionWrapper(
ExpressionWrapper(F("duration"), models.IntegerField()) * F("count"),
models.DurationField(),
),
)
@ -551,6 +542,17 @@ class EventExt(Event):
max_digits=13, decimal_places=4, default=0
)
details = models.JSONField(null=True, blank=True)
details_timestamp = models.GeneratedField(
expression=Func(
Func(F("details___ts"), function="FROM_UNIXTIME"),
template="CONVERT_TZ(%(expressions)s, @@session.time_zone, 'UTC')",
),
output_field=models.DateTimeField(),
db_persist=False,
verbose_name="Last details fetch",
)
registrations = models.JSONField(null=True, blank=True)
def get_absolute_url(self) -> str:
return reverse("membershipworks:event-detail", kwargs={"eid": self.eid})
@ -573,7 +575,7 @@ class EventExt(Event):
self.materials_fee_included_in_price is not None
or self.materials_fee == 0
)
and self.total_due_to_instructor is not None
and getattr(self, "total_due_to_instructor") is not None
)
class Meta:
@ -581,6 +583,32 @@ class EventExt(Event):
ordering = ["-start"]
if TYPE_CHECKING:
class EventExtAnnotations(TypedDict):
meetings: int
duration: timedelta
person_hours: timedelta
details_timestamp: datetime
class EventExtFinancialAnnotations(TypedDict):
quantity: Decimal
amount: Decimal
materials: Decimal
amount_without_materials: Decimal
instructor_revenue: Decimal
instructor_amount: Decimal
total_due_to_instructor: Decimal
gross_revenue: Decimal
net_revenue: Decimal
EventExtAnnotated = WithAnnotations[EventExt, EventExtAnnotations]
EventExtAnnotatedWithFinancials = WithAnnotations[EventExt, EventExtAnnotations]
else:
EventExtAnnotated = WithAnnotations[EventExt]
EventExtAnnotatedWithFinancials = WithAnnotations[EventExt]
class EventMeetingTime(models.Model):
event = models.ForeignKey(
EventExt, on_delete=models.CASCADE, related_name="meeting_times"

View File

@ -65,6 +65,30 @@ class EventTable(tables.Table):
}
class EventRegistrationsTable(tables.Table):
ticket_count = tables.Column(empty_values=())
name = tables.Column(accessor="Full name")
email = tables.EmailColumn(accessor="Email")
phone = tables.Column(accessor="Phone")
emergency_contact_name = tables.Column(accessor="Emergency Contact Name:")
emergency_contact_phone_number = tables.Column(
accessor="Emergency Contact Phone Number:"
)
emergency_contact_relation = tables.Column(accessor="Emergency Contact Relation:")
def render_ticket_count(self, record):
return sum(int(v) for k, v in record.items() if k.startswith("Ticket: "))
class Meta:
row_attrs = {
"class": lambda table, record: (
""
if table.render_ticket_count(record) > 0
else "text-decoration-line-through table-danger"
)
}
class EventSummaryTable(tables.Table):
event_count = tables.Column("Events")
canceled_event_count = tables.Column("Canceled Events")

View File

@ -1,4 +1,5 @@
import logging
from collections.abc import Iterable
from datetime import datetime, timedelta
from django.conf import settings
@ -30,7 +31,7 @@ def flags_for_member(csv_member, all_flags, folders):
yield flag
def update_flags(mw_flags) -> list[Flag]:
def update_flags(mw_flags) -> Iterable[Flag]:
for typ, flags_of_type in mw_flags.items():
for name, id in flags_of_type.items():
flag = Flag(id=id, name=name, type=typ[:-1])
@ -110,6 +111,7 @@ def scrape_event_details(queryset: QuerySet[EventExt]):
for event in queryset:
event.details = membershipworks.get_event_by_eid(event.eid)
event.registrations = membershipworks.get_event_registrations(event.eid)
event.save()
@ -171,4 +173,5 @@ def scrape_events():
event_ext.end or event_ext.start
):
event_ext.details = membershipworks.get_event_by_eid(event.eid)
event_ext.registrations = membershipworks.get_event_registrations(event.eid)
event_ext.save()

View File

@ -0,0 +1,15 @@
{% if perms.membershipworks.view_eventext %}
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:event-index-report' %}">MW Event Reports</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:event-year-report' event.start|date:"Y" %}">{{ event.start|date:"Y" }}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:event-month-report' event.start|date:"Y" event.start|date:"m" %}">{{ event.start|date:"F" }}</a>
</li>
{% else %}
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:user-events' %}">My Events</a>
</li>
{% endif %}

View File

@ -7,8 +7,16 @@
{% block admin_link %}
{% url 'admin:membershipworks_eventext_change' event.pk %}
{% endblock %}
{% block breadcrumbs %}
{% include "./components/event_breadcrumbs.dj.html" %}
<li class="breadcrumb-item active" aria-current="page">{{ event.details.ttl|nh3 }}</li>
{% endblock %}
{% block content %}
<div class="container">
{% if event.registrations is not None %}
{% url 'membershipworks:event-registrations' event.pk as registrations_url %}
{% bootstrap_button href=registrations_url content="Show Registrations" %}
{% endif %}
{% include "membershipworks/event_invoice.dj.html" %}
<div class="card w-auto mt-5">

View File

@ -0,0 +1,29 @@
{% extends "base.dj.html" %}
{% load nh3_tags %}
{% load render_table from django_tables2 %}
{% load django_bootstrap5 %}
{% block title %}Registrations for {{ event.details.ttl|nh3 }}{% endblock %}
{% block admin_link %}
{% url 'admin:membershipworks_eventext_change' event.pk %}
{% endblock %}
{% block breadcrumbs %}
{% include "./components/event_breadcrumbs.dj.html" %}
<li class="breadcrumb-item">
<a href="{% url 'membershipworks:event-detail' event.pk %}">{{ event.details.ttl|nh3|truncatechars_html:40 }}</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Registrations</li>
{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-auto">
{% bootstrap_button extra_classes="btn-sm" href=email_link target="_blank" content="Email all attendees" %}
</div>
<div class="col-auto">{% include "cmsmanage/components/download_table.dj.html" %}</div>
</div>
{% render_table table %}
<p class="text-center">Data last updated {{ event.details_timestamp }}</p>
</div>
{% endblock %}

View File

@ -3,6 +3,7 @@
{% load render_table from django_tables2 %}
{% block title %}My Events{% endblock %}
{% block breadcrumbs %}<li class="breadcrumb-item active" aria-current="page">My Events</li>{% endblock %}
{% block content %}
{% include "cmsmanage/components/download_table.dj.html" %}

View File

@ -45,6 +45,11 @@ urlpatterns = [
views.EventDetailView.as_view(),
name="event-detail",
),
path(
"event/<eid>/registrations",
views.EventRegistrationsView.as_view(),
name="event-registrations",
),
path(
"event/invoice/<uuid:uuid>.pdf",
views.EventInvoicePDFView.as_view(),

View File

@ -1,6 +1,7 @@
import uuid
from datetime import datetime
from typing import Any
from urllib.parse import quote, urlencode
from django.conf import settings
from django.contrib import messages
@ -32,7 +33,7 @@ import django_tables2 as tables
import weasyprint
from dal import autocomplete
from django_filters.views import BaseFilterView
from django_mysql.models import GroupConcat
from django_mysql.models.aggregates import GroupConcat
from django_sendfile import sendfile
from django_tables2 import A, SingleTableMixin
from django_tables2.export.views import ExportMixin
@ -46,6 +47,7 @@ from .invoice_email import make_invoice_emails
from .models import EventAttendee, EventExt, EventInvoice, Member
from .tables import (
EventAttendeeTable,
EventRegistrationsTable,
EventSummaryTable,
EventTable,
InvoiceTable,
@ -241,7 +243,7 @@ class EventMonthReport(
class UserEventView(SingleTableMixin, ListView):
model = EventExt
model: type[EventExt] = EventExt
table_class = UserEventTable
export_formats = ("csv", "xlsx", "ods")
template_name = "membershipworks/user_event_list.dj.html"
@ -412,13 +414,56 @@ class EventInvoicePDFView(AccessMixin, BaseDetailView):
if request.user.has_perm(
"membershipworks.view_eventinvoice"
) or invoice.event.user_is_instructor(request.user):
# return HttpResponse(invoice.pdf.path)
return sendfile(request, invoice.pdf.path, mimetype="application/pdf")
else:
return self.handle_no_permission()
class EventRegistrationsView(ExportMixin, SingleTableMixin, AccessMixin, DetailView):
permission_required = "membershipworks.view_eventext"
model = EventExt
pk_url_kwarg = "eid"
context_object_name = "event"
template_name = "membershipworks/event_registrations.dj.html"
table_class = EventRegistrationsTable
export_formats = ("csv", "xlsx", "ods")
def render_to_response(
self, context: dict[str, Any], **response_kwargs: Any
) -> HttpResponse:
if self.request.user.has_perm(
self.permission_required
) or self.object.user_is_instructor(self.request.user):
return super().render_to_response(context, **response_kwargs)
else:
return self.handle_no_permission()
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context_data = super().get_context_data(**kwargs)
context_data["email_link"] = "mailto:?" + urlencode(
{
"subject": f"[CMS Event] {self.object.title}",
"bcc": ",".join(
mail.message.sanitize_address(
(reg["Full name"], reg["Email"]), settings.DEFAULT_CHARSET
)
for reg in self.object.registrations
if any(
int(v) > 0 for k, v in reg.items() if k.startswith("Ticket: ")
)
),
},
quote_via=quote,
)
return context_data
def get_table_data(self):
return self.object.registrations
class EventAttendeeFilters(django_filters.FilterSet):
new_since = django_filters.DateFilter(
field_name="event__start", method="filter_new_since"

View File

@ -1,4 +1,6 @@
from django.db.models import Q
from typing import Required, TypedDict
from django.db.models import Q, QuerySet
from rest_framework import routers, serializers, viewsets
from rest_framework.decorators import action
@ -20,8 +22,20 @@ class DepartmentSerializer(serializers.HyperlinkedModelSerializer):
fields = ["name", "parent", "shop_lead_flag", "list_reply_to_address"]
class ListConfig(TypedDict, total=False):
real_name: str
subject_prefix: str
reply_to_address: str
class MailingList(TypedDict, total=False):
config: ListConfig
moderators: set[str]
members: Required[set[str]]
class DepartmentViewSet(viewsets.ModelViewSet):
queryset = Department.objects.all()
queryset: QuerySet[Department] = Department.objects.all()
serializer_class = DepartmentSerializer
@action(detail=False, methods=["get"])
@ -34,15 +48,20 @@ class DepartmentViewSet(viewsets.ModelViewSet):
"children",
"shop_lead_flag__members",
)
lists = {}
lists: dict[str, MailingList] = {}
shopleads: dict[Member, list[Department]] = {}
for department in departments.filter(has_mailing_list=True):
if department.shop_lead_flag is not None:
moderator_emails = {
member.volunteer_email if member.volunteer_email else member.email
for member in department.shop_lead_flag.members.all()
}
for member in department.shop_lead_flag.members.all():
if member not in shopleads:
shopleads[member] = []
shopleads[member].append(department)
else:
moderator_emails = []
moderator_emails = set()
active_certified_members = {
member.sanitized_mailbox()
@ -52,6 +71,9 @@ class DepartmentViewSet(viewsets.ModelViewSet):
)
}
# list_name can only be None if has_mailing_list is False
assert department.list_name is not None
lists[department.list_name] = {
"config": {
"real_name": department.list_name,
@ -76,13 +98,6 @@ class DepartmentViewSet(viewsets.ModelViewSet):
if department.parent_id is None:
recurse_children(department)
shopleads = {}
for department in departments.filter(shop_lead_flag__isnull=False):
for member in department.shop_lead_flag.members.all():
if member not in shopleads:
shopleads[member] = []
shopleads[member].append(department)
# Add members to the Shop Leads mailing list, but don't configure it
lists["ShopLeads"] = {
"members": {

View File

@ -1,7 +1,7 @@
from django import forms
from django.core.exceptions import ValidationError
from dal import autocomplete
from dal.autocomplete import ModelSelect2
from .models import Certification, CertificationDefinition
@ -49,7 +49,7 @@ class CertificationForm(forms.ModelForm):
"notes",
]
widgets = {
"certification_version": autocomplete.ModelSelect2(
"certification_version": ModelSelect2(
url="paperwork:certification_version_autocomplete",
forward=["certification_definition"],
)

View File

@ -1,8 +1,11 @@
from collections.abc import Callable
from itertools import chain
from typing import TypedDict
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.auth.models import AbstractBaseUser, Permission
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.test import Client
from hypothesis import given
@ -19,9 +22,20 @@ from paperwork.models import (
)
class PermissionLookup(TypedDict):
codename: str
model: type[models.Model]
class PermissionRequiredViewTestCaseMixin:
permissions = []
path = None
permissions: list[PermissionLookup] = []
path: str
client: Client
user_with_permission: AbstractBaseUser
user_without_permission: AbstractBaseUser
assertEqual: Callable
@classmethod
def setUpTestData(cls):

View File

@ -1,7 +1,9 @@
from collections.abc import Iterable
from django.conf import settings
from django.contrib import staticfiles
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.staticfiles import finders as staticfiles_finders
from django.db import models
from django.db.models import (
Case,
@ -21,7 +23,7 @@ from django.views.generic import ListView
import requests
import weasyprint
from django_mysql.models import GroupConcat
from django_mysql.models.aggregates import GroupConcat
from django_tables2 import SingleTableMixin
from django_tables2.export.views import ExportMixin
@ -63,6 +65,7 @@ class MemberCertificationListView(ListView):
@login_required
def department_certifications(request):
departments: Iterable[Department]
if (member := Member.from_user(request.user)) is not None:
departments = Department.objects.filter_by_shop_lead(member)
else:
@ -115,7 +118,7 @@ def certification_pdf(request, cert_name):
html = weasyprint.HTML(f"{WIKI_URL}/index.php?title={wiki_page}")
stylesheet = staticfiles.finders.find("paperwork/certification-print.css")
stylesheet = staticfiles_finders.find("paperwork/certification-print.css")
pdf = html.write_pdf(stylesheets=[stylesheet])
return HttpResponse(
pdf,

357
pdm.lock
View File

@ -5,7 +5,7 @@
groups = ["default", "debug", "lint", "server", "typing", "dev"]
strategy = ["cross_platform"]
lock_version = "4.4.1"
content_hash = "sha256:d2a8240d2754416c9c2c277832f3b8a2401eae3b56bda6e412aaf285c1c3b955"
content_hash = "sha256:651200bd58f4159fe99a599564e0f83a89fd149e5e7300abd151d6a3bb6477a9"
[[package]]
name = "aiohttp"
@ -224,15 +224,15 @@ files = [
[[package]]
name = "bitstring"
version = "4.1.4"
requires_python = ">=3.7"
version = "4.2.1"
requires_python = ">=3.8"
summary = "Simple construction, analysis and modification of binary data."
dependencies = [
"bitarray<3.0.0,>=2.8.0",
"bitarray<3.0.0,>=2.9.0",
]
files = [
{file = "bitstring-4.1.4-py3-none-any.whl", hash = "sha256:da46c4d6f8f3fb75a85566fdd33d5083ba8b8f268ed76f34eefe5a00da426192"},
{file = "bitstring-4.1.4.tar.gz", hash = "sha256:94f3f1c45383ebe8fd4a359424ffeb75c2f290760ae8fcac421b44f89ac85213"},
{file = "bitstring-4.2.1-py3-none-any.whl", hash = "sha256:9ae5d89072b065d640d645d37c0efcd27284b2f79f1c48cc1cd38b54e1932b4f"},
{file = "bitstring-4.2.1.tar.gz", hash = "sha256:8abb5a661588c764bacf1a23d64c7bb57517d2841e3e6f54fb8c057119e0540d"},
]
[[package]]
@ -415,6 +415,16 @@ files = [
{file = "cssbeautifier-1.14.7.tar.gz", hash = "sha256:be7f1ea7a7b009f0172c2c0d0bebb2d136346e786f7182185ea944affb52135a"},
]
[[package]]
name = "cssselect"
version = "1.2.0"
requires_python = ">=3.7"
summary = "cssselect parses CSS3 Selectors and translates them to XPath 1.0"
files = [
{file = "cssselect-1.2.0-py2.py3-none-any.whl", hash = "sha256:da1885f0c10b60c03ed5eccbb6b68d6eff248d91976fcde348f395d54c9fd35e"},
{file = "cssselect-1.2.0.tar.gz", hash = "sha256:666b19839cfaddb9ce9d36bfe4c969132c647b92fc9088c4e23f786b30f1b3dc"},
]
[[package]]
name = "cssselect2"
version = "0.7.0"
@ -449,9 +459,31 @@ files = [
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
]
[[package]]
name = "dj-database-url"
version = "2.1.0"
summary = "Use Database URLs in your Django Application."
dependencies = [
"Django>=3.2",
"typing-extensions>=3.10.0.0",
]
files = [
{file = "dj-database-url-2.1.0.tar.gz", hash = "sha256:f2042cefe1086e539c9da39fad5ad7f61173bf79665e69bf7e4de55fa88b135f"},
{file = "dj_database_url-2.1.0-py3-none-any.whl", hash = "sha256:04bc34b248d4c21aaa13e4ab419ae6575ef5f10f3df735ce7da97722caa356e0"},
]
[[package]]
name = "dj-email-url"
version = "1.0.6"
summary = "Use an URL to configure email backend settings in your Django Application."
files = [
{file = "dj-email-url-1.0.6.tar.gz", hash = "sha256:55ffe3329e48f54f8a75aa36ece08f365e09d61f8a209773ef09a1d4760e699a"},
{file = "dj_email_url-1.0.6-py2.py3-none-any.whl", hash = "sha256:cbd08327fbb08b104eac160fb4703f375532e4c0243eb230f5b960daee7a96db"},
]
[[package]]
name = "django"
version = "5.0.4"
version = "5.0.6"
requires_python = ">=3.10"
summary = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
dependencies = [
@ -460,8 +492,8 @@ dependencies = [
"tzdata; sys_platform == \"win32\"",
]
files = [
{file = "Django-5.0.4-py3-none-any.whl", hash = "sha256:916423499d75d62da7aa038d19aef23d23498d8df229775eb0a6309ee1013775"},
{file = "Django-5.0.4.tar.gz", hash = "sha256:4bd01a8c830bb77a8a3b0e7d8b25b887e536ad17a81ba2dce5476135c73312bd"},
{file = "Django-5.0.6-py3-none-any.whl", hash = "sha256:8363ac062bb4ef7c3f12d078f6fa5d154031d129a15170a1066412af49d30905"},
{file = "Django-5.0.6.tar.gz", hash = "sha256:ff1b61005004e476e0aeea47c7f79b85864c70124030e95146315396f1e7951f"},
]
[[package]]
@ -504,28 +536,57 @@ files = [
[[package]]
name = "django-bootstrap5"
version = "24.1"
version = "24.2"
requires_python = ">=3.8"
summary = "Bootstrap 5 for Django"
dependencies = [
"Django>=3.2",
"Django>=4.2",
]
files = [
{file = "django-bootstrap5-24.1.tar.gz", hash = "sha256:fc272b5bb218690fe6f32d52b23c155ebb46fbc5a2856c84eb353c1bf5fc49ea"},
{file = "django_bootstrap5-24.1-py3-none-any.whl", hash = "sha256:c42b4f6e673d35af847486733da77104e1e98e7fd5caecc6d56f32a96cc77479"},
{file = "django_bootstrap5-24.2-py3-none-any.whl", hash = "sha256:6a5d83e9ff1952f7c07c54cebcb76c85f09787b8b57eeb4ec07554cd583acc64"},
{file = "django_bootstrap5-24.2.tar.gz", hash = "sha256:a3cee2b3d45745210c5b898af2917f310f44df746269fe09a93be28a0adc2a4b"},
]
[[package]]
name = "django-configurations"
version = "2.5.1"
requires_python = "<4.0,>=3.8"
summary = "A helper for organizing Django settings."
dependencies = [
"django>=3.2",
]
files = [
{file = "django-configurations-2.5.1.tar.gz", hash = "sha256:6e5083757e2bbdf9bb7850567536b96a93515f6b17503d74928ff628db2e0e94"},
{file = "django_configurations-2.5.1-py3-none-any.whl", hash = "sha256:ceb84858da2dac846b15e715c2fd936cfc4c7917c074aff8d31700564093955e"},
]
[[package]]
name = "django-configurations"
version = "2.5.1"
extras = ["database", "email"]
requires_python = "<4.0,>=3.8"
summary = "A helper for organizing Django settings."
dependencies = [
"dj-database-url",
"dj-email-url",
"django-configurations==2.5.1",
]
files = [
{file = "django-configurations-2.5.1.tar.gz", hash = "sha256:6e5083757e2bbdf9bb7850567536b96a93515f6b17503d74928ff628db2e0e94"},
{file = "django_configurations-2.5.1-py3-none-any.whl", hash = "sha256:ceb84858da2dac846b15e715c2fd936cfc4c7917c074aff8d31700564093955e"},
]
[[package]]
name = "django-db-views"
version = "0.1.6"
version = "0.1.7"
summary = "Handle database views. Allow to create migrations for database views. View migrations using django code. They can be reversed. Changes in model view definition are detected automatically. Support almost all options as regular makemigrations command"
dependencies = [
"Django",
"six",
]
files = [
{file = "django-db-views-0.1.6.tar.gz", hash = "sha256:05718bb87c819323d577b294ee75f25807e5bb767793aa27f1ecc4c7ae073172"},
{file = "django_db_views-0.1.6-py3-none-any.whl", hash = "sha256:1ae8a6b389a2e8a7a2e246050ce7688780343bf4fd4f9622263b607ae27e5524"},
{file = "django-db-views-0.1.7.tar.gz", hash = "sha256:7c0dc78aa5f53cc4eefc4d880450a8cb61bb8376b5494356e20383f71a3e1657"},
{file = "django_db_views-0.1.7-py3-none-any.whl", hash = "sha256:ffa399af1678e60f532f8c2b531927e94e8249e1012a83fc865234384419bff8"},
]
[[package]]
@ -584,15 +645,15 @@ files = [
[[package]]
name = "django-mysql"
version = "4.12.0"
version = "4.13.0"
requires_python = ">=3.8"
summary = "Django-MySQL extends Django's built-in MySQL and MariaDB support their specific features not available on other databases."
dependencies = [
"Django>=3.2",
]
files = [
{file = "django_mysql-4.12.0-py3-none-any.whl", hash = "sha256:1c188ee3a92590da21ce23351c309bb02df3b6611926521d312a9cdf6373c3d0"},
{file = "django_mysql-4.12.0.tar.gz", hash = "sha256:9a29b69ad30c85362984903379783b53731ee7b0cef4dfb4eb078f80e24f26d4"},
{file = "django_mysql-4.13.0-py3-none-any.whl", hash = "sha256:be7297fcaa65df109b270553881897adbdb41f67ceae308f6704544dd0a7d222"},
{file = "django_mysql-4.13.0.tar.gz", hash = "sha256:bb1e544ec1ff4b792a69785fb22bcd0e7c87646bd134843d3f0aad92d98e8eb7"},
]
[[package]]
@ -673,24 +734,24 @@ files = [
[[package]]
name = "django-stubs"
version = "4.2.7"
version = "5.0.0"
requires_python = ">=3.8"
summary = "Mypy stubs for Django"
dependencies = [
"asgiref",
"django",
"django-stubs-ext>=4.2.7",
"django-stubs-ext>=5.0.0",
"types-PyYAML",
"types-pytz",
"typing-extensions",
]
files = [
{file = "django-stubs-4.2.7.tar.gz", hash = "sha256:8ccd2ff4ee5adf22b9e3b7b1a516d2e1c2191e9d94e672c35cc2bc3dd61e0f6b"},
{file = "django_stubs-4.2.7-py3-none-any.whl", hash = "sha256:4cf4de258fa71adc6f2799e983091b9d46cfc67c6eebc68fe111218c9a62b3b8"},
{file = "django_stubs-5.0.0-py3-none-any.whl", hash = "sha256:084484cbe16a6d388e80ec687e46f529d67a232f3befaf55c936b3b476be289d"},
{file = "django_stubs-5.0.0.tar.gz", hash = "sha256:b8a792bee526d6cab31e197cb414ee7fa218abd931a50948c66a80b3a2548621"},
]
[[package]]
name = "django-stubs-ext"
version = "4.2.7"
version = "5.0.0"
requires_python = ">=3.8"
summary = "Monkey-patching and extensions for django-stubs"
dependencies = [
@ -698,23 +759,23 @@ dependencies = [
"typing-extensions",
]
files = [
{file = "django-stubs-ext-4.2.7.tar.gz", hash = "sha256:519342ac0849cda1559746c9a563f03ff99f636b0ebe7c14b75e816a00dfddc3"},
{file = "django_stubs_ext-4.2.7-py3-none-any.whl", hash = "sha256:45a5d102417a412e3606e3c358adb4744988a92b7b58ccf3fd64bddd5d04d14c"},
{file = "django_stubs_ext-5.0.0-py3-none-any.whl", hash = "sha256:8e1334fdf0c8bff87e25d593b33d4247487338aaed943037826244ff788b56a8"},
{file = "django_stubs_ext-5.0.0.tar.gz", hash = "sha256:5bacfbb498a206d5938454222b843d81da79ea8b6fcd1a59003f529e775bc115"},
]
[[package]]
name = "django-stubs"
version = "4.2.7"
version = "5.0.0"
extras = ["compatible-mypy"]
requires_python = ">=3.8"
summary = "Mypy stubs for Django"
dependencies = [
"django-stubs==4.2.7",
"mypy~=1.7.0",
"django-stubs==5.0.0",
"mypy~=1.10.0",
]
files = [
{file = "django-stubs-4.2.7.tar.gz", hash = "sha256:8ccd2ff4ee5adf22b9e3b7b1a516d2e1c2191e9d94e672c35cc2bc3dd61e0f6b"},
{file = "django_stubs-4.2.7-py3-none-any.whl", hash = "sha256:4cf4de258fa71adc6f2799e983091b9d46cfc67c6eebc68fe111218c9a62b3b8"},
{file = "django_stubs-5.0.0-py3-none-any.whl", hash = "sha256:084484cbe16a6d388e80ec687e46f529d67a232f3befaf55c936b3b476be289d"},
{file = "django_stubs-5.0.0.tar.gz", hash = "sha256:b8a792bee526d6cab31e197cb414ee7fa218abd931a50948c66a80b3a2548621"},
]
[[package]]
@ -768,35 +829,35 @@ files = [
[[package]]
name = "djangorestframework-stubs"
version = "3.14.5"
version = "3.15.0"
requires_python = ">=3.8"
summary = "PEP-484 stubs for django-rest-framework"
dependencies = [
"django-stubs>=4.2.7",
"django-stubs>=5.0.0",
"requests>=2.0.0",
"types-PyYAML>=5.4.3",
"types-requests>=0.1.12",
"typing-extensions>=3.10.0",
]
files = [
{file = "djangorestframework-stubs-3.14.5.tar.gz", hash = "sha256:5dd6f638aa5291fb7863e6166128a6ed20bf4986e2fc5cf334e6afc841797a09"},
{file = "djangorestframework_stubs-3.14.5-py3-none-any.whl", hash = "sha256:43d788fd50cda49b922cd411e59c5b8cdc3f3de49c02febae12ce42139f0269b"},
{file = "djangorestframework_stubs-3.15.0-py3-none-any.whl", hash = "sha256:6c634f16fe1f9b1654cfd921eca64cd4188ce8534ab5e3ec7e44aaa0ca969d93"},
{file = "djangorestframework_stubs-3.15.0.tar.gz", hash = "sha256:f60ee1c80abb01a77acc0169969e07c45c2739ae64667b9a0dd4a2e32697dcab"},
]
[[package]]
name = "djangorestframework-stubs"
version = "3.14.5"
version = "3.15.0"
extras = ["compatible-mypy"]
requires_python = ">=3.8"
summary = "PEP-484 stubs for django-rest-framework"
dependencies = [
"django-stubs[compatible-mypy]",
"djangorestframework-stubs==3.14.5",
"mypy~=1.7.0",
"djangorestframework-stubs==3.15.0",
"mypy~=1.10.0",
]
files = [
{file = "djangorestframework-stubs-3.14.5.tar.gz", hash = "sha256:5dd6f638aa5291fb7863e6166128a6ed20bf4986e2fc5cf334e6afc841797a09"},
{file = "djangorestframework_stubs-3.14.5-py3-none-any.whl", hash = "sha256:43d788fd50cda49b922cd411e59c5b8cdc3f3de49c02febae12ce42139f0269b"},
{file = "djangorestframework_stubs-3.15.0-py3-none-any.whl", hash = "sha256:6c634f16fe1f9b1654cfd921eca64cd4188ce8534ab5e3ec7e44aaa0ca969d93"},
{file = "djangorestframework_stubs-3.15.0.tar.gz", hash = "sha256:f60ee1c80abb01a77acc0169969e07c45c2739ae64667b9a0dd4a2e32697dcab"},
]
[[package]]
@ -1018,7 +1079,7 @@ files = [
[[package]]
name = "hypothesis"
version = "6.100.1"
version = "6.100.5"
requires_python = ">=3.8"
summary = "A library for property-based testing"
dependencies = [
@ -1026,23 +1087,23 @@ dependencies = [
"sortedcontainers<3.0.0,>=2.1.0",
]
files = [
{file = "hypothesis-6.100.1-py3-none-any.whl", hash = "sha256:3dacf6ec90e8d14aaee02cde081ac9a17d5b70105e45e6ac822db72052c0195b"},
{file = "hypothesis-6.100.1.tar.gz", hash = "sha256:ebff09d7fa4f1fb6a855a812baf17e578b4481b7b70ec6d96496210d1a4c6c35"},
{file = "hypothesis-6.100.5-py3-none-any.whl", hash = "sha256:d2f875a8791abdf68599e85cc9238f7239a73b72362d34be95e532e811766723"},
{file = "hypothesis-6.100.5.tar.gz", hash = "sha256:14e06081459ee96ca8f1ed996b6fc19f71910281e01f6a9fa3d9d6e68bbe4a25"},
]
[[package]]
name = "hypothesis"
version = "6.100.1"
version = "6.100.5"
extras = ["django"]
requires_python = ">=3.8"
summary = "A library for property-based testing"
dependencies = [
"django>=3.2",
"hypothesis==6.100.1",
"hypothesis==6.100.5",
]
files = [
{file = "hypothesis-6.100.1-py3-none-any.whl", hash = "sha256:3dacf6ec90e8d14aaee02cde081ac9a17d5b70105e45e6ac822db72052c0195b"},
{file = "hypothesis-6.100.1.tar.gz", hash = "sha256:ebff09d7fa4f1fb6a855a812baf17e578b4481b7b70ec6d96496210d1a4c6c35"},
{file = "hypothesis-6.100.5-py3-none-any.whl", hash = "sha256:d2f875a8791abdf68599e85cc9238f7239a73b72362d34be95e532e811766723"},
{file = "hypothesis-6.100.5.tar.gz", hash = "sha256:14e06081459ee96ca8f1ed996b6fc19f71910281e01f6a9fa3d9d6e68bbe4a25"},
]
[[package]]
@ -1057,7 +1118,7 @@ files = [
[[package]]
name = "ipython"
version = "8.23.0"
version = "8.24.0"
requires_python = ">=3.10"
summary = "IPython: Productive Interactive Computing"
dependencies = [
@ -1070,11 +1131,11 @@ dependencies = [
"pygments>=2.4.0",
"stack-data",
"traitlets>=5.13.0",
"typing-extensions; python_version < \"3.12\"",
"typing-extensions>=4.6; python_version < \"3.12\"",
]
files = [
{file = "ipython-8.23.0-py3-none-any.whl", hash = "sha256:07232af52a5ba146dc3372c7bf52a0f890a23edf38d77caef8d53f9cdc2584c1"},
{file = "ipython-8.23.0.tar.gz", hash = "sha256:7468edaf4f6de3e1b912e57f66c241e6fd3c7099f2ec2136e239e142e800274d"},
{file = "ipython-8.24.0-py3-none-any.whl", hash = "sha256:d7bf2f6c4314984e3e02393213bab8703cf163ede39672ce5918c51fe253a2a3"},
{file = "ipython-8.24.0.tar.gz", hash = "sha256:010db3f8a728a578bb641fdd06c063b9fb8e96a9464c63aec6310fbcb5e80501"},
]
[[package]]
@ -1186,15 +1247,6 @@ files = [
{file = "lxml-5.2.1.tar.gz", hash = "sha256:3f7765e69bbce0906a7c74d5fe46d2c7a7596147318dbc08e4a2431f3060e306"},
]
[[package]]
name = "lxml-stubs"
version = "0.5.1"
summary = "Type annotations for the lxml package"
files = [
{file = "lxml-stubs-0.5.1.tar.gz", hash = "sha256:e0ec2aa1ce92d91278b719091ce4515c12adc1d564359dfaf81efa7d4feab79d"},
{file = "lxml_stubs-0.5.1-py3-none-any.whl", hash = "sha256:1f689e5dbc4b9247cb09ae820c7d34daeb1fdbd1db06123814b856dae7787272"},
]
[[package]]
name = "markdown"
version = "3.4.1"
@ -1336,7 +1388,7 @@ files = [
[[package]]
name = "mypy"
version = "1.7.1"
version = "1.10.0"
requires_python = ">=3.8"
summary = "Optional static typing for Python"
dependencies = [
@ -1344,18 +1396,18 @@ dependencies = [
"typing-extensions>=4.1.0",
]
files = [
{file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"},
{file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"},
{file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"},
{file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"},
{file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"},
{file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"},
{file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"},
{file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"},
{file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"},
{file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"},
{file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"},
{file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"},
{file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"},
{file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"},
{file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"},
{file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"},
{file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"},
{file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"},
{file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"},
{file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"},
{file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"},
{file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"},
{file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"},
{file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"},
]
[[package]]
@ -1599,12 +1651,12 @@ files = [
[[package]]
name = "pydyf"
version = "0.8.0"
requires_python = ">=3.7"
version = "0.10.0"
requires_python = ">=3.8"
summary = "A low-level PDF generator."
files = [
{file = "pydyf-0.8.0-py3-none-any.whl", hash = "sha256:901186a2e9f897108139426a6486f5225bdcc9b70be2ec965f25111e42f8ac5d"},
{file = "pydyf-0.8.0.tar.gz", hash = "sha256:b22b1ef016141b54941ad66ed4e036a7bdff39c0b360993b283875c3f854dd9a"},
{file = "pydyf-0.10.0-py3-none-any.whl", hash = "sha256:ef76b6c0976a091a9e15827fb5800e5e37e7cd1a3ca4d4bd19d10a14ea8c0ae3"},
{file = "pydyf-0.10.0.tar.gz", hash = "sha256:357194593efaf61d7b48ab97c3d59722114934967c3df3d7878ca6dd25b04c30"},
]
[[package]]
@ -1781,27 +1833,27 @@ files = [
[[package]]
name = "ruff"
version = "0.3.7"
version = "0.4.3"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
files = [
{file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"},
{file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"},
{file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"},
{file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"},
{file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"},
{file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"},
{file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"},
{file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"},
{file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"},
{file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"},
{file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"},
{file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"},
{file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"},
{file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"},
{file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"},
{file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"},
{file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"},
{file = "ruff-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b70800c290f14ae6fcbb41bbe201cf62dfca024d124a1f373e76371a007454ce"},
{file = "ruff-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08a0d6a22918ab2552ace96adeaca308833873a4d7d1d587bb1d37bae8728eb3"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba1f14df3c758dd7de5b55fbae7e1c8af238597961e5fb628f3de446c3c40c5"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:819fb06d535cc76dfddbfe8d3068ff602ddeb40e3eacbc90e0d1272bb8d97113"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bfc9e955e6dc6359eb6f82ea150c4f4e82b660e5b58d9a20a0e42ec3bb6342b"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:510a67d232d2ebe983fddea324dbf9d69b71c4d2dfeb8a862f4a127536dd4cfb"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9ff11cd9a092ee7680a56d21f302bdda14327772cd870d806610a3503d001f"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29efff25bf9ee685c2c8390563a5b5c006a3fee5230d28ea39f4f75f9d0b6f2f"},
{file = "ruff-0.4.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b00e0bcccf0fc8d7186ed21e311dffd19761cb632241a6e4fe4477cc80ef6e"},
{file = "ruff-0.4.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262f5635e2c74d80b7507fbc2fac28fe0d4fef26373bbc62039526f7722bca1b"},
{file = "ruff-0.4.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7363691198719c26459e08cc17c6a3dac6f592e9ea3d2fa772f4e561b5fe82a3"},
{file = "ruff-0.4.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eeb039f8428fcb6725bb63cbae92ad67b0559e68b5d80f840f11914afd8ddf7f"},
{file = "ruff-0.4.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:927b11c1e4d0727ce1a729eace61cee88a334623ec424c0b1c8fe3e5f9d3c865"},
{file = "ruff-0.4.3-py3-none-win32.whl", hash = "sha256:25cacda2155778beb0d064e0ec5a3944dcca9c12715f7c4634fd9d93ac33fd30"},
{file = "ruff-0.4.3-py3-none-win_amd64.whl", hash = "sha256:7a1c3a450bc6539ef00da6c819fb1b76b6b065dec585f91456e7c0d6a0bbc725"},
{file = "ruff-0.4.3-py3-none-win_arm64.whl", hash = "sha256:71ca5f8ccf1121b95a59649482470c5601c60a416bf189d553955b0338e34614"},
{file = "ruff-0.4.3.tar.gz", hash = "sha256:ff0a3ef2e3c4b6d133fbedcf9586abfbe38d076041f2dc18ffb2c7e0485d5a07"},
]
[[package]]
@ -1925,15 +1977,15 @@ files = [
[[package]]
name = "tinycss2"
version = "1.1.1"
requires_python = ">=3.6"
version = "1.3.0"
requires_python = ">=3.8"
summary = "A tiny CSS parser"
dependencies = [
"webencodings>=0.4",
]
files = [
{file = "tinycss2-1.1.1-py3-none-any.whl", hash = "sha256:fe794ceaadfe3cf3e686b22155d0da5780dd0e273471a51846d0a02bc204fec8"},
{file = "tinycss2-1.1.1.tar.gz", hash = "sha256:b2e44dd8883c360c35dd0d1b5aad0b610e5156c2cb3b33434634e539ead9d8bf"},
{file = "tinycss2-1.3.0-py3-none-any.whl", hash = "sha256:54a8dbdffb334d536851be0226030e9505965bb2f30f21a4a82c55fb2a80fae7"},
{file = "tinycss2-1.3.0.tar.gz", hash = "sha256:152f9acabd296a8375fbca5b84c961ff95971fcfc32e79550c8df8e29118c54d"},
]
[[package]]
@ -1959,6 +2011,19 @@ files = [
{file = "traitlets-5.13.0.tar.gz", hash = "sha256:9b232b9430c8f57288c1024b34a8f0251ddcc47268927367a0dd3eeaca40deb5"},
]
[[package]]
name = "types-beautifulsoup4"
version = "4.12.0.20240229"
requires_python = ">=3.8"
summary = "Typing stubs for beautifulsoup4"
dependencies = [
"types-html5lib",
]
files = [
{file = "types-beautifulsoup4-4.12.0.20240229.tar.gz", hash = "sha256:e37e4cfa11b03b01775732e56d2c010cb24ee107786277bae6bc0fa3e305b686"},
{file = "types_beautifulsoup4-4.12.0.20240229-py3-none-any.whl", hash = "sha256:000cdddb8aee4effb45a04be95654de8629fb8594a4f2f1231cff81108977324"},
]
[[package]]
name = "types-bleach"
version = "6.1.0.20240331"
@ -1972,6 +2037,16 @@ files = [
{file = "types_bleach-6.1.0.20240331-py3-none-any.whl", hash = "sha256:399bc59bfd20a36a56595f13f805e56c8a08e5a5c07903e5cf6fafb5a5107dd4"},
]
[[package]]
name = "types-docutils"
version = "0.21.0.20240423"
requires_python = ">=3.8"
summary = "Typing stubs for docutils"
files = [
{file = "types-docutils-0.21.0.20240423.tar.gz", hash = "sha256:7716ec6c68b5179b7ba1738cace2f1326e64df9f44b7ab08d9904d32c23fc15f"},
{file = "types_docutils-0.21.0.20240423-py3-none-any.whl", hash = "sha256:7f6e84ba8fcd2454c5b8bb8d77384d091a901929cc2b31079316e10eb346580a"},
]
[[package]]
name = "types-html5lib"
version = "1.1.11.20240228"
@ -1983,12 +2058,52 @@ files = [
]
[[package]]
name = "types-pytz"
version = "2022.6.0.1"
summary = "Typing stubs for pytz"
name = "types-lxml"
version = "2024.4.14"
requires_python = ">=3.8"
summary = "Complete lxml external type annotation"
dependencies = [
"cssselect~=1.2",
"types-beautifulsoup4~=4.12",
"typing-extensions~=4.5",
]
files = [
{file = "types-pytz-2022.6.0.1.tar.gz", hash = "sha256:d078196374d1277e9f9984d49373ea043cf2c64d5d5c491fbc86c258557bd46f"},
{file = "types_pytz-2022.6.0.1-py3-none-any.whl", hash = "sha256:bea605ce5d5a5d52a8e1afd7656c9b42476e18a0f888de6be91587355313ddf4"},
{file = "types_lxml-2024.4.14-py3-none-any.whl", hash = "sha256:7e5f836067cde4fddce3cdbf2bac7192c764bf5ee6d3eb86c732ad1b84f265c5"},
{file = "types_lxml-2024.4.14.tar.gz", hash = "sha256:dd8105b579925af1b6ae77469f4fc835be3872b15e86cb46ad4fcc33b20c781d"},
]
[[package]]
name = "types-markdown"
version = "3.6.0.20240316"
requires_python = ">=3.8"
summary = "Typing stubs for Markdown"
files = [
{file = "types-Markdown-3.6.0.20240316.tar.gz", hash = "sha256:de9fb84860b55b647b170ca576895fcca61b934a6ecdc65c31932c6795b440b8"},
{file = "types_Markdown-3.6.0.20240316-py3-none-any.whl", hash = "sha256:d3ecd26a940781787c7b57a0e3c9d77c150db64e12989ef687059edc83dfd78a"},
]
[[package]]
name = "types-psycopg2"
version = "2.9.21.20240417"
requires_python = ">=3.8"
summary = "Typing stubs for psycopg2"
files = [
{file = "types-psycopg2-2.9.21.20240417.tar.gz", hash = "sha256:05db256f4a459fb21a426b8e7fca0656c3539105ff0208eaf6bdaf406a387087"},
{file = "types_psycopg2-2.9.21.20240417-py3-none-any.whl", hash = "sha256:644d6644d64ebbe37203229b00771012fb3b3bddd507a129a2e136485990e4f8"},
]
[[package]]
name = "types-pygments"
version = "2.18.0.20240506"
requires_python = ">=3.8"
summary = "Typing stubs for Pygments"
dependencies = [
"types-docutils",
"types-setuptools",
]
files = [
{file = "types-Pygments-2.18.0.20240506.tar.gz", hash = "sha256:4b4c37812c87bbde687dbf27adf5bac593745a321e57f678dbc311571ba2ac9d"},
{file = "types_Pygments-2.18.0.20240506-py3-none-any.whl", hash = "sha256:11c90bc1737c9af55e5569558b88df7c2233e12325cb516215f722271444e91d"},
]
[[package]]
@ -2013,6 +2128,16 @@ files = [
{file = "types_requests-2.31.0.20240406-py3-none-any.whl", hash = "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5"},
]
[[package]]
name = "types-setuptools"
version = "69.5.0.20240423"
requires_python = ">=3.8"
summary = "Typing stubs for setuptools"
files = [
{file = "types-setuptools-69.5.0.20240423.tar.gz", hash = "sha256:a7ba908f1746c4337d13f027fa0f4a5bcad6d1d92048219ba792b3295c58586d"},
{file = "types_setuptools-69.5.0.20240423-py3-none-any.whl", hash = "sha256:a4381e041510755a6c9210e26ad55b1629bc10237aeb9cb8b6bd24996b73db48"},
]
[[package]]
name = "types-urllib3"
version = "1.26.25.14"
@ -2024,12 +2149,12 @@ files = [
[[package]]
name = "typing-extensions"
version = "4.4.0"
requires_python = ">=3.7"
summary = "Backported and Experimental Type Hints for Python 3.7+"
version = "4.11.0"
requires_python = ">=3.8"
summary = "Backported and Experimental Type Hints for Python 3.8+"
files = [
{file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"},
{file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"},
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
]
[[package]]
@ -2186,8 +2311,8 @@ files = [
[[package]]
name = "weasyprint"
version = "61.2"
requires_python = ">=3.8"
version = "62.1"
requires_python = ">=3.9"
summary = "The Awesome Document Factory"
dependencies = [
"Pillow>=9.1.0",
@ -2196,12 +2321,12 @@ dependencies = [
"cssselect2>=0.1",
"fonttools[woff]>=4.0.0",
"html5lib>=1.1",
"pydyf>=0.8.0",
"tinycss2>=1.0.0",
"pydyf>=0.10.0",
"tinycss2>=1.3.0",
]
files = [
{file = "weasyprint-61.2-py3-none-any.whl", hash = "sha256:76c6dc0e75e09182d5645d92c66ddf86b1b992c9420235b723fb374b584e5bf4"},
{file = "weasyprint-61.2.tar.gz", hash = "sha256:47df6cfeeff8c6c28cf2e4caf837cde17715efe462708ada74baa2eb391b6059"},
{file = "weasyprint-62.1-py3-none-any.whl", hash = "sha256:654d4c266336cbf9acc4da118c7778ef5839717e6055d5b8f995cf50be200c46"},
{file = "weasyprint-62.1.tar.gz", hash = "sha256:bf3c1a9ac4194271a7cf117229c093744105b50ac2fa64c0a6e44e68ae742992"},
]
[[package]]

View File

@ -12,20 +12,20 @@ dependencies = [
"django-markdownx~=4.0",
"django-recurrence~=1.11",
"django-widget-tweaks~=1.5",
"django-stubs-ext~=4.2",
"django-stubs-ext~=5.0",
"markdownify~=0.12",
"mdformat~=0.7",
"mdformat-tables~=0.4",
"mysqlclient~=2.2",
"django-autocomplete-light~=3.11",
"weasyprint~=61.2",
"weasyprint~=62.1",
"requests~=2.31",
"semver~=3.0",
"djangorestframework~=3.15",
"django-q2~=1.6",
"lxml~=5.2",
"django-object-actions~=4.2",
"bitstring~=4.1",
"bitstring~=4.2",
"udm-rest-client~=1.2",
"openapi-client-udm~=1.0",
"django-nh3~=0.1",
@ -34,10 +34,11 @@ dependencies = [
"tablib[ods,xlsx]~=3.6",
"django-filter~=24.2",
"django-db-views~=0.1",
"django-mysql~=4.12",
"django-mysql~=4.13",
"django-weasyprint~=2.3",
"django-sendfile2~=0.7",
"django-bootstrap5~=24.1",
"django-bootstrap5~=24.2",
"django-configurations[database,email]~=2.5",
]
requires-python = ">=3.11"
@ -89,13 +90,14 @@ indent_size = 2
[tool.mypy]
plugins = [
"./cmsmanage/mypy_django_configurations_plugin.py",
"mypy_django_plugin.main",
"mypy_drf_plugin.main",
]
[tool.django-stubs]
django_settings_module = "cmsmanage.settings.dev"
django_settings_module = "cmsmanage.settings"
strict_settings = false
[[tool.pdm.source]]
url = "https://pypi.org/simple"
@ -111,24 +113,27 @@ include_packages = ["openapi-client-udm"]
[tool.pdm.dev-dependencies]
lint = [
"djlint~=1.34",
"ruff~=0.3",
"ruff~=0.4",
]
typing = [
"mypy~=1.7",
"django-stubs~=4.2",
"mypy~=1.10",
"django-stubs~=5.0",
"setuptools~=69.5",
"types-bleach~=6.1",
"types-requests~=2.31",
"types-urllib3~=1.26",
"djangorestframework-stubs[compatible-mypy]~=3.14",
"lxml-stubs~=0.5",
"djangorestframework-stubs[compatible-mypy]~=3.15",
"types-Markdown~=3.6",
"types-Pygments~=2.18",
"types-psycopg2~=2.9",
"types-lxml~=2024.4",
]
debug = [
"django-debug-toolbar~=4.3",
]
dev = [
"django-extensions~=3.2",
"ipython~=8.23",
"ipython~=8.24",
"hypothesis[django]~=6.100",
"tblib~=3.0",
]

View File

@ -1,6 +1,6 @@
from django import forms
from dal import autocomplete
from dal.autocomplete import ModelSelect2
from rentals.models import LockerInfo
@ -16,7 +16,7 @@ class LockerInfoForm(forms.ModelForm):
"notes",
]
widgets = {
"renter": autocomplete.ModelSelect2(
"renter": ModelSelect2(
url="membershipworks:member-autocomplete",
attrs={
"data-placeholder": "-- No Renter --",