From 5194a15e16ad45961aa3c1a03874fcd3d44f9466 Mon Sep 17 00:00:00 2001 From: Quan Pham <74226479+QuanMPhm@users.noreply.github.com> Date: Tue, 16 Jan 2024 09:13:30 -0500 Subject: [PATCH 001/110] Fixed allocation bug in views.py The if statement used the wrong operator to determine if the allocation status was changed to "Denied" or "Revoked" Signed-off-by: Quan Pham --- coldfront/core/allocation/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 216c3531ca..ec129fc396 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -238,7 +238,7 @@ def post(self, request, *args, **kwargs): allocation_obj.end_date = None allocation_obj.save() - if allocation_obj.status.name == ['Denied', 'Revoked']: + if allocation_obj.status.name in ['Denied', 'Revoked']: allocation_disable.send( sender=self.__class__, allocation_pk=allocation_obj.pk) allocation_users = allocation_obj.allocationuser_set.exclude( From 1e59faa6b8990efcc458569e18b6ac80daeb852d Mon Sep 17 00:00:00 2001 From: David Simpson Date: Fri, 21 Feb 2025 13:50:16 +0000 Subject: [PATCH 002/110] docs: Add note about django ldap auth and certs to config.md Signed-off-by: David Simpson --- docs/pages/config.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/pages/config.md b/docs/pages/config.md index e2f4ec396b..df2112cada 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -149,6 +149,10 @@ For more info on [ColdFront plugins](../../plugin/existing_plugins/) (Django app #### LDAP Auth +N.B. this uses django_auth_ldap - therefore ldaps cert paths will be taken from global OS +ldap config. E.g. /etc/{ldap,openldap}/ldap.conf and within TLS_CACERT + + !!! warning "Required" LDAP authentication backend requires `ldap3` and `django_auth_ldap`. ``` From ec983854724ab379788261098e914da635fcd002 Mon Sep 17 00:00:00 2001 From: Matthew Kusz Date: Mon, 24 Feb 2025 11:55:37 -0500 Subject: [PATCH 003/110] Fixes ubccr#647 Signed-off-by: Matthew Kusz --- .../templates/allocation/allocation_request_list.html | 2 +- coldfront/core/allocation/views.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/coldfront/core/allocation/templates/allocation/allocation_request_list.html b/coldfront/core/allocation/templates/allocation/allocation_request_list.html index 6e257e0bcb..fc478429fb 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_request_list.html +++ b/coldfront/core/allocation/templates/allocation/allocation_request_list.html @@ -43,7 +43,7 @@

Allocation Requests

{% for allocation in allocation_list %} {{allocation.pk}} - {{ allocation.created|date:"M. d, Y" }} + {{allocation_renewal_dates|get_value_from_dict:allocation.pk|default:allocation.created|date:"M. d, Y"}} {{allocation.project.title|truncatechars:50}} {{allocation.project.pi.first_name}} {{allocation.project.pi.last_name}} ({{allocation.project.pi.username}}) diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 216c3531ca..fada59021b 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -946,6 +946,16 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) allocation_list = Allocation.objects.filter( status__name__in=['New', 'Renewal Requested', 'Paid', 'Approved',]) + + allocation_renewal_dates = {} + for allocation in allocation_list.filter(status__name='Renewal Requested'): + allocation_history = allocation.history.all().order_by('-history_date') + for history in allocation_history: + if history.status.name != 'Renewal Requested': + break + allocation_renewal_dates[allocation.pk] = history.history_date + + context['allocation_renewal_dates'] = allocation_renewal_dates context['allocation_status_active'] = AllocationStatusChoice.objects.get(name='Active') context['allocation_list'] = allocation_list context['PROJECT_ENABLE_PROJECT_REVIEW'] = PROJECT_ENABLE_PROJECT_REVIEW From 79e9f109bbc42af642b47801fede4fbce7987351 Mon Sep 17 00:00:00 2001 From: Quan Pham Date: Fri, 7 Mar 2025 13:40:18 -0500 Subject: [PATCH 004/110] Added a signal for when allocation change requests are created Signed-off-by: Quan Pham --- coldfront/core/allocation/signals.py | 3 +++ coldfront/core/allocation/views.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/coldfront/core/allocation/signals.py b/coldfront/core/allocation/signals.py index 1c078f0a4b..51277b184f 100644 --- a/coldfront/core/allocation/signals.py +++ b/coldfront/core/allocation/signals.py @@ -14,3 +14,6 @@ allocation_change_approved = django.dispatch.Signal() #providing_args=["allocation_pk", "allocation_change_pk"] + +allocation_change_created = django.dispatch.Signal() + #providing_args=["allocation_pk", "allocation_change_pk"] diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 216c3531ca..1246b82e1d 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -53,6 +53,7 @@ allocation_activate_user, allocation_disable, allocation_remove_user, + allocation_change_created, allocation_change_approved,) from coldfront.core.allocation.utils import (generate_guauge_data_from_usage, get_user_resources) @@ -1811,6 +1812,12 @@ def post(self, request, *args, **kwargs): messages.success(request, 'Allocation change request successfully submitted.') + allocation_change_created.send( + sender=self.__class__, + allocation_pk=allocation_obj.pk, + allocation_change_pk=allocation_change_request_obj.pk,) + + send_allocation_admin_email(allocation_obj, 'New Allocation Change Request', 'email/new_allocation_change_request.txt', From 8af400e113fadfe58a92fcc7064dc36fcfcd8a34 Mon Sep 17 00:00:00 2001 From: Sajid Ali Date: Wed, 15 Jan 2025 15:13:50 -0500 Subject: [PATCH 005/110] OIDC config: set MOKEY_OIDC_PI_GROUP from env var Signed-off-by: Sajid Ali modified: coldfront/config/plugins/openid.py modified: coldfront/config/plugins/openid.py --- coldfront/config/plugins/openid.py | 1 + 1 file changed, 1 insertion(+) diff --git a/coldfront/config/plugins/openid.py b/coldfront/config/plugins/openid.py index 45971d0cb9..b1e0226148 100644 --- a/coldfront/config/plugins/openid.py +++ b/coldfront/config/plugins/openid.py @@ -19,6 +19,7 @@ AUTHENTICATION_BACKENDS += [ 'coldfront.plugins.mokey_oidc.auth.OIDCMokeyAuthenticationBackend', ] + MOKEY_OIDC_PI_GROUP= ENV.str('MOKEY_OIDC_PI_GROUP') else: AUTHENTICATION_BACKENDS += [ 'mozilla_django_oidc.auth.OIDCAuthenticationBackend', From fefbb7b63ad1687b1554f36af1c9b2c780d9be89 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Tue, 18 Mar 2025 11:01:22 -0400 Subject: [PATCH 006/110] Update mkdocs and fix broken links. --- docs/mkdocs.yml | 8 ------ docs/pages/config.md | 10 +++---- docs/pages/plugin/how_to_create_a_plugin.md | 6 ++-- docs/requirements.txt | 32 +++++++++++---------- 4 files changed, 25 insertions(+), 31 deletions(-) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 018bec7ec2..4b1ab7b0cc 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -29,14 +29,6 @@ plugins: options: show_source: false show_signature: false - - setup_commands: - - "import os" - - "import sys" - - "import django" - - "sys.path.insert(0, os.path.abspath('..'))" - - "os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'coldfront.config.settings')" - - "django.setup()" markdown_extensions: - footnotes diff --git a/docs/pages/config.md b/docs/pages/config.md index df2112cada..9714cf0c74 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -145,20 +145,20 @@ disabled: | EMAIL_ADMINS_ON_ALLOCATION_EXPIRE | Setting this to True will send a daily email notification to administrators with a list of allocations that have expired that day. | ### Plugin settings -For more info on [ColdFront plugins](../../plugin/existing_plugins/) (Django apps) +For more info on [ColdFront plugins](plugin/existing_plugins.md) (Django apps) #### LDAP Auth -N.B. this uses django_auth_ldap - therefore ldaps cert paths will be taken from global OS -ldap config. E.g. /etc/{ldap,openldap}/ldap.conf and within TLS_CACERT - - !!! warning "Required" LDAP authentication backend requires `ldap3` and `django_auth_ldap`. ``` $ pip install ldap3 django_auth_ldap ``` + This uses `django_auth_ldap` therefore ldaps cert paths will be taken from + global OS ldap config, `/etc/{ldap,openldap}/ldap.conf` and within `TLS_CACERT` + + | Name | Description | | :---------------------------|:----------------------------------------| | PLUGIN_AUTH_LDAP | Enable LDAP Authentication Backend. Default False | diff --git a/docs/pages/plugin/how_to_create_a_plugin.md b/docs/pages/plugin/how_to_create_a_plugin.md index 81164a46ae..5d3d3ae9a1 100644 --- a/docs/pages/plugin/how_to_create_a_plugin.md +++ b/docs/pages/plugin/how_to_create_a_plugin.md @@ -128,7 +128,7 @@ plugin_configs['PLUGIN_WEEKLYREPORTAPP'] = 'plugins/weeklyreportapp.py' ``` !!! Tip - Note: To override any other default ColdFront templates, follow [these instructions](../../config/#custom-branding) from our docs. + Note: To override any other default ColdFront templates, follow [these instructions](../config.md#custom-branding) from our docs. Your app should now be linked to ColdFront. @@ -193,6 +193,6 @@ plugin_configs['PLUGIN_WEEKLYREPORTAPP'] = 'plugins/weeklyreportapp.py' ``` !!! Tip - Note: To override any other default ColdFront templates, follow [these instructions](../../config/#custom-branding) from our docs. + Note: To override any other default ColdFront templates, follow [these instructions](../config.md#custom-branding) from our docs. -Your app should now be linked to ColdFront. \ No newline at end of file +Your app should now be linked to ColdFront. diff --git a/docs/requirements.txt b/docs/requirements.txt index 897e3f136e..fafc825b87 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,16 +1,18 @@ -htmlmin==0.1.12 -Jinja2==3.1.2 +Jinja2==3.1.6 jsmin==3.0.1 -Markdown==3.3.4 -MarkupSafe==2.1.2 -mkdocs==1.4.2 -mkdocs-material==9.0.11 -mkdocs-minify-plugin==0.6.2 -mkdocs-awesome-pages-plugin==2.8.0 -mkdocstrings==0.20.0 -mkdocstrings-python-legacy==0.2.3 -Pygments==2.14.0 -pymdown-extensions==9.9.2 -PyYAML==6.0 -six==1.16.0 -importlib-metadata==4.11.3 +Markdown==3.7 +MarkupSafe==3.0.2 +mkdocs==1.6.1 +mkdocs-autorefs==1.4.1 +mkdocs-awesome-pages-plugin==2.10.1 +mkdocs-get-deps==0.2.0 +mkdocs-material==9.6.9 +mkdocs-material-extensions==1.3.1 +mkdocs-minify-plugin==0.8.0 +mkdocstrings==0.29.0 +mkdocstrings-python==1.16.5 +Pygments==2.19.1 +pymdown-extensions==10.14.3 +PyYAML==6.0.2 +six==1.17.0 +importlib_metadata==8.6.1 From 253fa95169799477643f99daa77e15924ecff539 Mon Sep 17 00:00:00 2001 From: Connor Brock Date: Mon, 24 Mar 2025 11:08:15 +0000 Subject: [PATCH 007/110] Added ProjectCreationForm and changed associated view class. Signed-off-by: Connor Brock --- coldfront/core/project/forms.py | 5 +++++ coldfront/core/project/views.py | 5 +++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/coldfront/core/project/forms.py b/coldfront/core/project/forms.py index beeea24e9c..611726e69f 100644 --- a/coldfront/core/project/forms.py +++ b/coldfront/core/project/forms.py @@ -190,3 +190,8 @@ def clean(self): proj_attr = ProjectAttribute.objects.get(pk=cleaned_data.get('pk')) proj_attr.value = cleaned_data.get('new_value') proj_attr.clean() + +class ProjectCreationForm(forms.ModelForm): + class Meta: + model = Project + fields = ['title', 'description', 'field_of_science'] \ No newline at end of file diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 39d144c5e0..aecd812048 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -43,7 +43,8 @@ ProjectReviewForm, ProjectSearchForm, ProjectUserUpdateForm, - ProjectAttributeUpdateForm) + ProjectAttributeUpdateForm, + ProjectCreationForm) from coldfront.core.project.models import (Project, ProjectAttribute, ProjectReview, @@ -451,7 +452,7 @@ def post(self, request, *args, **kwargs): class ProjectCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = Project template_name_suffix = '_create_form' - fields = ['title', 'description', 'field_of_science', ] + form_class = ProjectCreationForm def test_func(self): """ UserPassesTestMixin Tests""" From 622dcfc386a39808fe143b3cfc75d74a96973e18 Mon Sep 17 00:00:00 2001 From: Matthew Kusz Date: Thu, 27 Mar 2025 10:50:20 -0400 Subject: [PATCH 008/110] Adds allocation limits for a resource Signed-off-by: Matthew Kusz --- coldfront/core/allocation/views.py | 19 +++++++++++++++++++ .../commands/add_resource_defaults.py | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index fada59021b..be624a444e 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -493,6 +493,25 @@ def form_valid(self, form): 'You need to create an account name. Create it by clicking the link under the "Allocation account" field.')) return self.form_invalid(form) + allocation_limit_objs = resource_obj.resourceattribute_set.filter( + resource_attribute_type__name='allocation_limit').first() + if allocation_limit_objs: + allocation_limit = int(allocation_limit_objs.value) + allocation_count = project_obj.allocation_set.filter( + resources=resource_obj, + status__name__in=[ + 'Active', 'New', + 'Renewal Requested', + 'Paid', + 'Payment Pending', + 'Payment Requested' + ] + ).count() + if allocation_count >= allocation_limit: + form.add_error(None, format_html( + 'Your project is at the allocation limit allowed for this resource.')) + return self.form_invalid(form) + usernames = form_data.get('users') usernames.append(project_obj.pi.username) usernames = list(set(usernames)) diff --git a/coldfront/core/resource/management/commands/add_resource_defaults.py b/coldfront/core/resource/management/commands/add_resource_defaults.py index dd29af8b5b..c1734f6e25 100644 --- a/coldfront/core/resource/management/commands/add_resource_defaults.py +++ b/coldfront/core/resource/management/commands/add_resource_defaults.py @@ -10,7 +10,7 @@ class Command(BaseCommand): def handle(self, *args, **options): - for attribute_type in ('Active/Inactive', 'Date', 'Int', + for attribute_type in ('Active/Inactive', 'Date', 'Int', 'Public/Private', 'Text', 'Yes/No', 'Attribute Expanded Text'): AttributeType.objects.get_or_create(name=attribute_type) @@ -36,6 +36,7 @@ def handle(self, *args, **options): ('RackUnits', 'Int'), ('InstallDate', 'Date'), ('WarrantyExpirationDate', 'Date'), + ('allocation_limit', 'Int'), ): ResourceAttributeType.objects.get_or_create( name=resource_attribute_type, attribute_type=AttributeType.objects.get(name=attribute_type)) From 59dac17aefc72d1766b56e4951ddb70bcb64cc41 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Sun, 20 Apr 2025 10:56:07 -0400 Subject: [PATCH 009/110] Use email sender for account upgrade notifications. Fixes #599 Fixes #600 Signed-off-by: Andrew E. Bruno --- coldfront/core/user/views.py | 3 ++- coldfront/core/utils/mail.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/coldfront/core/user/views.py b/coldfront/core/user/views.py index b68ac0e8ee..50e9316d97 100644 --- a/coldfront/core/user/views.py +++ b/coldfront/core/user/views.py @@ -23,6 +23,7 @@ logger = logging.getLogger(__name__) EMAIL_ENABLED = import_from_settings('EMAIL_ENABLED', False) if EMAIL_ENABLED: + EMAIL_SENDER = import_from_settings('EMAIL_SENDER') EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings( 'EMAIL_TICKET_SYSTEM_ADDRESS') @@ -208,7 +209,7 @@ def post(self, request): 'Upgrade Account Request', 'email/upgrade_account_request.txt', {'user': request.user}, - request.user.email, + EMAIL_SENDER, [EMAIL_TICKET_SYSTEM_ADDRESS] ) diff --git a/coldfront/core/utils/mail.py b/coldfront/core/utils/mail.py index 286cab4805..414f469f92 100644 --- a/coldfront/core/utils/mail.py +++ b/coldfront/core/utils/mail.py @@ -56,7 +56,7 @@ def send_email(subject, body, sender, receiver_list, cc=[]): send_mail(subject, body, sender, receiver_list, fail_silently=False) except SMTPException as e: - logger.error('Failed to send email to %s from %s with subject %s', + logger.error('Failed to send email from %s to %s with subject %s', sender, ','.join(receiver_list), subject) From 0703f0d5c1b60c77362ec27b6dfcfaff020ddc45 Mon Sep 17 00:00:00 2001 From: David Simpson Date: Mon, 24 Mar 2025 10:00:15 +0000 Subject: [PATCH 010/110] Add project signals to core/project views.py , signals.py Signed-off-by: David Simpson --- coldfront/core/project/signals.py | 16 ++++++++++++++++ coldfront/core/project/views.py | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 coldfront/core/project/signals.py diff --git a/coldfront/core/project/signals.py b/coldfront/core/project/signals.py new file mode 100644 index 0000000000..511463094b --- /dev/null +++ b/coldfront/core/project/signals.py @@ -0,0 +1,16 @@ +import django.dispatch + +project_new = django.dispatch.Signal() + #providing_args=["project_obj"] + +project_archive = django.dispatch.Signal() + #providing_args=["project_obj"] + +project_update = django.dispatch.Signal() + #providing_args=["project_obj"] + +project_activate_user = django.dispatch.Signal() + #providing_args=["project_user_pk"] + +project_remove_user = django.dispatch.Signal() + #providing_args=["project_user_pk"] diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 39d144c5e0..d08546ddb9 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -33,6 +33,11 @@ AllocationUserStatusChoice) from coldfront.core.allocation.signals import (allocation_activate_user, allocation_remove_user) +from coldfront.core.project.signals import (project_new, + project_archive, + project_activate_user, + project_remove_user, + project_update) from coldfront.core.grant.models import Grant from coldfront.core.project.forms import (ProjectAddUserForm, ProjectAddUsersToAllocationForm, @@ -441,6 +446,10 @@ def post(self, request, *args, **kwargs): end_date = datetime.datetime.now() project.status = project_status_archive project.save() + + # project signals + project_archive.send(sender=self.__class__,project_obj=project) + for allocation in project.allocation_set.filter(status__name='Active'): allocation.status = allocation_status_expired allocation.end_date = end_date @@ -475,6 +484,9 @@ def form_valid(self, form): status=ProjectUserStatusChoice.objects.get(name='Active') ) + # project signals + project_new.send(sender=self.__class__, project_obj=project_obj) + return super().form_valid(form) def get_success_url(self): @@ -509,6 +521,8 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def get_success_url(self): + # project signals + project_update.send(sender=self.__class__, project_obj=self.object) return reverse('project-detail', kwargs={'pk': self.object.pk}) @@ -703,6 +717,9 @@ def post(self, request, *args, **kwargs): project_user_obj = ProjectUser.objects.create( user=user_obj, project=project_obj, role=role_choice, status=project_user_active_status_choice) + # project signals + project_activate_user.send(sender=self.__class__,project_user_pk=project_user_obj.pk) + for allocation in Allocation.objects.filter(pk__in=allocation_form_data): if allocation.allocationuser_set.filter(user=user_obj).exists(): allocation_user_obj = allocation.allocationuser_set.get( @@ -821,6 +838,9 @@ def post(self, request, *args, **kwargs): project_user_obj.status = project_user_removed_status_choice project_user_obj.save() + # project signals + project_remove_user.send(sender=self.__class__,project_user_pk=project_user_obj.pk) + # get allocation to remove users from allocations_to_remove_user_from = project_obj.allocation_set.filter( status__name__in=['Active', 'New', 'Renewal Requested']) From 15ebb47d53a29a01364508ebdd8bcf0f312d1ebc Mon Sep 17 00:00:00 2001 From: geistling <34081638+geistling@users.noreply.github.com> Date: Wed, 6 Nov 2024 13:26:56 -0500 Subject: [PATCH 011/110] add api add token regeneration feature, improve token display add api Signed-off-by: geistling <34081638+geistling@users.noreply.github.com> update testing module Update api.py fix viewed_user/request.user comparison remove unnecessary variable declaration add django-oauth-toolkit to setup.py add labels to mislabeled filters fix typo fix fulfilled boolean filter add projectuser/allocation data optionality to project api query remove oauth from requirements Signed-off-by: geistling <34081638+geistling@users.noreply.github.com> --- coldfront/config/plugins/api.py | 23 ++ coldfront/config/settings.py | 1 + coldfront/config/urls.py | 3 + .../user/templates/user/user_profile.html | 66 ++-- coldfront/plugins/api/__init__.py | 0 coldfront/plugins/api/apps.py | 18 + coldfront/plugins/api/serializers.py | 167 +++++++++ .../api/templates/user/user_profile.html | 107 ++++++ coldfront/plugins/api/tests.py | 68 ++++ coldfront/plugins/api/urls.py | 17 + coldfront/plugins/api/views.py | 332 ++++++++++++++++++ requirements.txt | 2 + setup.py | 2 + 13 files changed, 774 insertions(+), 32 deletions(-) create mode 100644 coldfront/config/plugins/api.py create mode 100644 coldfront/plugins/api/__init__.py create mode 100644 coldfront/plugins/api/apps.py create mode 100644 coldfront/plugins/api/serializers.py create mode 100644 coldfront/plugins/api/templates/user/user_profile.html create mode 100644 coldfront/plugins/api/tests.py create mode 100644 coldfront/plugins/api/urls.py create mode 100644 coldfront/plugins/api/views.py diff --git a/coldfront/config/plugins/api.py b/coldfront/config/plugins/api.py new file mode 100644 index 0000000000..4c15fa8261 --- /dev/null +++ b/coldfront/config/plugins/api.py @@ -0,0 +1,23 @@ +from coldfront.config.base import INSTALLED_APPS + +INSTALLED_APPS += [ + 'django_filters', + 'rest_framework', + 'rest_framework.authtoken', + 'coldfront.plugins.api' + ] + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + # only use BasicAuthentication for test purposes + # 'rest_framework.authentication.BasicAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated' + ], + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend' + ], +} diff --git a/coldfront/config/settings.py b/coldfront/config/settings.py index 7ab3ccfc10..de2b08b879 100644 --- a/coldfront/config/settings.py +++ b/coldfront/config/settings.py @@ -22,6 +22,7 @@ 'PLUGIN_AUTH_OIDC': 'plugins/openid.py', 'PLUGIN_AUTH_LDAP': 'plugins/ldap.py', 'PLUGIN_LDAP_USER_SEARCH': 'plugins/ldap_user_search.py', + 'PLUGIN_API': 'plugins/api.py', } # This allows plugins to be enabled via environment variables. Can alternatively diff --git a/coldfront/config/urls.py b/coldfront/config/urls.py index cabf338dc8..0afbfa30cd 100644 --- a/coldfront/config/urls.py +++ b/coldfront/config/urls.py @@ -33,6 +33,9 @@ if settings.RESEARCH_OUTPUT_ENABLE: urlpatterns.append(path('research-output/', include('coldfront.core.research_output.urls'))) +if 'coldfront.plugins.api' in settings.INSTALLED_APPS: + urlpatterns.append(path('api/', include('coldfront.plugins.api.urls'))) + if 'coldfront.plugins.iquota' in settings.INSTALLED_APPS: urlpatterns.append(path('iquota/', include('coldfront.plugins.iquota.urls'))) diff --git a/coldfront/core/user/templates/user/user_profile.html b/coldfront/core/user/templates/user/user_profile.html index 18a7b0899a..864f5ee0ee 100644 --- a/coldfront/core/user/templates/user/user_profile.html +++ b/coldfront/core/user/templates/user/user_profile.html @@ -24,38 +24,40 @@

User Profile

- - - - - - - - - - - - - - - - + {% block profile_contents %} + + + + + + + + + + + + + + + + + {% endblock %}
University Role(s):{{group_list}}
Email:{{viewed_user.email}}
PI Status: - {% if viewed_user.userprofile.is_pi %} - Yes - {% elif not user == viewed_user %} - No - {% else %} -
-
- No -
-
- {% csrf_token %} - -
-
- {% endif %} -
Last Login:{{viewed_user.last_login}}
University Role(s):{{group_list}}
Email:{{viewed_user.email}}
PI Status: + {% if viewed_user.userprofile.is_pi %} + Yes + {% elif not user == viewed_user %} + No + {% else %} +
+
+ No +
+
+ {% csrf_token %} + +
+
+ {% endif %} +
Last Login:{{viewed_user.last_login}}
diff --git a/coldfront/plugins/api/__init__.py b/coldfront/plugins/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/plugins/api/apps.py b/coldfront/plugins/api/apps.py new file mode 100644 index 0000000000..f0a74afed8 --- /dev/null +++ b/coldfront/plugins/api/apps.py @@ -0,0 +1,18 @@ +import os + +from django.apps import AppConfig +from coldfront.core.utils.common import import_from_settings + +class ApiConfig(AppConfig): + name = 'coldfront.plugins.api' + + def ready(self): + # Dynamically add the api plugin templates directory to TEMPLATES['DIRS'] + BASE_DIR = import_from_settings('BASE_DIR') + TEMPLATES = import_from_settings('TEMPLATES') + api_templates_dir = os.path.join( + BASE_DIR, 'coldfront/plugins/api/templates' + ) + for template_setting in TEMPLATES: + if api_templates_dir not in template_setting['DIRS']: + template_setting['DIRS'] = [api_templates_dir] + template_setting['DIRS'] diff --git a/coldfront/plugins/api/serializers.py b/coldfront/plugins/api/serializers.py new file mode 100644 index 0000000000..3b91f45b48 --- /dev/null +++ b/coldfront/plugins/api/serializers.py @@ -0,0 +1,167 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from coldfront.core.resource.models import Resource +from coldfront.core.project.models import Project, ProjectUser +from coldfront.core.allocation.models import Allocation, AllocationChangeRequest + + +class UserSerializer(serializers.ModelSerializer): + + class Meta: + model = get_user_model() + fields = ( + 'id', + 'username', + 'first_name', + 'last_name', + 'is_active', + 'is_superuser', + 'is_staff', + 'date_joined', + ) + + +class ResourceSerializer(serializers.ModelSerializer): + resource_type = serializers.SlugRelatedField(slug_field='name', read_only=True) + + class Meta: + model = Resource + fields = ('id', 'resource_type', 'name', 'description', 'is_allocatable') + + +class AllocationSerializer(serializers.ModelSerializer): + resource = serializers.ReadOnlyField(source='get_resources_as_string') + project = serializers.SlugRelatedField(slug_field='title', read_only=True) + status = serializers.SlugRelatedField(slug_field='name', read_only=True) + + class Meta: + model = Allocation + fields = ( + 'id', + 'project', + 'resource', + 'status', + ) + + +class AllocationRequestSerializer(serializers.ModelSerializer): + project = serializers.SlugRelatedField(slug_field='title', read_only=True) + resource = serializers.ReadOnlyField(source='get_resources_as_string', read_only=True) + status = serializers.SlugRelatedField(slug_field='name', read_only=True) + fulfilled_date = serializers.DateTimeField(read_only=True) + created_by = serializers.SerializerMethodField(read_only=True) + fulfilled_by = serializers.SerializerMethodField(read_only=True) + time_to_fulfillment = serializers.DurationField(read_only=True) + + class Meta: + model = Allocation + fields = ( + 'id', + 'project', + 'resource', + 'status', + 'created', + 'created_by', + 'fulfilled_date', + 'fulfilled_by', + 'time_to_fulfillment', + ) + + def get_created_by(self, obj): + historical_record = obj.history.earliest() + creator = historical_record.history_user if historical_record else None + if not creator: + return None + return historical_record.history_user.username + + def get_fulfilled_by(self, obj): + historical_records = obj.history.filter(status__name='Active') + if historical_records: + user = historical_records.earliest().history_user + if user: + return user.username + return None + + +class AllocationChangeRequestSerializer(serializers.ModelSerializer): + allocation = AllocationSerializer(read_only=True) + status = serializers.SlugRelatedField(slug_field='name', read_only=True) + created_by = serializers.SerializerMethodField(read_only=True) + fulfilled_date = serializers.DateTimeField(read_only=True) + fulfilled_by = serializers.SerializerMethodField(read_only=True) + time_to_fulfillment = serializers.DurationField(read_only=True) + + class Meta: + model = AllocationChangeRequest + fields = ( + 'id', + 'allocation', + 'justification', + 'status', + 'created', + 'created_by', + 'fulfilled_date', + 'fulfilled_by', + 'time_to_fulfillment', + ) + + def get_created_by(self, obj): + historical_record = obj.history.earliest() + creator = historical_record.history_user if historical_record else None + if not creator: + return None + return historical_record.history_user.username + + def get_fulfilled_by(self, obj): + if not obj.status.name == 'Approved': + return None + historical_record = obj.history.latest() + fulfiller = historical_record.history_user if historical_record else None + if not fulfiller: + return None + return historical_record.history_user.username + + +class ProjAllocationSerializer(serializers.ModelSerializer): + resource = serializers.ReadOnlyField(source='get_resources_as_string') + status = serializers.SlugRelatedField(slug_field='name', read_only=True) + + class Meta: + model = Allocation + fields = ('id', 'resource', 'status') + + +class ProjectUserSerializer(serializers.ModelSerializer): + user = serializers.SlugRelatedField(slug_field='username', read_only=True) + status = serializers.SlugRelatedField(slug_field='name', read_only=True) + role = serializers.SlugRelatedField(slug_field='name', read_only=True) + + class Meta: + model = ProjectUser + fields = ('user', 'role', 'status') + + +class ProjectSerializer(serializers.ModelSerializer): + pi = serializers.SlugRelatedField(slug_field='username', read_only=True) + status = serializers.SlugRelatedField(slug_field='name', read_only=True) + project_users = serializers.SerializerMethodField() + allocations = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ('id', 'title', 'pi', 'status', 'project_users', 'allocations') + + def get_project_users(self, obj): + request = self.context.get('request', None) + if request and request.query_params.get('project_users') in ['true','True']: + return ProjectUserSerializer(obj.projectuser_set, many=True, read_only=True).data + return None + + def get_allocations(self, obj): + request = self.context.get('request', None) + if request and request.query_params.get('allocations') in ['true','True']: + return ProjAllocationSerializer(obj.allocation_set, many=True, read_only=True).data + return None diff --git a/coldfront/plugins/api/templates/user/user_profile.html b/coldfront/plugins/api/templates/user/user_profile.html new file mode 100644 index 0000000000..deab5f8c6f --- /dev/null +++ b/coldfront/plugins/api/templates/user/user_profile.html @@ -0,0 +1,107 @@ +{% extends "user/user_profile.html" %} +{% block profile_contents %} + {% csrf_token %} + {{ block.super }} + {% if viewed_user == request.user %} + + API Token: + +
+ {% if request.user.auth_token.key %} + + •••••••{{ request.user.auth_token.key|slice:"-6:" }} + + {% else %} + None + {% endif %} +
+
+ + + + + {% endif %} + +{% endblock %} diff --git a/coldfront/plugins/api/tests.py b/coldfront/plugins/api/tests.py new file mode 100644 index 0000000000..0ffbabf17e --- /dev/null +++ b/coldfront/plugins/api/tests.py @@ -0,0 +1,68 @@ +from rest_framework import status +from rest_framework.test import APITestCase +from coldfront.core.allocation.models import Allocation +from coldfront.core.project.models import Project + + +class ColdfrontAPI(APITestCase): + """Tests for the Coldfront REST API""" + + def test_requires_login(self): + """Test that the API requires authentication""" + response = self.client.get('/api/') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_allocation_request_api_permissions(self): + """Test that accessing the allocation-request API view as an admin returns all + allocations, and that accessing it as a user is forbidden""" + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get('/api/allocation-requests/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.client.force_login(self.pi_user) + response = self.client.get('/api/allocation-requests/', format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_allocation_api_permissions(self): + """Test that accessing the allocation API view as an admin returns all + allocations, and that accessing it as a user returns only the allocations + for that user""" + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get('/api/allocations/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), Allocation.objects.all().count()) + + self.client.force_login(self.pi_user) + response = self.client.get('/api/allocations/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) + + def test_project_api_permissions(self): + """Confirm permissions for project API: + admin user should be able to access everything + Projectusers should be able to access only their projects + """ + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get('/api/projects/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), Project.objects.all().count()) + + self.client.force_login(self.pi_user) + response = self.client.get('/api/projects/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_user_api_permissions(self): + """Test that accessing the user API view as an admin returns all + allocations, and that accessing it as a user is forbidden""" + # login as admin + self.client.force_login(self.admin_user) + response = self.client.get('/api/users/', format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.client.force_login(self.pi_user) + response = self.client.get('/api/users/', format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/coldfront/plugins/api/urls.py b/coldfront/plugins/api/urls.py new file mode 100644 index 0000000000..c1831e7dc8 --- /dev/null +++ b/coldfront/plugins/api/urls.py @@ -0,0 +1,17 @@ +from django.urls import include, path +from rest_framework import routers +from coldfront.plugins.api import views + +router = routers.DefaultRouter() +router.register(r'allocations', views.AllocationViewSet, basename='allocations') +router.register(r'allocation-requests', views.AllocationRequestViewSet, basename='allocation-requests') +router.register(r'allocation-change-requests', views.AllocationChangeRequestViewSet, basename='allocation-change-requests') +router.register(r'projects', views.ProjectViewSet, basename='projects') +router.register(r'resources', views.ResourceViewSet, basename='resources') +router.register(r'users', views.UserViewSet, basename='users') + +urlpatterns = [ + path('', include(router.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + path('regenerate-token/', views.regenerate_token, name='regenerate_token'), +] diff --git a/coldfront/plugins/api/views.py b/coldfront/plugins/api/views.py new file mode 100644 index 0000000000..0679fc7b85 --- /dev/null +++ b/coldfront/plugins/api/views.py @@ -0,0 +1,332 @@ +import logging +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.db.models import OuterRef, Subquery, Q, F, ExpressionWrapper, fields +from django.db.models.functions import Cast +from django_filters import rest_framework as filters +from rest_framework import viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from rest_framework.authtoken.models import Token +from rest_framework.permissions import IsAuthenticated, IsAdminUser +from simple_history.utils import get_history_model_for_model + +from coldfront.core.allocation.models import Allocation, AllocationChangeRequest +from coldfront.core.project.models import Project +from coldfront.core.resource.models import Resource +from coldfront.plugins.api import serializers + +logger = logging.getLogger(__name__) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def regenerate_token(request): + old_token = None + if hasattr(request.user, 'auth_token'): + old_token = request.user.auth_token.key[-6:] # Last 6 chars for logging + # Delete existing token + Token.objects.filter(user=request.user).delete() + + # Create new token + token = Token.objects.create(user=request.user) + + logger.info( + "API token regenerated for user %s (uid: %s). Old token ending: %s", + request.user.username, + request.user.id, + old_token or 'None' + ) + return Response({'token': token.key}) + + +class ResourceViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = serializers.ResourceSerializer + queryset = Resource.objects.all() + + +class AllocationViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = serializers.AllocationSerializer + # permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + + def get_queryset(self): + allocations = Allocation.objects.prefetch_related( + 'project', 'project__pi', 'status' + ) + + if not (self.request.user.is_superuser or self.request.user.has_perm( + 'allocation.can_view_all_allocations' + )): + allocations = allocations.filter( + Q(project__status__name__in=['New', 'Active']) & + ( + ( + Q(project__projectuser__role__name__contains='Manager') + & Q(project__projectuser__user=self.request.user) + ) + | Q(project__pi=self.request.user) + ) + ).distinct() + + allocations = allocations.order_by('project') + + return allocations + + +class AllocationRequestFilter(filters.FilterSet): + '''Filters for AllocationChangeRequestViewSet. + created_before is the date the request was created before. + created_after is the date the request was created after. + ''' + created = filters.DateFromToRangeFilter() + fulfilled = filters.BooleanFilter(method='filter_fulfilled', label='Fulfilled') + fulfilled_date = filters.DateFromToRangeFilter(label='Date fulfilled') + time_to_fulfillment = filters.NumericRangeFilter(method='filter_time_to_fulfillment', label='Time to fulfillment') + + class Meta: + model = Allocation + fields = [ + 'created', + 'fulfilled', + 'fulfilled_date', + 'time_to_fulfillment', + ] + + def filter_fulfilled(self, queryset, name, value): + if value: + return queryset.filter(fulfilled_date__isnull=False) + else: + return queryset.filter(fulfilled_date__isnull=True) + + def filter_time_to_fulfillment(self, queryset, name, value): + if value.start is not None: + queryset = queryset.filter( + time_to_fulfillment__gte=timedelta(days=int(value.start)) + ) + if value.stop is not None: + queryset = queryset.filter( + time_to_fulfillment__lte=timedelta(days=int(value.stop)) + ) + return queryset + + +class AllocationRequestViewSet(viewsets.ReadOnlyModelViewSet): + '''Report view on allocations requested through Coldfront. + Data: + - id: allocation id + - project: project name + - resource: resource name + - status: current status of the allocation + - created: date created + - created_by: user who submitted the allocation request + - fulfilled_date: date the allocation's status was first set to "Active" + - fulfilled_by: user who first set the allocation status to "Active" + - time_to_fulfillment: time between request creation and time_to_fulfillment + displayed as "DAY_INTEGER HH:MM:SS" + + Filters: + - created_before/created_after (structure date as 'YYYY-MM-DD') + - fulfilled (boolean) + Set to true to return all approved requests, false to return all pending and denied requests. + - fulfilled_date_before/fulfilled_date_after (structure date as 'YYYY-MM-DD') + - time_to_fulfillment_max/time_to_fulfillment_min (integer) + Set to the maximum/minimum number of days between request creation and time_to_fulfillment. + ''' + serializer_class = serializers.AllocationRequestSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = AllocationRequestFilter + permission_classes = [IsAuthenticated, IsAdminUser] + + def get_queryset(self): + HistoricalAllocation = get_history_model_for_model(Allocation) + + # Subquery to get the earliest historical record for each allocation + earliest_history = HistoricalAllocation.objects.filter( + id=OuterRef('pk') + ).order_by('history_date').values('status__name')[:1] + + fulfilled_date = HistoricalAllocation.objects.filter( + id=OuterRef('pk'), status__name='Active' + ).order_by('history_date').values('modified')[:1] + + # Annotate allocations with the status_id of their earliest historical record + allocations = Allocation.objects.annotate( + earliest_status_name=Subquery(earliest_history) + ).filter(earliest_status_name='New').order_by('created') + + allocations = allocations.annotate( + fulfilled_date=Subquery(fulfilled_date) + ) + + allocations = allocations.annotate( + time_to_fulfillment=ExpressionWrapper( + (Cast(Subquery(fulfilled_date), fields.DateTimeField()) - F('created')), + output_field=fields.DurationField() + ) + ) + return allocations + + +class AllocationChangeRequestFilter(filters.FilterSet): + '''Filters for AllocationChangeRequestViewSet. + created_before is the date the request was created before. + created_after is the date the request was created after. + ''' + created = filters.DateFromToRangeFilter() + fulfilled = filters.BooleanFilter(method='filter_fulfilled', label='Fulfilled') + fulfilled_date = filters.DateFromToRangeFilter(label='Date fulfilled') + time_to_fulfillment = filters.NumericRangeFilter(method='filter_time_to_fulfillment', label='Time to fulfillment') + + class Meta: + model = AllocationChangeRequest + fields = [ + 'created', + 'fulfilled', + 'fulfilled_date', + 'time_to_fulfillment', + ] + + def filter_fulfilled(self, queryset, name, value): + if value: + return queryset.filter(status__name='Approved') + else: + return queryset.filter(status__name__in=['Pending', 'Denied']) + + def filter_time_to_fulfillment(self, queryset, name, value): + if value.start is not None: + queryset = queryset.filter( + time_to_fulfillment__gte=timedelta(days=int(value.start)) + ) + if value.stop is not None: + queryset = queryset.filter( + time_to_fulfillment__lte=timedelta(days=int(value.stop)) + ) + return queryset + + +class AllocationChangeRequestViewSet(viewsets.ReadOnlyModelViewSet): + ''' + Data: + - allocation: allocation object details + - justification: justification provided at time of filing + - status: request status + - created: date created + - created_by: user who created the object. + - fulfilled_date: date the allocationchangerequests's status was first set to "Approved" + - fulfilled_by: user who last modified an approved object. + + Query parameters: + - created_before/created_after (structure date as 'YYYY-MM-DD') + - fulfilled (boolean) + Set to true to return all approved requests, false to return all pending and denied requests. + - fulfilled_date_before/fulfilled_date_after (structure date as 'YYYY-MM-DD') + - time_to_fulfillment_max/time_to_fulfillment_min (integer) + Set to the maximum/minimum number of days between request creation and time_to_fulfillment. + ''' + serializer_class = serializers.AllocationChangeRequestSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = AllocationChangeRequestFilter + + def get_queryset(self): + requests = AllocationChangeRequest.objects.prefetch_related( + 'allocation', 'allocation__project', 'allocation__project__pi' + ) + + if not (self.request.user.is_superuser or self.request.user.is_staff): + requests = requests.filter( + Q(allocation__project__status__name__in=['New', 'Active']) & + ( + ( + Q(allocation__project__projectuser__role__name__contains='Manager') + & Q(allocation__project__projectuser__user=self.request.user) + ) + | Q(allocation__project__pi=self.request.user) + ) + ).distinct() + + HistoricalAllocationChangeRequest = get_history_model_for_model( + AllocationChangeRequest + ) + + fulfilled_date = HistoricalAllocationChangeRequest.objects.filter( + id=OuterRef('pk'), status__name='Approved' + ).order_by('history_date').values('modified')[:1] + + requests = requests.annotate(fulfilled_date=Subquery(fulfilled_date)) + + requests = requests.annotate( + time_to_fulfillment=ExpressionWrapper( + (Cast(Subquery(fulfilled_date), fields.DateTimeField()) - F('created')), + output_field=fields.DurationField() + ) + ) + requests = requests.order_by('created') + + return requests + + +class ProjectViewSet(viewsets.ReadOnlyModelViewSet): + ''' + Query parameters: + - allocations (default false) + Show related allocation data. + - project_users (default false) + Show related user data. + ''' + serializer_class = serializers.ProjectSerializer + + def get_queryset(self): + projects = Project.objects.prefetch_related('status') + + if not ( + self.request.user.is_superuser + or self.request.user.is_staff + or self.request.user.has_perm('project.can_view_all_projects') + ): + projects = projects.filter( + Q(status__name__in=['New', 'Active']) & + ( + ( + Q(projectuser__role__name__contains='Manager') + & Q(projectuser__user=self.request.user) + ) + | Q(pi=self.request.user) + ) + ).distinct().order_by('pi') + + if self.request.query_params.get('project_users') in ['True', 'true']: + projects = projects.prefetch_related('projectuser_set') + + if self.request.query_params.get('allocations') in ['True', 'true']: + projects = projects.prefetch_related('allocation_set') + + return projects.order_by('pi') + + +class UserFilter(filters.FilterSet): + is_staff = filters.BooleanFilter() + is_active = filters.BooleanFilter() + is_superuser = filters.BooleanFilter() + username = filters.CharFilter(field_name='username', lookup_expr='exact') + + class Meta: + model = get_user_model() + fields = ['is_staff', 'is_active', 'is_superuser', 'username'] + + +class UserViewSet(viewsets.ReadOnlyModelViewSet): + '''Staff and superuser-only view for user data. + Filter parameters: + - username (exact) + - is_active + - is_superuser + - is_staff + ''' + serializer_class = serializers.UserSerializer + filter_backends = (filters.DjangoFilterBackend,) + filterset_class = UserFilter + permission_classes = [IsAuthenticated, IsAdminUser] + + def get_queryset(self): + queryset = get_user_model().objects.all() + return queryset diff --git a/requirements.txt b/requirements.txt index efcf7fbbe7..e7b5d4a7a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ Django==4.2.11 django-crispy-forms==2.1 crispy-bootstrap4==2024.1 django-environ==0.11.2 +django-filter==24.2 django-model-utils==4.4.0 django-picklefield==3.1 django-q==1.3.9 @@ -17,6 +18,7 @@ django-simple-history==3.5.0 django-split-settings==1.3.0 django-sslserver==0.22 django-su==1.0.0 +djangorestframework==3.15.2 doi2bib==0.4.0 factory-boy==3.3.0 Faker==24.1.0 diff --git a/setup.py b/setup.py index c6764581d8..7c532f5fe0 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,7 @@ 'django-crispy-forms==2.1', 'crispy-bootstrap4==2024.1', 'django-environ==0.11.2', + 'django-filter==24.2', 'django-model-utils==4.4.0', 'django-picklefield==3.1', 'django-q==1.3.9', @@ -43,6 +44,7 @@ 'django-split-settings==1.3.0', 'django-sslserver==0.22', 'django-su==1.0.0', + 'djangorestframework==3.15.2', 'doi2bib==0.4.0', 'factory-boy==3.3.0', 'Faker==24.1.0', From 3beb4a21998db7155f2a879690d9d81bddbaf8d3 Mon Sep 17 00:00:00 2001 From: Connor Brock Date: Fri, 6 Dec 2024 18:28:35 +0000 Subject: [PATCH 012/110] Implemented project code feature. Project view, model, utils, admin, management commands, docs, templates adjusted. Signed-off-by: Connor Brock --- coldfront/config/core.py | 8 ++ coldfront/core/project/admin.py | 10 +++ .../management/commands/add_project_codes.py | 48 +++++++++++ ...lter_historicalproject_options_and_more.py | 83 +++++++++++++++++++ coldfront/core/project/models.py | 1 + .../project/project_archived_list.html | 6 +- .../templates/project/project_detail.html | 5 ++ .../templates/project/project_list.html | 6 +- coldfront/core/project/tests.py | 74 ++++++++++++++++- coldfront/core/project/utils.py | 18 ++++ coldfront/core/project/views.py | 20 +++++ docs/pages/config.md | 3 +- 12 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 coldfront/core/project/management/commands/add_project_codes.py create mode 100644 coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py diff --git a/coldfront/config/core.py b/coldfront/config/core.py index 10f2b8a6ea..27c5e7468c 100644 --- a/coldfront/config/core.py +++ b/coldfront/config/core.py @@ -92,3 +92,11 @@ Please see instructions on our website. Staff, students, and external collaborators must request an account through a university faculty member. ''' + + +#------------------------------------------------------------------------------ +# Provide institution project code. +#------------------------------------------------------------------------------ + +PROJECT_CODE = ENV.str('PROJECT_CODE', default=None) +PROJECT_CODE_PADDING = ENV.int('PROJECT_CODE_PADDING', default=None) \ No newline at end of file diff --git a/coldfront/core/project/admin.py b/coldfront/core/project/admin.py index 61dd884222..a9887508d0 100644 --- a/coldfront/core/project/admin.py +++ b/coldfront/core/project/admin.py @@ -13,7 +13,9 @@ ProjectAttributeType, AttributeType, ProjectAttributeUsage) +from coldfront.core.utils.common import import_from_settings +PROJECT_CODE = import_from_settings('PROJECT_CODE', False) @admin.register(ProjectStatusChoice) class ProjectStatusChoiceAdmin(admin.ModelAdmin): @@ -275,6 +277,14 @@ def get_inline_instances(self, request, obj=None): else: return super().get_inline_instances(request) + def get_list_display(self, request): + if PROJECT_CODE: + list_display = list(self.list_display) + list_display.insert(1, 'project_code') + return tuple(list_display) + + return self.list_display + def save_formset(self, request, form, formset, change): if formset.model in [ProjectAdminComment, ProjectUserMessage]: instances = formset.save(commit=False) diff --git a/coldfront/core/project/management/commands/add_project_codes.py b/coldfront/core/project/management/commands/add_project_codes.py new file mode 100644 index 0000000000..812b0556cc --- /dev/null +++ b/coldfront/core/project/management/commands/add_project_codes.py @@ -0,0 +1,48 @@ +from django.core.management.base import BaseCommand +from coldfront.core.project.models import Project +from coldfront.core.project.utils import generate_project_code +from coldfront.core.utils.common import import_from_settings + +PROJECT_CODE = import_from_settings('PROJECT_CODE', False) +PROJECT_CODE_PADDING = import_from_settings('PROJECT_CODE_PADDING', False) + +class Command(BaseCommand): + help = 'Update existing projects with project codes.' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Outputting project primary keys and titled, followed by their updated project code', + ) + + def update_project_code(self, projects): + user_input = input('Assign all existing projects with project codes? You can use the --dry-run flag to preview changes first. [y/N] ') + + try: + if user_input == 'y' or user_input == 'Y': + for project in projects: + project.project_code = generate_project_code(PROJECT_CODE, project.pk, PROJECT_CODE_PADDING) + project.save(update_fields=["project_code"]) + self.stdout.write(f"Updated {projects.count()} projects with project codes") + else: + self.stdout.write('No changes made') + except AttributeError: + self.stdout.write('Error, no changes made. Please set PROJECT_CODE as a string value inside configuration file.') + + def project_code_dry_run(self, projects): + try: + for project in projects: + new_code = generate_project_code(PROJECT_CODE, project.pk, PROJECT_CODE_PADDING) + self.stdout.write(f"Project {project.pk}, called {project.title}: new project_code would be '{new_code}'") + except AttributeError: + self.stdout.write('Error, no changes made. Please set PROJECT_CODE as a string value inside configuration file.') + + def handle(self, *args, **options): + dry_run = options['dry_run'] + projects_without_codes = Project.objects.filter(project_code="") + + if dry_run: + self.project_code_dry_run(projects_without_codes) + else: + self.update_project_code(projects_without_codes) \ No newline at end of file diff --git a/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py b/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py new file mode 100644 index 0000000000..1fb941e5b0 --- /dev/null +++ b/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.11 on 2025-03-12 13:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('project', '0004_auto_20230406_1133'), + ] + + operations = [ + migrations.AlterModelOptions( + name='historicalproject', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project', 'verbose_name_plural': 'historical projects'}, + ), + migrations.AlterModelOptions( + name='historicalprojectattribute', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project attribute', 'verbose_name_plural': 'historical project attributes'}, + ), + migrations.AlterModelOptions( + name='historicalprojectattributetype', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project attribute type', 'verbose_name_plural': 'historical project attribute types'}, + ), + migrations.AlterModelOptions( + name='historicalprojectattributeusage', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project attribute usage', 'verbose_name_plural': 'historical project attribute usages'}, + ), + migrations.AlterModelOptions( + name='historicalprojectreview', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project review', 'verbose_name_plural': 'historical project reviews'}, + ), + migrations.AlterModelOptions( + name='historicalprojectuser', + options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project user', 'verbose_name_plural': 'historical Project User Status'}, + ), + migrations.AddField( + model_name='historicalproject', + name='project_code', + field=models.CharField(blank=True, max_length=10), + ), + migrations.AddField( + model_name='project', + name='project_code', + field=models.CharField(blank=True, max_length=10), + ), + migrations.AlterField( + model_name='historicalproject', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name='historicalprojectattribute', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name='historicalprojectattributetype', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name='historicalprojectattributeusage', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name='historicalprojectreview', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterField( + model_name='historicalprojectuser', + name='history_date', + field=models.DateTimeField(db_index=True), + ), + migrations.AlterUniqueTogether( + name='project', + unique_together={('title', 'pi')}, + ), + ] diff --git a/coldfront/core/project/models.py b/coldfront/core/project/models.py index 45f9470ac4..d06636293c 100644 --- a/coldfront/core/project/models.py +++ b/coldfront/core/project/models.py @@ -94,6 +94,7 @@ def get_by_natural_key(self, title, pi_username): requires_review = models.BooleanField(default=True) history = HistoricalRecords() objects = ProjectManager() + project_code = models.CharField(max_length=10, blank=True) def clean(self): """ Validates the project and raises errors if the project is invalid. """ diff --git a/coldfront/core/project/templates/project/project_archived_list.html b/coldfront/core/project/templates/project/project_archived_list.html index cac6013a86..b20526dc1e 100644 --- a/coldfront/core/project/templates/project/project_archived_list.html +++ b/coldfront/core/project/templates/project/project_archived_list.html @@ -76,7 +76,11 @@

Archived Projects

{% for project in project_list %} - {{ project.id }} + {% if project.project_code %} + {{ project.project_code }} + {% else %} + {{ project.id }} + {% endif %} {{ project.pi.username }} Title: {{ project.title }}
Description: {{ project.description }} diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index 9f0f39657c..37e26c7258 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -69,6 +69,11 @@

Email PI

Description: {{ project.description }}

+ {% if project.project_code %} +

Project Code: {{ project.project_code }}

+ {% else %} +

ID: {{ project.id }}

+ {% endif %}

Field of Science: {{ project.field_of_science }}

Project Status: {{ project.status }} {% if project.last_project_review and project.last_project_review.status.name == 'Pending'%} diff --git a/coldfront/core/project/templates/project/project_list.html b/coldfront/core/project/templates/project/project_list.html index 90c6450c33..808c680e29 100644 --- a/coldfront/core/project/templates/project/project_list.html +++ b/coldfront/core/project/templates/project/project_list.html @@ -76,7 +76,11 @@

Projects

{% for project in project_list %} - {{ project.id }} + {% if project.project_code %} + {{ project.project_code }} + {% else %} + {{ project.id }} + {% endif %} {{ project.pi.username }} {{ project.title }} {{ project.field_of_science.description }} diff --git a/coldfront/core/project/tests.py b/coldfront/core/project/tests.py index afc85f9af8..ad0113d93b 100644 --- a/coldfront/core/project/tests.py +++ b/coldfront/core/project/tests.py @@ -1,8 +1,10 @@ import logging +from unittest.mock import patch from django.core.exceptions import ValidationError -from django.test import TestCase +from django.test import TestCase, TransactionTestCase +from coldfront.core.project.utils import generate_project_code from coldfront.core.test_helpers.factories import ( UserFactory, ProjectFactory, @@ -205,3 +207,73 @@ def test_attribute_must_match_datatype(self): ) with self.assertRaises(ValidationError): new_attr.clean() + + +class TestProjectCode(TransactionTestCase): + """Tear down database after each run to prevent conflicts across cases """ + reset_sequences = True + + def setUp(self): + self.user = UserFactory(username='capeo') + self.field_of_science = FieldOfScienceFactory(description='Physics') + self.status = ProjectStatusChoiceFactory(name='Active') + + + def create_project_with_code(self, title, project_code, project_code_padding=0): + """Helper method to create a project and a project code with a specific prefix and padding""" + # Project Creation + project = Project.objects.create( + title=title, + pi=self.user, + status=self.status, + field_of_science=self.field_of_science, + ) + + project.project_code = generate_project_code(project_code, project.pk, project_code_padding) + + project.save() + + return project.project_code + + + @patch('coldfront.config.core.PROJECT_CODE', 'BFO') + @patch('coldfront.config.core.PROJECT_CODE_PADDING', 3) + def test_project_code_increment_after_deletion(self): + from coldfront.config.core import PROJECT_CODE + from coldfront.config.core import PROJECT_CODE_PADDING + """Test that the project code increments by one after a project is deleted""" + + # Create the first project + project_with_code_padding1 = self.create_project_with_code('Project 1', PROJECT_CODE, PROJECT_CODE_PADDING) + self.assertEqual(project_with_code_padding1, 'BFO001') + + # Delete the first project + project_obj1 = Project.objects.get(title='Project 1') + project_obj1.delete() + + # Create the second project + project_with_code_padding2 = self.create_project_with_code('Project 2', PROJECT_CODE, PROJECT_CODE_PADDING) + self.assertEqual(project_with_code_padding2, 'BFO002') + + + @patch('coldfront.config.core.PROJECT_CODE','BFO') + def test_no_padding(self): + from coldfront.config.core import PROJECT_CODE + """Test with code and no padding""" + project_with_code = self.create_project_with_code('Project 1', PROJECT_CODE) + self.assertEqual(project_with_code, 'BFO1') # No padding + + @patch('coldfront.config.core.PROJECT_CODE', 'BFO') + @patch('coldfront.config.core.PROJECT_CODE_PADDING', 3) + def test_different_prefix_padding(self): + from coldfront.config.core import PROJECT_CODE + from coldfront.config.core import PROJECT_CODE_PADDING + """Test with code and padding""" + + # Create two projects with codes + project_with_code_padding1 = self.create_project_with_code('Project 1', PROJECT_CODE, PROJECT_CODE_PADDING) + project_with_code_padding2 = self.create_project_with_code('Project 2', PROJECT_CODE, PROJECT_CODE_PADDING) + + # Test the generated project codes + self.assertEqual(project_with_code_padding1, 'BFO001') + self.assertEqual(project_with_code_padding2, 'BFO002') diff --git a/coldfront/core/project/utils.py b/coldfront/core/project/utils.py index be5b611c52..31f08e05eb 100644 --- a/coldfront/core/project/utils.py +++ b/coldfront/core/project/utils.py @@ -18,3 +18,21 @@ def add_project_user_status_choices(apps, schema_editor): for choice in ['Active', 'Pending Remove', 'Denied', 'Removed', ]: ProjectUserStatusChoice.objects.get_or_create(name=choice) + + +def generate_project_code(project_code: str, project_pk: int, padding: int = 0) -> str: + + """ + Generate a formatted project code by combining an uppercased user-defined project code, + project primary key and requested padding value (default = 0). + + :param project_code: The base project code, set through the PROJECT_CODE configuration variable. + :param project_pk: The primary key of the project. + :param padding: The number of digits to pad the primary key with, set through the PROJECT_CODE_PADDING configuration variable. + :return: A formatted project code string. + """ + + return f"{project_code.upper()}{str(project_pk).zfill(padding)}" + + + diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index e536ec4c8b..676d54eaab 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -11,6 +11,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.decorators import user_passes_test, login_required from django.contrib.auth.models import User +from coldfront.core.project.utils import generate_project_code from coldfront.core.utils.common import import_from_settings from django.contrib.messages.views import SuccessMessageMixin from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator @@ -77,6 +78,9 @@ 'EMAIL_DIRECTOR_EMAIL_ADDRESS') EMAIL_SENDER = import_from_settings('EMAIL_SENDER') +PROJECT_CODE = import_from_settings('PROJECT_CODE', False) +PROJECT_CODE_PADDING = import_from_settings('PROJECT_CODE_PADDING', False) + logger = logging.getLogger(__name__) class ProjectDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView): @@ -488,6 +492,14 @@ def form_valid(self, form): # project signals project_new.send(sender=self.__class__, project_obj=project_obj) + if PROJECT_CODE: + ''' + Set the ProjectCode object, if PROJECT_CODE is defined. + If PROJECT_CODE_PADDING is defined, the set amount of padding will be added to PROJECT_CODE. + ''' + project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0) + project_obj.save(update_fields=["project_code"]) + return super().form_valid(form) def get_success_url(self): @@ -515,6 +527,14 @@ def test_func(self): def dispatch(self, request, *args, **kwargs): project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + + if PROJECT_CODE and project_obj.project_code == "": + ''' + Updates project code if no value was set, providing the feature is activated. + ''' + project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0) + project_obj.save(update_fields=["project_code"]) + if project_obj.status.name not in ['Active', 'New', ]: messages.error(request, 'You cannot update an archived project.') return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) diff --git a/docs/pages/config.md b/docs/pages/config.md index 9714cf0c74..b64d420055 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -100,7 +100,8 @@ The following settings are ColdFront specific settings related to the core appli | RESEARCH_OUTPUT_ENABLE | Enable or disable research outputs. Default True | | GRANT_ENABLE | Enable or disable grants. Default True | | PUBLICATION_ENABLE | Enable or disable publications. Default True | - +| PROJECT_CODE | Specifies a custom internal project identifier. Default False, provide string value to enable.| +| PROJECT_CODE_PADDING | Defines a optional padding value to be added before the Primary Key section of PROJECT_CODE. Default False, provide integer value to enable.| ### Database settings From 527d1cd78a7b6336d3929b4a00cb61e86683892a Mon Sep 17 00:00:00 2001 From: John LaGrone Date: Thu, 24 Apr 2025 10:46:10 -0500 Subject: [PATCH 013/110] Squashed commit of the following: commit ede2d714d20d58750bd50ca75732a6ac71cb54a8 Merge: 2fa1ee22 8e555c14 Author: John LaGrone Date: Thu Apr 24 10:44:57 2025 -0500 Merge branch 'main' into add_eula_enforcement Signed-off-by: John LaGrone commit 2fa1ee224829d37019fa0f4e304b09273ac0f152 Author: John LaGrone Date: Thu Apr 24 10:12:22 2025 -0500 add option to include eula in email. fix link Signed-off-by: John LaGrone commit 9a50502d4342164152ff293743c8f06cf69019bd Author: John LaGrone Date: Thu Apr 24 10:02:39 2025 -0500 add cc option to send email template Signed-off-by: John LaGrone commit 123ec0fe7c12547390d55c8abaf76e756d6501f7 Author: John LaGrone Date: Thu Apr 24 09:55:35 2025 -0500 put appropriate blocks in for getting user status needed commit 47433419567fcc2d9682a4eb5b6201225810799c Author: John LaGrone Date: Thu Apr 24 09:52:07 2025 -0500 add user allocation status to portal home commit 61d4f25d0f9984c6086b8eafa79d2e92812b886f Author: John LaGrone Date: Thu Apr 24 09:44:59 2025 -0500 show pending eula in allocation bar on portal home commit 7c62ff0ffe5990f3988df197963c816444ae9d8e Author: John LaGrone Date: Thu Apr 24 09:36:23 2025 -0500 fix default values Signed-off-by: John LaGrone commit c231c3914992b3327a503fe9a0f93d5932f32936 Author: John LaGrone Date: Wed Apr 23 14:24:18 2025 -0500 add docs for new config variables commit 2c62474864c063115ff56208bee7da5249ae4cde Author: John LaGrone Date: Wed Apr 23 11:30:42 2025 -0500 add templates for eula acceptance / decline commit 2481a4dfb904728ad34db91caf36fa1017d08e28 Author: John LaGrone Date: Wed Apr 23 11:22:00 2025 -0500 add logic to send eula accepted / declined messages commit 63a39653fce877574f877162b60bdcd77a1ea303 Author: John LaGrone Date: Wed Apr 23 10:51:21 2025 -0500 update allocation reminders to to allow for email notification settings to be ignored and only send to active project users commit 7565fe22d3120892de30d0053a97d80952003418 Author: John LaGrone Date: Mon Apr 21 11:02:52 2025 -0500 add back checked default Signed-off-by: John LaGrone commit 9d8861ec1593a4ceeae55ed7281a6374abd619c7 Author: John LaGrone Date: Mon Apr 21 10:57:46 2025 -0500 set allocation enable to false by default commit b9d74fb666566d1b006388f863e790add8ac1f1d Author: John LaGrone Date: Mon Apr 21 10:57:02 2025 -0500 set email reminders to false by default commit f017b294b8decae748c980a136a704e44449c17c Author: John LaGrone Date: Mon Apr 21 10:20:54 2025 -0500 remove unused code Signed-off-by: John LaGrone commit 4eb9375602b8ad4f64bb665d73d58817fb36ce62 Author: John LaGrone Date: Mon Apr 21 09:22:15 2025 -0500 conditionally include eula url Signed-off-by: John LaGrone commit 32c43bc79253be4ffa86c2ae0d9c00b5c5a35c43 Author: John LaGrone Date: Mon Apr 21 09:19:57 2025 -0500 add missing , Signed-off-by: John LaGrone commit 8a95483538e31c1df0a8102fcf17c4c65ffef093 Author: John LaGrone Date: Mon Apr 21 09:19:01 2025 -0500 re-add email notification check Signed-off-by: John LaGrone commit c2f27e4318e2856684445c4803344ed2dc6457bb Author: John LaGrone Date: Mon Apr 21 09:15:00 2025 -0500 Only check some eula stuff if enabled Signed-off-by: John LaGrone commit bed8aa78e9474b02de8a97ecde33d60c12731a90 Author: John LaGrone Date: Mon Apr 21 08:58:17 2025 -0500 Change config names for EULAs Signed-off-by: John LaGrone commit db0283e4b416e1a5baf103401e646e549973b21c Author: John LaGrone Date: Wed Apr 2 08:59:54 2025 -0500 activate user when they accept eula commit 4e1ddadb2e795b596615d10995c3b415445a7790 Author: John LaGrone Date: Wed Apr 2 08:58:11 2025 -0500 only send activate if eula is active commit 731d52d48178d1eb1995c387a2d93dc5c6b83830 Merge: a4d5ac5d 7aeded98 Author: John LaGrone Date: Wed Apr 2 08:53:21 2025 -0500 Merge branch 'main' into add_eula_enforcement commit a4d5ac5d276fee323abf37e468a931acc70df96e Author: John LaGrone Date: Fri May 10 13:12:19 2024 -0500 remove site specific .gitignore commit 5545cd3f5f3f6ca5d080d0680bb1f9fe443cc2db Author: John LaGrone Date: Fri May 10 12:50:01 2024 -0500 remove white space commit 8b458f910d9f3e9ef29b799eeeb6345bec329166 Author: John LaGrone Date: Tue Apr 30 10:40:47 2024 -0500 only get user status if user is in allocation commit b248fed0aa828071c3cf71bc67f0a9e17ceb3b0a Author: John Date: Fri Apr 12 10:57:16 2024 -0500 remove import of unused form commit 79806629a9a05765b3659e187b5f246a7675552f Author: John Date: Fri Apr 12 10:42:28 2024 -0500 fix some links and remove unused form commit 0a43c0dbe905104d57837c0f89def9acc6efd138 Author: John Date: Fri Apr 12 10:32:53 2024 -0500 finish moving EULA acceptance /review to it's own page commit 35468083712cc2ba36452967c8bb937913324084 Author: John Date: Fri Apr 12 08:49:54 2024 -0500 start breaking out some eula logic to it's own page commit 8556e5a321a0246ad12513f1ea40954275fcc525 Author: John Date: Fri Apr 12 07:27:17 2024 -0500 remove debugging / handle reject eula better commit 1d55c0b46fd4586a7b32526e3ee90b0581660106 Author: John Date: Thu Apr 11 17:53:09 2024 -0500 remove debug prints commit 0b363b111742584536ef0a9dcef3c22e900e86fc Author: John Date: Thu Apr 11 17:50:39 2024 -0500 add more helpful messaging and links for pending allocations commit 3d44d765aee7c99726d2b7580a939590be34bef7 Author: John Date: Thu Apr 11 16:24:46 2024 -0500 remove another instance of get_eula def commit bf9ee30e52f907758d5e8d78c8eb4493809fcefd Author: John Date: Thu Apr 11 15:53:22 2024 -0500 default new users on allocations to pending eula not active commit a6334f0e2ea0e202cd1509b3235cccba0f93f4a4 Author: John Date: Thu Apr 11 14:59:01 2024 -0500 remove unused pending allocations commit dca5baabc25f0b5d996325cf90d7500fb8a6edb7 Author: John Date: Thu Apr 11 14:45:33 2024 -0500 refactor to not redfine the same function many times commit ca141ebab4af6d2206fb74e61a03f34d3a149b03 Author: John Date: Thu Apr 11 14:07:45 2024 -0500 don't default to users being active. They may not have agreed to the EULA commit da0d7f5b0bfc08eae99db0a556c1cd79c7a94375 Author: John Date: Thu Apr 11 13:40:22 2024 -0500 remove more PI prompts to accept user EULA commit 6a6ed57c98b73691b5254e3c35885d312475deac Author: John Date: Thu Apr 11 13:36:27 2024 -0500 don't ask a PI to accept EULA for user commit c7caf60986f72df000fd4d2a5d76ffe837f01f46 Author: John Date: Thu Apr 11 13:21:35 2024 -0500 fix user in allocation check commit 7be2046b038ebe68c8f4e2722e0dbf6fcf91b8d4 Author: John Date: Thu Apr 11 10:13:43 2024 -0500 handled pending eula differently than other cases commit d35f8f36cbff89b2f3f0936dc7fb472b3f87123d Author: John Date: Wed Apr 10 13:06:20 2024 -0500 only show eula approval to current user commit a79ba7f3c4888ba08871d26c0ad28347da5b8e86 Author: John Date: Wed Apr 10 10:01:42 2024 -0500 change denied -> deniedeula for clarity commit 679e261c2bc7a45f089a69e3fe07ba9591060c2c Author: John Date: Wed Apr 10 09:58:13 2024 -0500 changing pending -> pendingeula for clarity commit db0f320e5fca9c2e3c9fc423edf6b4d07b6d1101 Author: John Date: Wed Apr 10 09:10:55 2024 -0500 add EULA_AGREEMENT to resource view commit 76d1d98dc033ce92e081d3b3c3b55d26f639f539 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 14:53:14 2023 -0400 fixed html side of bug commit 6627fe889e05dc39893e1a3e78cc4b7cf72c5223 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 14:52:24 2023 -0400 fixed bug in eula display commit 709e8c114bdc24a111dbfabcd578a7b89edfbf04 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Tue Jun 27 11:55:26 2023 -0400 code cleanup commit 67f62a66c2f481b04f7fdf47dd06d160200eba4b Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Mon Jun 26 11:48:59 2023 -0400 fixed checkbox functionality commit 131b36148268cb5b53ede9fb36f92455c5122554 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Sun Jun 18 14:21:20 2023 -0400 fixed display for allocations without eula commit a78cb0f6aa1d672087188591923d8042d065f150 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Sun Jun 18 14:11:58 2023 -0400 fixed functionality when adding 0 users to allocation commit ae2cb8f6377b109232a7599128dc0f449f009376 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Sun Jun 18 14:01:09 2023 -0400 fixed formatting and removed comments commit 3f4706659a126e7443c48d3d4e088efabcd05a0e Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Sun Jun 18 13:58:51 2023 -0400 done commit d23c4be98b7bbe64639b146885a8eccb04da00d2 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Sun Jun 18 12:47:03 2023 -0400 commit with comments commit 6ed7dd40c8f20f79f4e221fe705c56d56c502756 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jun 14 18:01:44 2023 -0400 added ability to prompt users when adding to allocation and have eula show up on allocation detail page commit 8aec0976bba74957393c9f3a38c60016f5bb73a4 Author: Ria Gupta <74742605+rg663@users.noreply.github.com> Date: Thu Jul 27 14:13:20 2023 -0400 Update add_allocation_defaults.py commit 40beb9990cd01c7dc77300454ef1abe7957c3c26 Author: Ria Gupta <74742605+rg663@users.noreply.github.com> Date: Thu Jul 27 14:12:46 2023 -0400 Update add_allocation_defaults.py commit fcc451c1a6d3f737dc7b9c9a94525518d51a13e0 Author: Ria Gupta <74742605+rg663@users.noreply.github.com> Date: Tue Jul 25 15:41:53 2023 -0400 Update .gitignore commit d31404872631ccb302778691e94cd8fa3ef2df95 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Thu Jul 13 15:18:41 2023 -0400 refactored eula reminder emails commit 5a32cf4b2eafdecde60b9be92359302e7b85e85b Author: Ria Gupta <74742605+rg663@users.noreply.github.com> Date: Fri Jul 7 20:28:56 2023 -0400 Update allocation_agree_to_eula.txt commit 2c1b66ed2c03340c1ac58b7d25ccb70da7e8b3c4 Author: Ria Gupta <74742605+rg663@users.noreply.github.com> Date: Fri Jul 7 20:15:03 2023 -0400 Update add_scheduled_tasks.py commit 6d402638185bbc2178999cafdb8dbae9aedd6075 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Fri Jul 7 20:03:15 2023 -0400 small fixes commit 850a2235173eb35bee6dab1eee544791c44e5bd2 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Fri Jul 7 14:41:01 2023 -0400 config fix commit e023f7e2658e661ef1e535f348bc8504876e6003 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Fri Jul 7 14:32:51 2023 -0400 email updates commit 99825f4c1a517d698b6f8fdc87c9f8f6cccc2797 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Fri Jul 7 14:21:46 2023 -0400 emails work commit a8c16679f47f5de59221156835a164b34b138aa4 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Thu Jul 6 14:03:07 2023 -0400 bug fix commit 9a3dd2d6e68b0585d7b3161d69d50cb467bcffb5 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Thu Jul 6 13:58:27 2023 -0400 fixed functionality for when config is false commit 4a8a14f2c7b31bf3b335f8f5aa9f20a246219523 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 20:47:53 2023 -0400 email functionality almost done commit edd4d13bc2221fdc8d1b1bf67038860eda595664 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 19:03:51 2023 -0400 functionality near complete commit 27017324dbc24de49ded42e9ab628b503873c6af Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 18:56:25 2023 -0400 email functionality close to working commit 913594bae00ba7079128db50787230ed71fa2983 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 17:20:46 2023 -0400 code cleanup commit 2e9c468c12be7da72802de171ae97d6a1ca92b29 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 16:25:23 2023 -0400 fixed email commit 019d7a500b712e9c09a588e811ac5302f0b8ec95 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 16:05:27 2023 -0400 small change commit e21f12a2a0a17edf4290415329c7252afdc0126f Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 15:23:09 2023 -0400 users can now accept/decline eulas commit ba9ccf02dc301bd9e1cb3d9c6c8504e80ba58d23 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Wed Jul 5 14:51:32 2023 -0400 working on agree button commit a7f6c7cf7feaf0f6f12662d06ca2c3ec13487021 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Fri Jun 30 22:04:32 2023 -0400 started selection functionality commit 1ca566d508df038ac7e5af547f98e7172a9cb515 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Thu Jun 29 15:34:18 2023 -0400 enabling checkbox functionality commit 6f26dcf523709501e6a9b740a9e464531de7eb6d Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Thu Jun 29 13:54:19 2023 -0400 working through user statuses commit 0c361d2d07bfac96668a4312a2be3332a6958be6 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Tue Jun 27 17:33:06 2023 -0400 add config variable check to each part commit 47f2f3ff75b828782b77f3190c0e2f7f837b0c32 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Tue Jun 27 17:18:17 2023 -0400 line deletion commit d9f6bf31e19d86127242d47725c74dccd8289e81 Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Tue Jun 27 17:17:24 2023 -0400 added email functionality commit 90d9b5e83cb67305c285dcecd34e0a98cfc3789d Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Tue Jun 27 14:59:23 2023 -0400 made it so user status is pending when allocation has eula commit cb60d7c4add9fee99b0017d40049be364bfe7a7e Author: riathetechie@gmail.com <74742605+rg663@users.noreply.github.com> Date: Tue Jun 27 12:34:14 2023 -0400 started eula user functionality Signed-off-by: John LaGrone --- coldfront/config/core.py | 7 +- coldfront/config/email.py | 5 + .../commands/add_allocation_defaults.py | 2 +- coldfront/core/allocation/models.py | 12 +- coldfront/core/allocation/tasks.py | 26 ++- .../allocation/allocation_add_users.html | 4 +- .../allocation/allocation_detail.html | 49 ++++- .../allocation/allocation_review_eula.html | 70 +++++++ coldfront/core/allocation/urls.py | 6 + coldfront/core/allocation/views.py | 174 ++++++++++++++++-- .../templates/portal/authorized_home.html | 6 +- coldfront/core/portal/views.py | 15 +- coldfront/core/project/forms.py | 2 +- .../project/add_user_search_results.html | 3 +- .../templates/project/project_detail.html | 11 +- coldfront/core/project/views.py | 29 ++- coldfront/core/resource/views.py | 23 +++ coldfront/core/utils/mail.py | 38 +++- .../commands/add_scheduled_tasks.py | 9 +- .../core/utils/templatetags/common_tags.py | 7 + .../email/allocation_agree_to_eula.txt | 6 + .../email/allocation_eula_accepted.txt | 12 ++ .../email/allocation_eula_declined.txt | 6 + .../email/allocation_eula_reminder.txt | 6 + docs/pages/config.md | 6 + 25 files changed, 495 insertions(+), 39 deletions(-) create mode 100644 coldfront/core/allocation/templates/allocation/allocation_review_eula.html create mode 100644 coldfront/templates/email/allocation_agree_to_eula.txt create mode 100644 coldfront/templates/email/allocation_eula_accepted.txt create mode 100644 coldfront/templates/email/allocation_eula_declined.txt create mode 100644 coldfront/templates/email/allocation_eula_reminder.txt diff --git a/coldfront/config/core.py b/coldfront/config/core.py index 27c5e7468c..3b7c5a3746 100644 --- a/coldfront/config/core.py +++ b/coldfront/config/core.py @@ -25,6 +25,11 @@ #------------------------------------------------------------------------------ PROJECT_ENABLE_PROJECT_REVIEW = ENV.bool('PROJECT_ENABLE_PROJECT_REVIEW', default=True) +#------------------------------------------------------------------------------ +# Enable EULA force agreement +#------------------------------------------------------------------------------ +ALLOCATION_EULA_ENABLE = ENV.bool('ALLOCATION_EULA_ENABLE', default=False) + #------------------------------------------------------------------------------ # Allocation related #------------------------------------------------------------------------------ @@ -36,7 +41,6 @@ # This is in days ALLOCATION_DEFAULT_ALLOCATION_LENGTH = ENV.int('ALLOCATION_DEFAULT_ALLOCATION_LENGTH', default=365) - #------------------------------------------------------------------------------ # Allow user to select account name for allocation #------------------------------------------------------------------------------ @@ -46,6 +50,7 @@ SETTINGS_EXPORT += [ 'ALLOCATION_ACCOUNT_ENABLED', 'CENTER_HELP_URL', + 'ALLOCATION_EULA_ENABLE', 'RESEARCH_OUTPUT_ENABLE', 'GRANT_ENABLE', 'PUBLICATION_ENABLE', diff --git a/coldfront/config/email.py b/coldfront/config/email.py index aee4d5f0d9..6e79e94d44 100644 --- a/coldfront/config/email.py +++ b/coldfront/config/email.py @@ -22,3 +22,8 @@ EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS = ENV.list('EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS', cast=int, default=[7, 14, 30]) EMAIL_SIGNATURE = ENV.str('EMAIL_SIGNATURE', default='', multiline=True) EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = ENV.bool('EMAIL_ADMINS_ON_ALLOCATION_EXPIRE', default=False) +EMAIL_ALLOCATION_EULA_REMINDERS = ENV.bool('EMAIL_ALLOCATION_EULA_REMINDERS', default=False) +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = ENV.bool('EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT', default=False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS = ENV.bool('EMAIL_ALLOCATION_EULA_CONFIRMATIONS', default=False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS = ENV.bool('EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS', default=False) +EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA = ENV.bool('EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA', default=False) \ No newline at end of file diff --git a/coldfront/core/allocation/management/commands/add_allocation_defaults.py b/coldfront/core/allocation/management/commands/add_allocation_defaults.py index ca5c4fc370..d2fd6237cb 100644 --- a/coldfront/core/allocation/management/commands/add_allocation_defaults.py +++ b/coldfront/core/allocation/management/commands/add_allocation_defaults.py @@ -25,7 +25,7 @@ def handle(self, *args, **options): for choice in ('Pending', 'Approved', 'Denied',): AllocationChangeStatusChoice.objects.get_or_create(name=choice) - for choice in ('Active', 'Error', 'Removed', ): + for choice in ('Active', 'Error', 'Removed', 'PendingEULA', 'DeclinedEULA'): AllocationUserStatusChoice.objects.get_or_create(name=choice) for name, attribute_type, has_usage, is_private in ( diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index 50d8fa591d..c0122cfdce 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -29,7 +29,7 @@ ['-is_allocatable', 'name']) class AllocationPermission(Enum): - """ A project permission stores the user and manager fields of a project. """ + """ An allocation permission stores the user and manager fields of a project. """ USER = 'USER' MANAGER = 'MANAGER' @@ -321,7 +321,7 @@ def user_permissions(self, user): if ProjectPermission.PI in project_perms or ProjectPermission.MANAGER in project_perms: return [AllocationPermission.USER, AllocationPermission.MANAGER] - if self.allocationuser_set.filter(user=user, status__name__in=['Active', 'New', ]).exists(): + if self.allocationuser_set.filter(user=user, status__name__in=['Active', 'New', 'PendingEULA']).exists(): return [AllocationPermission.USER] return [] @@ -341,6 +341,14 @@ def has_perm(self, user, perm): def __str__(self): return "%s (%s)" % (self.get_parent_resource.name, self.project.pi) + + def get_eula(self): + if self.get_resources_as_list: + for res in self.get_resources_as_list: + if res.get_attribute(name='eula'): + return res.get_attribute(name='eula') + else: + return None class AllocationAdminNote(TimeStampedModel): """ An allocation admin note is a note that an admin makes on an allocation. diff --git a/coldfront/core/allocation/tasks.py b/coldfront/core/allocation/tasks.py index e1a37fc057..e6371e442b 100644 --- a/coldfront/core/allocation/tasks.py +++ b/coldfront/core/allocation/tasks.py @@ -3,7 +3,8 @@ import logging from coldfront.core.allocation.models import (Allocation, - AllocationStatusChoice) + AllocationStatusChoice, AllocationUserStatusChoice) +from coldfront.core.allocation.utils import get_user_resources from coldfront.core.user.models import User from coldfront.core.utils.common import import_from_settings from coldfront.core.utils.mail import send_email_template @@ -26,6 +27,8 @@ EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = import_from_settings('EMAIL_ADMINS_ON_ALLOCATION_EXPIRE') EMAIL_ADMIN_LIST = import_from_settings('EMAIL_ADMIN_LIST') +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = import_from_settings('EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT') + def update_statuses(): expired_status_choice = AllocationStatusChoice.objects.get( @@ -39,6 +42,27 @@ def update_statuses(): logger.info('Allocations set to expired: {}'.format( allocations_to_expire.count())) +def send_eula_reminders(): + for allocation in Allocation.objects.all(): + if allocation.get_eula(): + email_receiver_list = [] + for allocation_user in allocation.allocationuser_set.all(): + projectuser = allocation.project.projectuser_set.get(user=allocation_user.user) + if allocation_user.status == AllocationUserStatusChoice.objects.get(name='PendingEULA') and projectuser.status.name == 'Active': + should_send = (projectuser.enable_notifications) or (EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT) + if should_send and allocation_user.user.email not in email_receiver_list: + email_receiver_list.append(allocation_user.user.email) + + template_context = { + 'center_name': CENTER_NAME, + 'resource': allocation.get_parent_resource, + 'url': f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/review-eula', + 'signature': EMAIL_SIGNATURE + } + + if email_receiver_list: + send_email_template(f'Reminder: Agree to EULA for {allocation}', 'email/allocation_eula_reminder.txt', template_context, EMAIL_SENDER, email_receiver_list) + logger.debug(f'Allocation(s) EULA reminder sent to users {email_receiver_list}.') def send_expiry_emails(): #Allocations expiring soon diff --git a/coldfront/core/allocation/templates/allocation/allocation_add_users.html b/coldfront/core/allocation/templates/allocation/allocation_add_users.html index 81d56c857d..7fd799ddbf 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_add_users.html +++ b/coldfront/core/allocation/templates/allocation/allocation_add_users.html @@ -47,7 +47,7 @@

Add users to allocation for project: {{allocation.project.title}}

{{ formset.management_form }}
- Back to Allocation @@ -75,4 +75,4 @@

Add users to allocation for project: {{allocation.project.title}}

} }); -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index b459f04882..369cf81b63 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -60,9 +60,9 @@

Allocation Information

{% for resource in allocation.get_resources_as_list %} {{ resource }}
{% endfor %} - {% else %} - None - {% endif %} + {% else %} + None + {% endif %} {% if request.user.is_superuser %} @@ -173,6 +173,41 @@

Allocation Information

+{% if eulas %} +
+
+

EULA Agreements

+
+
+
+ + + + + + + + + + + + + +
ResourceEULA
+ {{res_obj}}
+
+ {{eulas}} +
+ {% if user_in_allocation %} + + {% endif %} +
+
+
+ {% endif %} + {% if attributes or attributes_with_usage or request.user.is_superuser %}
@@ -234,6 +269,7 @@

{{attribute}}

{% endif %} +{% if not user_is_pending %}
@@ -322,7 +358,9 @@

Users in Al {{ user.user.email }} {% if user.status.name == 'Active' %} {{ user.status.name }} - {% elif user.status.name == 'Denied' or user.status.name == 'Error' %} + {% elif user.status.name == 'PendingEULA' %} + {{ user.status.name }} + {% elif user.status.name == 'Denied' or user.status.name == 'Error' or user.status.name == 'DeclinedEULA' %} {{ user.status.name }} {% else %} {{ user.status.name }} @@ -380,6 +418,7 @@

Notificatio {% endif %}

+{% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/coldfront/core/allocation/templates/allocation/allocation_review_eula.html b/coldfront/core/allocation/templates/allocation/allocation_review_eula.html new file mode 100644 index 0000000000..70774489b4 --- /dev/null +++ b/coldfront/core/allocation/templates/allocation/allocation_review_eula.html @@ -0,0 +1,70 @@ +{% extends "common/base.html" %} +{% load crispy_forms_tags %} +{% load static %} + + +{% block title %} +Review Allocation EULA +{% endblock %} + + +{% block content %} +{% if allocation.project.status.name == 'Archived' %} + +{% endif %} + + +{% if form.non_field_errors %} + +{% endif %} + +{% if eulas %} +
+
+

EULA Agreements

+
+
+
+ + + + + + + + + + + + + +
ResourceEULA
+ {{res_obj}}
+
+ {{eulas}} +
+ {% if allocation_user_status %} + {% if allocation_user_status == 'PendingEULA' %} +
+ {% csrf_token %} +

In order to access this allocation you must agree to the EULA

+
+ + +
+
+ {% elif allocation_user_status == 'DeclinedEULA' %} +

You declined the EULA for this allocation on {{last_updated}}

+ {% elif allocation_user_status == 'Active' %} +

You accepted the EULA for this allocation on {{last_updated}}

+ {% endif %} + {% endif %} +
+
+
+ {% endif %} +{% endblock %} diff --git a/coldfront/core/allocation/urls.py b/coldfront/core/allocation/urls.py index 9d0f747be6..da27b22143 100644 --- a/coldfront/core/allocation/urls.py +++ b/coldfront/core/allocation/urls.py @@ -1,6 +1,8 @@ from django.urls import path import coldfront.core.allocation.views as allocation_views +from coldfront.config.core import ALLOCATION_EULA_ENABLE + urlpatterns = [ path('', allocation_views.AllocationListView.as_view(), name='allocation-list'), @@ -45,3 +47,7 @@ path('allocation-account-list/', allocation_views.AllocationAccountListView.as_view(), name='allocation-account-list'), ] + +if ALLOCATION_EULA_ENABLE: + urlpatterns.append(path('/review-eula', allocation_views.AllocationEULAView.as_view(), + name='allocation-review-eula')) diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 2fc844d4c8..fc75614bc2 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -20,6 +20,7 @@ from django.views import View from django.views.generic import ListView, TemplateView from django.views.generic.edit import CreateView, FormView, UpdateView +from coldfront.config.core import ALLOCATION_EULA_ENABLE from coldfront.core.allocation.forms import (AllocationAccountForm, AllocationAddUserForm, @@ -57,11 +58,16 @@ allocation_change_approved,) from coldfront.core.allocation.utils import (generate_guauge_data_from_usage, get_user_resources) -from coldfront.core.project.models import (Project, ProjectUser, ProjectPermission, +from coldfront.core.project.models import (Project, ProjectUser, ProjectPermission, ProjectUserRoleChoice, ProjectUserStatusChoice) from coldfront.core.resource.models import Resource from coldfront.core.utils.common import get_domain_url, import_from_settings -from coldfront.core.utils.mail import send_allocation_admin_email, send_allocation_customer_email +from coldfront.core.utils.mail import (build_link, + send_allocation_admin_email, + send_allocation_customer_email, + send_allocation_eula_customer_email, + send_email, + send_email_template) ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings( 'ALLOCATION_ENABLE_ALLOCATION_RENEWAL', True) @@ -82,6 +88,14 @@ ALLOCATION_ACCOUNT_MAPPING = import_from_settings( 'ALLOCATION_ACCOUNT_MAPPING', {}) +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = import_from_settings( + 'EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT', False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS = import_from_settings( + 'EMAIL_ALLOCATION_EULA_CONFIRMATIONS',False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS = import_from_settings( + 'EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS',False) +EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA = import_from_settings( + 'EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA',False) logger = logging.getLogger(__name__) @@ -105,7 +119,20 @@ def get_context_data(self, **kwargs): pk = self.kwargs.get('pk') allocation_obj = get_object_or_404(Allocation, pk=pk) allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).order_by('user__username') + status__name__in=['Removed',]).order_by('user__username') + + if ALLOCATION_EULA_ENABLE: + user_in_allocation = allocation_users.filter(user=self.request.user).exists() + context['user_in_allocation'] = user_in_allocation + + if user_in_allocation: + allocation_user_status = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user).status + if allocation_obj.status.name == 'Active' and allocation_user_status.name == 'PendingEula': + messages.info(self.request, "This allocation is active, but you must agree to the EULA to use it!") + + context['eulas'] = allocation_obj.get_eula() + context['res'] = allocation_obj.get_parent_resource.pk + context['res_obj'] = allocation_obj.get_parent_resource # set visible usage attributes alloc_attr_set = allocation_obj.get_attribute_set(self.request.user) @@ -173,11 +200,14 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): pk = self.kwargs.get('pk') allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_users = allocation_obj.allocationuser_set.exclude( + status__name__in=['Removed']).order_by('user__username') + if not self.request.user.is_superuser: messages.success( - request, 'You do not have permission to update the allocation') + request, 'You do not have permission to update the allocation') return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) - + initial_data = { 'status': allocation_obj.status, 'end_date': allocation_obj.end_date, @@ -225,7 +255,7 @@ def post(self, request, *args, **kwargs): allocation_activate.send( sender=self.__class__, allocation_pk=allocation_obj.pk) - allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=['Removed', 'Error']) + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=['Removed', 'Error', 'DeclinedEULA', 'PendingEULA']) for allocation_user in allocation_users: allocation_activate_user.send( sender=self.__class__, allocation_user_pk=allocation_user.pk) @@ -271,6 +301,86 @@ def post(self, request, *args, **kwargs): return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) +class AllocationEULAView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): + model = Allocation + template_name = 'allocation/allocation_review_eula.html' + context_object_name = 'allocation-eula' + + def test_func(self): + """ UserPassesTestMixin Tests""" + pk = self.kwargs.get('pk') + allocation_obj = get_object_or_404(Allocation, pk=pk) + + if self.request.user.has_perm('allocation.can_view_all_allocations'): + return True + + return allocation_obj.has_perm(self.request.user, AllocationPermission.USER) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + pk = self.kwargs.get('pk') + allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_users = allocation_obj.allocationuser_set.exclude( + status__name__in=['Removed',]).order_by('user__username') + user_in_allocation = allocation_users.filter(user=self.request.user).exists() + + context['allocation'] = allocation_obj.pk + context['eulas'] = allocation_obj.get_eula() + context['res'] = allocation_obj.get_parent_resource.pk + context['res_obj'] = allocation_obj.get_parent_resource + + if user_in_allocation and ALLOCATION_EULA_ENABLE: + allocation_user_status = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user).status + context["allocation_user_status"] = allocation_user_status.name + context['last_updated'] = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user).modified + + return context + + def get(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + allocation_obj = get_object_or_404(Allocation, pk=pk) + context = self.get_context_data() + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + pk = self.kwargs.get('pk') + allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_users = allocation_obj.allocationuser_set.exclude( + status__name__in=['Removed','DeclinedEULA']).order_by('user__username') + user_in_allocation = allocation_users.filter(user=self.request.user).exists() + if user_in_allocation: + allocation_user_obj = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user) + action = request.POST.get('action') + if action not in ['accepted_eula', 'declined_eula']: + return HttpResponseBadRequest("Invalid request") + if 'accepted_eula' in action: + allocation_user_obj.status = AllocationUserStatusChoice.objects.get(name='Active') + messages.success(self.request, "EULA Accepted!") + if EMAIL_ALLOCATION_EULA_CONFIRMATIONS: + project_user = allocation_user_obj.allocation.project.projectuser_set.get(user=allocation_user_obj.user) + if EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT or project_user.enable_notifications: + send_allocation_eula_customer_email(allocation_user_obj, + "EULA accepted", + 'email/allocation_eula_accepted.txt', + cc_managers=EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS, + include_eula=EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA) + if (allocation_obj.status == AllocationStatusChoice.objects.get(name='Active')): + allocation_activate_user.send(sender=self.__class__, + allocation_user_pk=allocation_user_obj.pk) + elif action == 'declined_eula': + allocation_user_obj.status = AllocationUserStatusChoice.objects.get(name='DeclinedEULA') + messages.warning(self.request, "You did not agree to the EULA and were removed from the allocation. To access this allocation, your PI will have to re-add you.") + if EMAIL_ALLOCATION_EULA_CONFIRMATIONS: + project_user = allocation_user_obj.allocation.project.projectuser_set.get(user=allocation_user_obj.user) + if EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT or project_user.enable_notifications: + send_allocation_eula_customer_email(allocation_user_obj, + "EULA declined", + 'email/allocation_eula_declined.txt', + cc_managers=EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS) + allocation_user_obj.save() + + return HttpResponseRedirect(reverse('allocation-review-eula', kwargs={'pk': pk})) + class AllocationListView(LoginRequiredMixin, ListView): @@ -299,13 +409,13 @@ def get_queryset(self): 'project', 'project__pi', 'status',).all().order_by(order_by) else: allocations = Allocation.objects.prefetch_related('project', 'project__pi', 'status',).filter( - Q(project__status__name__in=['New', 'Active', ]) & - Q(project__projectuser__status__name='Active') & + Q(project__status__name__in=['New', 'Active']) & + Q(project__projectuser__status__name__in=['Active']) & Q(project__projectuser__user=self.request.user) & (Q(project__projectuser__role__name='Manager') | Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name='Active')) + Q(allocationuser__status__name__in=['Active', 'PendingEULA'] )) ).distinct().order_by(order_by) # Project Title @@ -318,7 +428,7 @@ def get_queryset(self): allocations = allocations.filter( Q(project__pi__username__icontains=data.get('username')) | Q(allocationuser__user__username__icontains=data.get('username')) & - Q(allocationuser__status__name='Active') + Q(allocationuser__status__name__in=['PendingEULA', 'Active']) ) # Resource Type @@ -359,7 +469,7 @@ def get_queryset(self): else: allocations = Allocation.objects.prefetch_related('project', 'project__pi', 'status',).filter( Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name='Active') + Q(allocationuser__status__name__in=['PendingEULA', 'Active']) ).order_by(order_by) return allocations.distinct() @@ -557,8 +667,15 @@ def form_valid(self, form): allocation_user_active_status = AllocationUserStatusChoice.objects.get( name='Active') + if ALLOCATION_EULA_ENABLE: + allocation_user_pending_status = AllocationUserStatusChoice.objects.get( + name='PendingEULA') for user in users: - AllocationUser.objects.create(allocation=allocation_obj, user=user, + if ALLOCATION_EULA_ENABLE and not (user == self.request.user): + AllocationUser.objects.create(allocation=allocation_obj, user=user, + status=allocation_user_pending_status) + else: + AllocationUser.objects.create(allocation=allocation_obj, user=user, status=allocation_user_active_status) send_allocation_admin_email( @@ -579,6 +696,8 @@ def get_success_url(self): class AllocationAddUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): template_name = 'allocation/allocation_add_users.html' + model = Allocation + context_object_name = 'allocation' def test_func(self): """ UserPassesTestMixin Tests""" @@ -639,6 +758,21 @@ def get(self, request, *args, **kwargs): context['formset'] = formset context['allocation'] = allocation_obj + + user_resources = get_user_resources(self.request.user) + resources_with_eula = {} + for res in user_resources: + if res in allocation_obj.get_resources_as_list: + if res.get_attribute_list(name='eula'): + for attr_value in res.get_attribute_list(name='eula'): + resources_with_eula[res] = attr_value + + context['resources_with_eula'] = resources_with_eula + string_accumulator = "" + for res, value in resources_with_eula.items(): + string_accumulator += f"{res}: {value}\n" + context['compiled_eula'] = str(string_accumulator) + return render(request, self.template_name, context) def post(self, request, *args, **kwargs): @@ -658,6 +792,9 @@ def post(self, request, *args, **kwargs): allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get( name='Active') + if ALLOCATION_EULA_ENABLE: + allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get( + name='PendingEULA') for form in formset: user_form_data = form.cleaned_data @@ -671,10 +808,19 @@ def post(self, request, *args, **kwargs): if allocation_obj.allocationuser_set.filter(user=user_obj).exists(): allocation_user_obj = allocation_obj.allocationuser_set.get( user=user_obj) - allocation_user_obj.status = allocation_user_active_status_choice + if ALLOCATION_EULA_ENABLE and not user_obj.userprofile.is_pi and allocation_obj.get_eula(): + allocation_user_obj.status = allocation_user_pending_status_choice + send_email_template(f'Agree to EULA for {allocation_obj.get_parent_resource.__str__()}', 'email/allocation_agree_to_eula.txt', {"resource": allocation_obj.get_parent_resource, "url": build_link(reverse('allocation-review-eula', kwargs={'pk': allocation_obj.pk}), domain_url=get_domain_url(self.request))}, self.request.user.email, [user_obj]) + else: + allocation_user_obj.status = allocation_user_active_status_choice allocation_user_obj.save() else: - allocation_user_obj = AllocationUser.objects.create( + if ALLOCATION_EULA_ENABLE and not user_obj.userprofile.is_pi and allocation_obj.get_eula(): + allocation_user_obj = AllocationUser.objects.create( + allocation=allocation_obj, user=user_obj, status=allocation_user_pending_status_choice) + send_email_template(f'Agree to EULA for {allocation_obj.get_parent_resource.__str__()}', 'email/allocation_agree_to_eula.txt', {"resource": allocation_obj.get_parent_resource, "url": build_link(reverse('allocation-review-eula', kwargs={'pk': allocation_obj.pk}), domain_url=get_domain_url(self.request))}, self.request.user.email, [user_obj]) + else: + allocation_user_obj = AllocationUser.objects.create( allocation=allocation_obj, user=user_obj, status=allocation_user_active_status_choice) allocation_activate_user.send(sender=self.__class__, diff --git a/coldfront/core/portal/templates/portal/authorized_home.html b/coldfront/core/portal/templates/portal/authorized_home.html index 5fef17e0e1..72bb046878 100644 --- a/coldfront/core/portal/templates/portal/authorized_home.html +++ b/coldfront/core/portal/templates/portal/authorized_home.html @@ -69,8 +69,12 @@

Allocations »

{{allocation.status}} {% elif allocation.status.name == "Active" %} - Review and Accept EULA to Activate + {% else %} + {{allocation.status}} + {% endif %} {% else %} {{allocation.status}} diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py index 6c861e0b07..ed94fc9eaa 100644 --- a/coldfront/core/portal/views.py +++ b/coldfront/core/portal/views.py @@ -16,6 +16,9 @@ from coldfront.core.project.models import Project from coldfront.core.publication.models import Publication from coldfront.core.research_output.models import ResearchOutput +from coldfront.core.utils.common import import_from_settings + +ALLOCATION_EULA_ENABLE = import_from_settings('ALLOCATION_EULA_ENABLE', False) def home(request): @@ -36,10 +39,20 @@ def home(request): Q(project__projectuser__user=request.user) & Q(project__projectuser__status__name__in=['Active', ]) & Q(allocationuser__user=request.user) & - Q(allocationuser__status__name__in=['Active', ]) + Q(allocationuser__status__name__in=['Active', 'PendingEULA']) ).distinct().order_by('-created')[:5] + + + if ALLOCATION_EULA_ENABLE: + user_status = [] + for allocation in allocation_list: + if allocation.allocationuser_set.filter(user=request.user).exists(): + user_status.append(allocation.allocationuser_set.get(user=request.user).status.name) + context['user_status'] = user_status + context['project_list'] = project_list context['allocation_list'] = allocation_list + try: context['ondemand_url'] = settings.ONDEMAND_URL except AttributeError: diff --git a/coldfront/core/project/forms.py b/coldfront/core/project/forms.py index 611726e69f..04df9eaea5 100644 --- a/coldfront/core/project/forms.py +++ b/coldfront/core/project/forms.py @@ -45,6 +45,7 @@ class ProjectAddUserForm(forms.Form): class ProjectAddUsersToAllocationForm(forms.Form): + allocation = forms.MultipleChoiceField( widget=forms.CheckboxSelectMultiple(attrs={'checked': 'checked'}), required=False) @@ -65,7 +66,6 @@ def __init__(self, request_user, project_pk, *args, **kwargs): else: self.fields['allocation'].widget = forms.HiddenInput() - class ProjectRemoveUserForm(forms.Form): username = forms.CharField(max_length=150, disabled=True) first_name = forms.CharField(max_length=150, required=False, disabled=True) diff --git a/coldfront/core/project/templates/project/add_user_search_results.html b/coldfront/core/project/templates/project/add_user_search_results.html index 075dde373e..934bff3894 100644 --- a/coldfront/core/project/templates/project/add_user_search_results.html +++ b/coldfront/core/project/templates/project/add_user_search_results.html @@ -95,6 +95,7 @@ var id = $(this).attr('id'); if ( id != "id_allocationform-allocation_0") { $("#id_allocationform-allocation_0").prop('checked', false); - } }); + +{% comment %} if eula box is in focus, then required {% endcomment %} \ No newline at end of file diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index 37e26c7258..f4fa1f6b76 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -2,6 +2,7 @@ {% load crispy_forms_tags %} {% load humanize %} {% load static %} +{% load common_tags %} {% block title %} @@ -194,13 +195,21 @@

Allocation {{ allocation.get_parent_resource.name }} {{ allocation.get_parent_resource.resource_type.name }} - {% if allocation.get_information != '' %} + {% if user_allocation_status|get_value_by_index:forloop.counter0 == 'PendingEULA' %} + Review and Accept EULA to activate + {% else %} + {% if allocation.get_information != '' %} {{allocation.get_information}} {% else %} {{allocation.description|default_if_none:""}} {% endif %} + {% endif %} {% if allocation.status.name == 'Active' %} + {% if user_allocation_status|get_value_by_index:forloop.counter0 == 'PendingEULA' %} + {{ user_allocation_status|get_value_by_index:forloop.counter0 }} + {% else %} {{ allocation.status.name }} + {% endif %} {% elif allocation.status.name == 'Expired' or allocation.status.name == 'Denied' %} {{ allocation.status.name }} {% else %} diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 676d54eaab..9b15b4ea65 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -22,11 +22,13 @@ from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string from django.urls import reverse -from coldfront.core.allocation.utils import generate_guauge_data_from_usage +from coldfront.core.allocation.utils import generate_guauge_data_from_usage, get_user_resources from django.views import View from django.views.generic import CreateView, DetailView, ListView, UpdateView from django.views.generic.base import TemplateView from django.views.generic.edit import FormView +from coldfront.config.core import ALLOCATION_EULA_ENABLE + from coldfront.core.allocation.models import (Allocation, AllocationStatusChoice, @@ -168,11 +170,16 @@ def get_context_data(self, **kwargs): Q(project__projectuser__user=self.request.user) & Q(project__projectuser__status__name__in=['Active', ]) & Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name__in=['Active', ]) + Q(allocationuser__status__name__in=['Active', 'PendingEULA' ]) ).distinct().order_by('-end_date') else: allocations = Allocation.objects.prefetch_related( 'resources').filter(project=self.object) + + user_status = [] + for allocation in allocations: + if allocation.allocationuser_set.filter(user=self.request.user).exists(): + user_status.append(allocation.allocationuser_set.get(user=self.request.user).status.name) context['publications'] = Publication.objects.filter( project=self.object, status='Active').order_by('-year') @@ -181,6 +188,7 @@ def get_context_data(self, **kwargs): context['grants'] = Grant.objects.filter( project=self.object, status__name__in=['Active', 'Pending', 'Archived']) context['allocations'] = allocations + context['user_allocation_status'] = user_status context['attributes'] = attributes context['guage_data'] = guage_data context['attributes_with_usage'] = attributes_with_usage @@ -710,6 +718,10 @@ def post(self, request, *args, **kwargs): name='Active') allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get( name='Active') + if ALLOCATION_EULA_ENABLE: + allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get( + name='PendingEULA') + allocation_form_data = allocation_form.cleaned_data['allocation'] if '__select_all__' in allocation_form_data: allocation_form_data.remove('__select_all__') @@ -742,17 +754,24 @@ def post(self, request, *args, **kwargs): project_activate_user.send(sender=self.__class__,project_user_pk=project_user_obj.pk) for allocation in Allocation.objects.filter(pk__in=allocation_form_data): + has_eula = allocation.get_eula() + user_status_choice = allocation_user_active_status_choice if allocation.allocationuser_set.filter(user=user_obj).exists(): + if ALLOCATION_EULA_ENABLE and has_eula and (allocation_user_obj.status != allocation_user_active_status_choice): + user_status_choice = allocation_user_pending_status_choice allocation_user_obj = allocation.allocationuser_set.get( user=user_obj) - allocation_user_obj.status = allocation_user_active_status_choice + allocation_user_obj.status = user_status_choice allocation_user_obj.save() else: + if ALLOCATION_EULA_ENABLE and has_eula: + user_status_choice = allocation_user_pending_status_choice allocation_user_obj = AllocationUser.objects.create( allocation=allocation, user=user_obj, - status=allocation_user_active_status_choice) - allocation_activate_user.send(sender=self.__class__, + status=user_status_choice) + if (user_status_choice == allocation_user_active_status_choice): + allocation_activate_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) messages.success( diff --git a/coldfront/core/resource/views.py b/coldfront/core/resource/views.py index 9b9884aaa9..b37ce43cec 100644 --- a/coldfront/core/resource/views.py +++ b/coldfront/core/resource/views.py @@ -10,10 +10,32 @@ from django.urls import reverse from django.views.generic import TemplateView, ListView from django.views.generic.edit import CreateView +from coldfront.config.core import ALLOCATION_EULA_ENABLE from coldfront.core.resource.forms import ResourceAttributeCreateForm, ResourceSearchForm, ResourceAttributeDeleteForm from coldfront.core.resource.models import Resource, ResourceAttribute +class ResourceEULAView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): + model = Resource + template_name = 'resource_eula.html' + context_object_name = 'resource' + + def test_func(self): + """ UserPassesTestMixin Tests""" + return True + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + pk = self.kwargs.get('pk') + resource_obj = get_object_or_404(Resource, pk=pk) + + attributes = [attribute for attribute in resource_obj.resourceattribute_set.all( + ).order_by('resource_attribute_type__name')] + + context['resource'] = resource_obj + context['attributes'] = attributes + + return context class ResourceDetailView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Resource @@ -294,6 +316,7 @@ def get_context_data(self, **kwargs): context['filter_parameters'] = filter_parameters context['filter_parameters_with_order_by'] = filter_parameters_with_order_by + context['ALLOCATION_EULA_ENABLE'] = ALLOCATION_EULA_ENABLE resource_list = context.get('resource_list') paginator = Paginator(resource_list, self.paginate_by) diff --git a/coldfront/core/utils/mail.py b/coldfront/core/utils/mail.py index 414f469f92..5305908da2 100644 --- a/coldfront/core/utils/mail.py +++ b/coldfront/core/utils/mail.py @@ -60,7 +60,7 @@ def send_email(subject, body, sender, receiver_list, cc=[]): sender, ','.join(receiver_list), subject) -def send_email_template(subject, template_name, template_context, sender, receiver_list): +def send_email_template(subject, template_name, template_context, sender, receiver_list, cc = []): """Helper function for sending emails from a template """ if not EMAIL_ENABLED: @@ -68,7 +68,7 @@ def send_email_template(subject, template_name, template_context, sender, receiv body = render_to_string(template_name, template_context) - return send_email(subject, body, sender, receiver_list) + return send_email(subject, body, sender, receiver_list, cc = cc) def email_template_context(): """Basic email template context used as base for all templates @@ -135,3 +135,37 @@ def send_allocation_customer_email(allocation_obj, subject, template_name, url_p EMAIL_SENDER, email_receiver_list ) + +def send_allocation_eula_customer_email(allocation_user, subject, template_name, url_path='', domain_url='', cc_managers=False, include_eula=False): + """Send allocation customer emails + """ + + allocation_obj = allocation_user.allocation + if not url_path: + url_path = reverse('allocation-review-eula', kwargs={'pk': allocation_obj.pk}) + + url = build_link(url_path, domain_url=domain_url) + ctx = email_template_context() + ctx['resource'] = allocation_obj.get_parent_resource + ctx['url'] = url + ctx['allocation_user'] = "{} {} ({})".format(allocation_user.user.first_name, allocation_user.user.last_name, allocation_user.user.username) + if include_eula: + ctx['eula'] = allocation_obj.get_eula() + + email_receiver_list = [allocation_user.user.email] + email_cc_list = [] + if cc_managers: + project_obj = allocation_obj.project + managers = project_obj.projectuser_set.filter(role__name='Manager', status__name='Active') + for manager in managers: + if manager.enable_notifications: + email_cc_list.append(manager.user.email) + + send_email_template( + subject, + template_name, + ctx, + EMAIL_SENDER, + email_receiver_list, + cc=email_cc_list + ) diff --git a/coldfront/core/utils/management/commands/add_scheduled_tasks.py b/coldfront/core/utils/management/commands/add_scheduled_tasks.py index 0f78c8ea79..d9e91799b5 100644 --- a/coldfront/core/utils/management/commands/add_scheduled_tasks.py +++ b/coldfront/core/utils/management/commands/add_scheduled_tasks.py @@ -4,12 +4,14 @@ from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.utils import timezone +from coldfront.config.email import EMAIL_ALLOCATION_EULA_REMINDERS from django_q.models import Schedule from django_q.tasks import schedule +from coldfront.core.utils.common import import_from_settings +ALLOCATION_EULA_ENABLE = import_from_settings('ALLOCATION_EULA_ENABLE', False) base_dir = settings.BASE_DIR - class Command(BaseCommand): def handle(self, *args, **options): @@ -23,3 +25,8 @@ def handle(self, *args, **options): schedule('coldfront.core.allocation.tasks.send_expiry_emails', schedule_type=Schedule.DAILY, next_run=date) + + if ALLOCATION_EULA_ENABLE and EMAIL_ALLOCATION_EULA_REMINDERS: + schedule('coldfront.core.allocation.tasks.send_eula_reminders', + schedule_type=Schedule.WEEKLY, + next_run=date) diff --git a/coldfront/core/utils/templatetags/common_tags.py b/coldfront/core/utils/templatetags/common_tags.py index 2fc45b2d7c..4b0247c99f 100644 --- a/coldfront/core/utils/templatetags/common_tags.py +++ b/coldfront/core/utils/templatetags/common_tags.py @@ -57,3 +57,10 @@ def get_value_from_dict(dict_data, key): """ if key: return dict_data.get(key) + +@register.filter('get_value_by_index') +def get_value_from_dict(array, index): + """ + usage example {{ your_list|get_value_by_index:your_index }} + """ + return array[index] diff --git a/coldfront/templates/email/allocation_agree_to_eula.txt b/coldfront/templates/email/allocation_agree_to_eula.txt new file mode 100644 index 0000000000..cdf52f86d5 --- /dev/null +++ b/coldfront/templates/email/allocation_agree_to_eula.txt @@ -0,0 +1,6 @@ +Dear {{center_name}} user, + +You have been added to the allocation for {{resource}}. To enable access to this resource, agree to the EULA at {{url}}. + +Thank you, +{{signature}} diff --git a/coldfront/templates/email/allocation_eula_accepted.txt b/coldfront/templates/email/allocation_eula_accepted.txt new file mode 100644 index 0000000000..75225697ed --- /dev/null +++ b/coldfront/templates/email/allocation_eula_accepted.txt @@ -0,0 +1,12 @@ +Dear {{allocation_user}}, + +This is a confirmation that you have agreed to the EULA for the resource {{resource}}. The EULA is available for review at {{url}}. + +{% if eula %} +The EULA is included below for your records: + +{{eula}} + +{% endif %} +Thank you, +{{signature}} diff --git a/coldfront/templates/email/allocation_eula_declined.txt b/coldfront/templates/email/allocation_eula_declined.txt new file mode 100644 index 0000000000..4da52df95c --- /dev/null +++ b/coldfront/templates/email/allocation_eula_declined.txt @@ -0,0 +1,6 @@ +Dear {{allocation_user}}, + +This is a notice that you have declined the EULA for the resource {{resource}}. As a result, this resource will not be available to you. + +Thank you, +{{signature}} diff --git a/coldfront/templates/email/allocation_eula_reminder.txt b/coldfront/templates/email/allocation_eula_reminder.txt new file mode 100644 index 0000000000..b10891503c --- /dev/null +++ b/coldfront/templates/email/allocation_eula_reminder.txt @@ -0,0 +1,6 @@ +Dear {{center_name}} user, + +This is a reminder that access to the resource {{resource}} requires agreeing to the EULA at {{url}}. + +Thank you, +{{signature}} diff --git a/docs/pages/config.md b/docs/pages/config.md index b64d420055..5774976907 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -93,6 +93,7 @@ The following settings are ColdFront specific settings related to the core appli | ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS | List of days users can request extensions in an allocation change request. Default 30,60,90 | | ALLOCATION_ACCOUNT_ENABLED | Allow user to select account name for allocation. Default False | | ALLOCATION_RESOURCE_ORDERING | Controls the ordering of parent resources for an allocation (if allocation has multiple resources). Should be a list of field names suitable for Django QuerySet order_by method. Default is ['-is_allocatable', 'name']; i.e. prefer Resources with is_allocatable field set, ordered by name of the Resource.| +| ALLOCATION_EULA_ENABLE | Enable or disable requiring users to agree to EULA on allocations. Only applies to allocations using a resource with a defined 'eula' attribute. Default False| | INVOICE_ENABLED | Enable or disable invoices. Default True | | ONDEMAND_URL | The URL to your Open OnDemand installation | | LOGIN_FAIL_MESSAGE | Custom message when user fails to login. Here you can paint a custom link to your user account portal | @@ -144,6 +145,11 @@ disabled: | EMAIL_SIGNATURE | Email signature to add to outgoing emails | | EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS | List of days to send email notifications for expiring allocations. Default 7,14,30 | | EMAIL_ADMINS_ON_ALLOCATION_EXPIRE | Setting this to True will send a daily email notification to administrators with a list of allocations that have expired that day. | +| EMAIL_ALLOCATION_EULA_REMINDERS | Enable/Disable EULA reminders. Default False | +| EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT | Ignore user email settings and always send EULA related emails. Default False | +| EMAIL_ALLOCATION_EULA_CONFIRMATIONS | Enable/Disable email notifications when a EULA is accepted or declined. Default False | +| EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS | CC project managers on eula notification emails (requires EMAIL_ALLOCATION_EULA_CONFIRMATIONS to be enabled). Default False | +| EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA | Include copy of EULA in email notifications for accepted EULAs. Default False | ### Plugin settings For more info on [ColdFront plugins](plugin/existing_plugins.md) (Django apps) From 0a5a9173f75fc1185f939a91a0e4b739cb82814b Mon Sep 17 00:00:00 2001 From: David Simpson Date: Thu, 1 May 2025 13:28:18 +0100 Subject: [PATCH 014/110] Reposition project_new signal (to end) in core/views.py Signed-off-by: David Simpson --- coldfront/core/project/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 676d54eaab..15ff183c09 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -489,9 +489,6 @@ def form_valid(self, form): status=ProjectUserStatusChoice.objects.get(name='Active') ) - # project signals - project_new.send(sender=self.__class__, project_obj=project_obj) - if PROJECT_CODE: ''' Set the ProjectCode object, if PROJECT_CODE is defined. @@ -500,6 +497,9 @@ def form_valid(self, form): project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0) project_obj.save(update_fields=["project_code"]) + # project signals + project_new.send(sender=self.__class__, project_obj=project_obj) + return super().form_valid(form) def get_success_url(self): From aeaaa8d409512c29dc99fea027f3468cc82abbb5 Mon Sep 17 00:00:00 2001 From: Tom Payerle Date: Fri, 2 May 2025 10:37:31 -0400 Subject: [PATCH 015/110] Updates to support TLS in ldap_user_search plugin (including suggestions from aebruno) This PR should address concerns of #631 Basically: 1) Adds new config parameter LDAP_USER_SEARCH_CERT_VALIDATE_MODE which is passed to the ldap3.Tls constructor as validate. Accepts as values: 'required' : Certs are required and must validate 'optional' : Certs are optional, but must validate if provided 'none' (or None): Certs are ignored. The default is None 2) The LDAP_USER_SEARCH_CERT_VALIDATE_MODE is passed as the validate field to the ldap3.Tls constructor 3) If LDAP_USE_TLS is set, we pass the connection parameter 'auto_bind' as ldap3.AUTO_BIND_TLS_BEFORE_BIND instead of simply True Inspection of ldap3 code shows that when this parameter is set to True (a value which is no longer listed in docs as valid) it is treated as AUTO_BIND_NO_TLS, so the previous before of leaving this as True was not doing TLS despite claiming to do TLS. This fix should change that. Signed-off-by: Tom Payerle --- coldfront/config/plugins/ldap_user_search.py | 1 + coldfront/plugins/ldap_user_search/README.md | 1 + coldfront/plugins/ldap_user_search/utils.py | 16 ++++++++++++++-- docs/pages/config.md | 1 + 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/coldfront/config/plugins/ldap_user_search.py b/coldfront/config/plugins/ldap_user_search.py index 9197bf2649..1438d96d66 100644 --- a/coldfront/config/plugins/ldap_user_search.py +++ b/coldfront/config/plugins/ldap_user_search.py @@ -20,5 +20,6 @@ LDAP_USER_SEARCH_PRIV_KEY_FILE = ENV.str("LDAP_USER_SEARCH_PRIV_KEY_FILE", default=None) LDAP_USER_SEARCH_CERT_FILE = ENV.str("LDAP_USER_SEARCH_CERT_FILE", default=None) LDAP_USER_SEARCH_CACERT_FILE = ENV.str("LDAP_USER_SEARCH_CACERT_FILE", default=None) +LDAP_USER_SEARCH_CERT_VALIDATE_MODE = ENV.str("LDAP_USER_SEARCH_CERT_VALIDATE_MODE", default=None) ADDITIONAL_USER_SEARCH_CLASSES = ['coldfront.plugins.ldap_user_search.utils.LDAPUserSearch'] diff --git a/coldfront/plugins/ldap_user_search/README.md b/coldfront/plugins/ldap_user_search/README.md index d33d530c74..57da2b4683 100644 --- a/coldfront/plugins/ldap_user_search/README.md +++ b/coldfront/plugins/ldap_user_search/README.md @@ -39,6 +39,7 @@ To enable this plugin set the following applicable environment variables: | `LDAP_USER_SEARCH_PRIV_KEY_FILE` | None | Path to the private key file | | `LDAP_USER_SEARCH_CERT_FILE` | None | Path to the certificate file | | `LDAP_USER_SEARCH_CACERT_FILE` | None | Path to the CA certificate file | +| `LDAP_USER_SEARCH_CERT_VALIDATE_MODE` | none | The extent to which the certificate is validated. Can be 'required' (the certificate is required and validated), 'optional' (certificate is optional but validated if provided), 'none' (certs are ignored) | The following can be set in your local settings: | `LDAP_USER_SEARCH_ATTRIBUTE_MAP` | `{"username": "uid", "last_name": "sn", "first_name": "givenName", "email": "mail"}` | A mapping from ColdFront user attributes to LDAP attributes. | diff --git a/coldfront/plugins/ldap_user_search/utils.py b/coldfront/plugins/ldap_user_search/utils.py index 1ee7ba5e50..b6f59acf47 100644 --- a/coldfront/plugins/ldap_user_search/utils.py +++ b/coldfront/plugins/ldap_user_search/utils.py @@ -4,7 +4,8 @@ import ldap.filter from coldfront.core.user.utils import UserSearch from coldfront.core.utils.common import import_from_settings -from ldap3 import Connection, Server, Tls, get_config_parameter, set_config_parameter, SASL +from ldap3 import Connection, Server, Tls, get_config_parameter, set_config_parameter, SASL, AUTO_BIND_TLS_BEFORE_BIND +import ssl logger = logging.getLogger(__name__) @@ -26,6 +27,7 @@ def __init__(self, user_search_string, search_by): self.LDAP_PRIV_KEY_FILE = import_from_settings('LDAP_USER_SEARCH_PRIV_KEY_FILE', None) self.LDAP_CERT_FILE = import_from_settings('LDAP_USER_SEARCH_CERT_FILE', None) self.LDAP_CACERT_FILE = import_from_settings('LDAP_USER_SEARCH_CACERT_FILE', None) + self.LDAP_CERT_VALIDATE_MODE = import_from_settings('LDAP_USER_SEARCH_CERT_VALIDATE_MODE', None) self.USERNAME_ONLY_ATTR = import_from_settings('LDAP_USER_SEARCH_USERNAME_ONLY_ATTR', 'username') self.ATTRIBUTE_MAP = import_from_settings('LDAP_USER_SEARCH_ATTRIBUTE_MAP', { "username": "uid", @@ -37,14 +39,24 @@ def __init__(self, user_search_string, search_by): tls = None if self.LDAP_USE_TLS: + ldap_cert_validate_mode = ssl.CERT_NONE + if self.LDAP_CERT_VALIDATE_MODE == 'optional': + ldap_cert_validate_mode = ssl.CERT_OPTIONAL + elif self.LDAP_CERT_VALIDATE_MODE == 'required': + ldap_cert_validate_mode = ssl.CERT_REQUIRED + tls = Tls( local_private_key_file=self.LDAP_PRIV_KEY_FILE, local_certificate_file=self.LDAP_CERT_FILE, ca_certs_file=self.LDAP_CACERT_FILE, + validate=ldap_cert_validate_mode, ) self.server = Server(self.LDAP_SERVER_URI, use_ssl=self.LDAP_USE_SSL, connect_timeout=self.LDAP_CONNECT_TIMEOUT, tls=tls) - conn_params = {"auto_bind": True} + auto_bind = True + if self.LDAP_USE_TLS: + auto_bind = AUTO_BIND_TLS_BEFORE_BIND + conn_params = {"auto_bind": auto_bind} if self.LDAP_SASL_MECHANISM: conn_params["sasl_mechanism"] = self.LDAP_SASL_MECHANISM conn_params["sasl_credentials"] = self.LDAP_SASL_CREDENTIALS diff --git a/docs/pages/config.md b/docs/pages/config.md index b64d420055..e43234ff38 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -271,6 +271,7 @@ exist in your backend LDAP to show up in the ColdFront user search. | LDAP_USER_SEARCH_PRIV_KEY_FILE | Path to the private key file. | | LDAP_USER_SEARCH_CERT_FILE | Path to the certificate file. | | LDAP_USER_SEARCH_CACERT_FILE | Path to the CA cert file. | +| LDAP_USER_SEARCH_CERT_VALIDATE_MODE | Whether to require/validate certs. If 'required', certs are required and validated. If 'optional', certs are optional but validated if provided. If 'none' (the default) certs are ignored. | ## Advanced Configuration From 1889d70c7ad531183006ffd2ca128ccc93941a41 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Sun, 6 Apr 2025 21:32:33 -0400 Subject: [PATCH 016/110] Migrate to uv Signed-off-by: Andrew E. Bruno --- .python-version | 1 + .readthedocs.yaml | 27 +- coldfront/config/base.py | 10 +- coldfront/core/grant/models.py | 6 + coldfront/core/project/test_views.py | 4 +- coldfront/core/project/views.py | 1 - coldfront/plugins/api/tests.py | 24 +- coldfront/plugins/freeipa/README.md | 4 +- coldfront/plugins/freeipa/requirements.txt | 4 - coldfront/plugins/iquota/README.md | 2 +- coldfront/plugins/iquota/requirements.txt | 1 - coldfront/plugins/ldap_user_search/README.md | 2 +- .../plugins/ldap_user_search/requirements.txt | 2 - coldfront/plugins/mokey_oidc/README.md | 2 +- coldfront/plugins/mokey_oidc/requirements.txt | 1 - .../plugins/system_monitor/requirements.txt | 1 - docs/pages/deploy.md | 87 +- docs/pages/install.md | 75 +- docs/pages/upgrading.md | 12 + docs/requirements.txt | 18 - pyproject.toml | 103 +- requirements.txt | 42 - uv.lock | 1597 +++++++++++++++++ 23 files changed, 1860 insertions(+), 166 deletions(-) create mode 100644 .python-version delete mode 100644 coldfront/plugins/freeipa/requirements.txt delete mode 100644 coldfront/plugins/iquota/requirements.txt delete mode 100644 coldfront/plugins/ldap_user_search/requirements.txt delete mode 100644 coldfront/plugins/mokey_oidc/requirements.txt delete mode 100644 coldfront/plugins/system_monitor/requirements.txt delete mode 100644 docs/requirements.txt delete mode 100644 requirements.txt create mode 100644 uv.lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..e4fba21835 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 4a75e5b695..f7de7204a9 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,17 +7,20 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.9" + python: "3.12" -# Build documentation with MkDocs -mkdocs: - configuration: docs/mkdocs.yml - -# Optionally set the version of Python and requirements required to build your docs -python: - install: - - requirements: docs/requirements.txt - - method: pip - path: . + jobs: + # See: https://docs.readthedocs.io/en/stable/build-customization.html#install-dependencies-with-uv + pre_create_environment: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + create_environment: + - uv venv + install: + - uv sync --group docs + build: + html: + - NO_COLOR=1 uv run mkdocs build --clean --config-file docs/mkdocs.yml --site-dir $READTHEDOCS_OUTPUT/html diff --git a/coldfront/config/base.py b/coldfront/config/base.py index d9f71d972f..c37af2d230 100644 --- a/coldfront/config/base.py +++ b/coldfront/config/base.py @@ -55,12 +55,20 @@ INSTALLED_APPS += [ 'crispy_forms', 'crispy_bootstrap4', - 'sslserver', 'django_q', 'simple_history', 'fontawesome_free', ] +if DEBUG: + try: + import sslserver + INSTALLED_APPS += [ + 'sslserver', + ] + except ImportError: + pass + # ColdFront Apps INSTALLED_APPS += [ 'coldfront.core.user', diff --git a/coldfront/core/grant/models.py b/coldfront/core/grant/models.py index af60ecb131..9251574dc6 100644 --- a/coldfront/core/grant/models.py +++ b/coldfront/core/grant/models.py @@ -1,6 +1,7 @@ from django.core.validators import (MaxLengthValidator, MaxValueValidator, MinLengthValidator) from django.db import models +from django.core.exceptions import ValidationError from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords from django.core.validators import RegexValidator @@ -74,6 +75,11 @@ def to_python(self, value): if value: value = value.replace(" ", "") value = value.replace("%", "") + try: + if float(value) > 100: + raise ValidationError("Percent credit should be less than 100") + except ValueError: + pass return value class Grant(TimeStampedModel): diff --git a/coldfront/core/project/test_views.py b/coldfront/core/project/test_views.py index aa2fd9bbf5..be02b1fd8f 100644 --- a/coldfront/core/project/test_views.py +++ b/coldfront/core/project/test_views.py @@ -194,9 +194,7 @@ def test_project_attribute_create_value_type_match(self): 'value': True, 'project': self.project.pk }) - self.assertFormError( - response, 'form', '', 'Invalid Value True. Value must be an int.' - ) + self.assertContains(response, 'Invalid Value True. Value must be an int.') class ProjectAttributeUpdateTest(ProjectViewTestBase): diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 15ff183c09..ef7b159bd8 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -1,5 +1,4 @@ import datetime -from pipes import Template import pprint import django import logging diff --git a/coldfront/plugins/api/tests.py b/coldfront/plugins/api/tests.py index 0ffbabf17e..993efec4b3 100644 --- a/coldfront/plugins/api/tests.py +++ b/coldfront/plugins/api/tests.py @@ -2,11 +2,33 @@ from rest_framework.test import APITestCase from coldfront.core.allocation.models import Allocation from coldfront.core.project.models import Project +from coldfront.core.test_helpers.factories import ( + AllocationFactory, + ProjectFactory, + ProjectStatusChoiceFactory, + ResourceFactory, + UserFactory, +) class ColdfrontAPI(APITestCase): """Tests for the Coldfront REST API""" + @classmethod + def setUpTestData(self): + """Test Data setup for ColdFront REST API tests.""" + self.admin_user = UserFactory(is_staff=True, is_superuser=True) + + project = ProjectFactory(status=ProjectStatusChoiceFactory(name='Active')) + allocation = AllocationFactory(project=project) + allocation.resources.add(ResourceFactory(name='test')) + self.pi_user = project.pi + + for i in range(0, 10): + project = ProjectFactory(status=ProjectStatusChoiceFactory(name='Active')) + allocation = AllocationFactory(project=project) + allocation.resources.add(ResourceFactory(name='test')) + def test_requires_login(self): """Test that the API requires authentication""" response = self.client.get('/api/') @@ -37,7 +59,7 @@ def test_allocation_api_permissions(self): self.client.force_login(self.pi_user) response = self.client.get('/api/allocations/', format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data), 2) + self.assertEqual(len(response.data), 1) def test_project_api_permissions(self): """Confirm permissions for project API: diff --git a/coldfront/plugins/freeipa/README.md b/coldfront/plugins/freeipa/README.md index 10a3cd3d30..a34b12a1a8 100644 --- a/coldfront/plugins/freeipa/README.md +++ b/coldfront/plugins/freeipa/README.md @@ -36,9 +36,7 @@ ipaclient python library. ### Install required python packages -- pip install django-q -- pip install ipaclient -- pip install dbus-python +- uv sync --extra ldap --extra freeipa ### Update sssd.conf to enable infopipe diff --git a/coldfront/plugins/freeipa/requirements.txt b/coldfront/plugins/freeipa/requirements.txt deleted file mode 100644 index 08b8c5a59e..0000000000 --- a/coldfront/plugins/freeipa/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -ipaclient==4.7.2 -gssapi==1.5.1 -ldap3==2.6 -dbus-python==1.2.8 diff --git a/coldfront/plugins/iquota/README.md b/coldfront/plugins/iquota/README.md index fa662a9d53..a4cdfff183 100644 --- a/coldfront/plugins/iquota/README.md +++ b/coldfront/plugins/iquota/README.md @@ -12,7 +12,7 @@ to authenticate to the API and a valid keytab file is required. ## Requirements -- pip install kerberos humanize requests +- uv sync --extra iquota ## Usage diff --git a/coldfront/plugins/iquota/requirements.txt b/coldfront/plugins/iquota/requirements.txt deleted file mode 100644 index 9cfb7ca74f..0000000000 --- a/coldfront/plugins/iquota/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -kerberos==1.3.0 diff --git a/coldfront/plugins/ldap_user_search/README.md b/coldfront/plugins/ldap_user_search/README.md index 57da2b4683..332f5256df 100644 --- a/coldfront/plugins/ldap_user_search/README.md +++ b/coldfront/plugins/ldap_user_search/README.md @@ -19,7 +19,7 @@ ColdFront users. ## Requirements -- `pip install python-ldap ldap3` +- uv sync --extra ldap ## Usage diff --git a/coldfront/plugins/ldap_user_search/requirements.txt b/coldfront/plugins/ldap_user_search/requirements.txt deleted file mode 100644 index 09ee3ae134..0000000000 --- a/coldfront/plugins/ldap_user_search/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -ldap3==2.6 -python-ldap==3.4.0 diff --git a/coldfront/plugins/mokey_oidc/README.md b/coldfront/plugins/mokey_oidc/README.md index 226f1539a9..46c6e8d341 100644 --- a/coldfront/plugins/mokey_oidc/README.md +++ b/coldfront/plugins/mokey_oidc/README.md @@ -17,7 +17,7 @@ Django users. ## Requirements -- pip install mozilla-django-oidc +- uv sync --extra oidc ## Usage ### Mokey/Hydra integration diff --git a/coldfront/plugins/mokey_oidc/requirements.txt b/coldfront/plugins/mokey_oidc/requirements.txt deleted file mode 100644 index 40266719ad..0000000000 --- a/coldfront/plugins/mokey_oidc/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -mozilla-django-oidc==1.2.1 diff --git a/coldfront/plugins/system_monitor/requirements.txt b/coldfront/plugins/system_monitor/requirements.txt deleted file mode 100644 index 5b8c55fa04..0000000000 --- a/coldfront/plugins/system_monitor/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -beautifulsoup4==4.7.1 diff --git a/docs/pages/deploy.md b/docs/pages/deploy.md index 39426c79b6..abf72049cd 100644 --- a/docs/pages/deploy.md +++ b/docs/pages/deploy.md @@ -13,10 +13,10 @@ worker processes will run under this user account. Also, create a directory for installing ColdFront and related files. ``` -# useradd --system -m coldfront -# mkdir /srv/coldfront -# chown coldfront.coldfront /srv/coldfront -# mkdir /etc/coldfront +$ useradd --system -m coldfront +$ mkdir /srv/coldfront +$ chown coldfront.coldfront /srv/coldfront +$ mkdir /etc/coldfront ``` !!! note @@ -25,14 +25,40 @@ installing ColdFront and related files. # yum/apt install nginx ``` -## Install ColdFront in a virtual environment +## Install ColdFront +ColdFront can be installed from PyPI using any of the following methods. +ColdFront has a few optional dependencies that are used for enabling specific +features: + + +| extra | Description | +| :-------------|:-----------------------| +| ldap | ldap user search | +| oidc | OIDC logins | +| freeipa | freeipa plugin | +| iquota | iquota plugin | +| mysql | MySQL/MariaDB backend | +| pg | PostgreSQL backend | + + + +### Using uv (recommended) + +``` +$ uv tool install coldfront[ldap,freeipa,oidc] ``` -# cd /srv/coldfront -# python3 -mvenv venv -# source venv/bin/activate -# pip install --upgrade pip -# pip install coldfront + +### Using pip in a virtual environment + +``` +$ cd /srv/coldfront +$ python3 -mvenv venv +$ source venv/bin/activate +$ pip install --upgrade pip + +# Adjust extras to suite your needs +$ pip install coldfront[ldap,freeipa,oidc,pg] ``` ## Configure ColdFront @@ -82,17 +108,17 @@ Install your preferred database and set the connection details using the `DB_URL` variable as shown above. !!! note "Note: Install python database drivers" - Be sure to install the database drivers associated with your db. For example: + Be sure to install the extra package options when installing ColdFront for your chosen database. For example: ``` - $ source /srv/coldfront/venv/bin/activate - $ pip install mysqlclient - $ pip install psycopg2 + # For mysql/mariadb + $ pip install coldfront[mysql] + # For postgresql + $ pip install coldfront[pg] ``` ## Initializing the ColdFront database ``` -$ source /srv/coldfront/venv/bin/activate $ coldfront initial_setup Running migrations: Applying contenttypes.0001_initial... OK @@ -112,7 +138,6 @@ Run the command below to create a new super user account with a secure password: ``` -$ source /srv/coldfront/venv/bin/activate $ coldfront createsuperuser ``` @@ -126,17 +151,9 @@ using nginx. For more information [see here](https://docs.djangoproject.com/en/3 This command will deploy all static assets to the `STATIC_ROOT` path in the configuration step above. ``` -$ source /srv/coldfront/venv/bin/activate $ coldfront collectstatic ``` -## Install Gunicorn - -``` -$ source /srv/coldfront/venv/bin/activate -$ pip install gunicorn -``` - ## Create systemd unit file for ColdFront Gunicorn workers Create file `/etc/systemd/system/gunicorn.service`: @@ -158,16 +175,17 @@ WantedBy=multi-user.target ``` !!! note - Adjust the number of workers for your site specific needs using the `--workers` flag above. + If using `uv tool` change the paths above accordingly. Adjust the number + of workers for your site specific needs using the `--workers` flag above. ## Start/enable ColdFront Gunicorn workers ``` -# systemctl start gunicorn.service -# systemctl enable gunicorn.service +$ systemctl start gunicorn.service +$ systemctl enable gunicorn.service # Check for any errors -# systemctl status gunicorn.service +$ systemctl status gunicorn.service ``` ## Create systemd unit file for ColdFront qcluster scheduled tasks @@ -193,14 +211,17 @@ ExecStart=/srv/coldfront/venv/bin/coldfront qcluster WantedBy=multi-user.target ``` +!!! note + If using `uv tool` change the paths above accordingly. + ## Start/enable ColdFront qcluster ``` -# systemctl start coldfrontq.service -# systemctl enable coldfrontq.service +$ systemctl start coldfrontq.service +$ systemctl enable coldfrontq.service # Check for any errors -# systemctl status coldfrontq.service +$ systemctl status coldfrontq.service ``` ## Configure nginx @@ -251,6 +272,6 @@ server { ## Start/enable nginx ``` -# systemctl restart nginx.service -# systemctl enable nginx.service +$ systemctl restart nginx.service +$ systemctl enable nginx.service ``` diff --git a/docs/pages/install.md b/docs/pages/install.md index 3eac49a88f..b463465f51 100644 --- a/docs/pages/install.md +++ b/docs/pages/install.md @@ -16,48 +16,55 @@ ColdFront requires Python 3+ ## Installation Methods -### Install via pip (recommended) +### Install via uv (recommended) -The recommended way of installing ColdFront is via pip inside a virtual -environment: +The recommended way of installing ColdFront is via [uv](https://docs.astral.sh/uv/): + +``` +$ uv tool install coldfront[ldap,freeipa,oidc] +``` + +### Install via pip + +ColdFront can be installed via pip inside a virtual environment: ``` $ python3 -mvenv venv $ source venv/bin/activate $ pip install --upgrade pip -$ pip install coldfront +# Adjust extras to suite your needs +$ pip install coldfront[ldap,freeipa,oidc,pg] ``` We recommend you install ColdFront in a test environment first; however, if you want to jump right to instructions for installing and deploying in a production environment, [go here](deploy.md) -### Install via source distribution - -We recommend installing via pip, but if you prefer to install via a source -distribution you can download ColdFront releases via -[PyPI](https://pypi.org/project/coldfront/#files) or -[GitHub](https://github.com/ubccr/coldfront/releases). We also recommend -installing this in a virtual environment. +### Install from source ``` -$ tar xvzf coldfront-x.x.x.tar.gz -$ cd coldfront-x.x.x -$ pip install . +$ git clone https://github.com/ubccr/coldfront.git +$ cd coldfront +$ uv sync --group dev ``` -### Developing ColdFront +## Configuring ColdFront + +For complete documentation on configuring ColdFront [see here](config.md). +ColdFront can be configured via environment variables, an environment file, or +a python file which can be used for more advanced configuration settings. +ColdFront requires a database and if you don't configure one it will use SQLite +by default. + +## Developing ColdFront If you're interested in hacking on the ColdFront source code you can install by -cloning our GitHub repo and install via pip development mode. Note the master -branch is the bleeding edge version and may be unstable. You can also checkout -one of the tagged releases. +cloning our GitHub repo and install via uv. Note the master branch is the +bleeding edge version and may be unstable. You can also checkout one of the +tagged releases. ``` -$ python3 -mvenv venv -$ source venv/bin/activate -$ pip install --upgrade pip $ git clone https://github.com/ubccr/coldfront.git $ cd coldfront -$ pip install -e . +$ uv sync --group dev ``` !!! info "Recommended" @@ -67,15 +74,7 @@ $ pip install -e . git checkout v1.x.x ``` -## Configuring ColdFront - -For complete documentation on configuring ColdFront [see here](config.md). -ColdFront can be configured via environment variables, an environment file, or -a python file which can be used for more advanced configuration settings. -ColdFront requires a database and if you don't configure one it will use SQLite -by default. - -## Initializing the ColdFront database +### Initializing the ColdFront database ColdFront supports MariaDB/MySQL, PostgreSQL, and SQLite. See the complete guide on [configuring ColdFront](config.md) for more details. By default, ColdFront will use @@ -85,7 +84,7 @@ After configuring your database of choice you must first initialize the ColdFront database. This should only be done once: ``` -$ coldfront initial_setup +$ uv run coldfront initial_setup Running migrations: Applying contenttypes.0001_initial... OK Applying auth.0001_initial... OK @@ -95,24 +94,24 @@ Running migrations: After the above command completes the ColdFront database is ready for use. -## Creating the super user account +### Creating the super user account Run the command below to create a new super user account: ``` -$ coldfront createsuperuser +$ uv run coldfront createsuperuser ``` !!! Tip This command should prompt you to select a username, password, and email address for your super user account. -## Running ColdFront server +### Running ColdFront server ColdFront is a Django application and comes with a simple web server to get started quickly. This is good for evaluating ColdFront and testing/demo purposes. Run the following command to start the development web server: ``` -$ DEBUG=True coldfront runserver +$ DEBUG=True uv run coldfront runserver ``` Point your browser to http://localhost:8000 and login with the super user @@ -122,14 +121,14 @@ account you created. Do not run this in production. For more information on deploying ColdFront in production [see here](deploy.md). -## Loading the sample test data +### Loading the sample test data If you're interested in evaluating ColdFront we provide an easy way to load some test data so you can get a feel for how ColdFront works. Run this command to load the test data set: ``` -$ coldfront load_test_data +$ uv run coldfront load_test_data ``` - You can log in as `admin` with password `test1234`. diff --git a/docs/pages/upgrading.md b/docs/pages/upgrading.md index 50fd60e3ce..a801606c5a 100644 --- a/docs/pages/upgrading.md +++ b/docs/pages/upgrading.md @@ -3,6 +3,18 @@ This document describes upgrading ColdFront. New releases of ColdFront may introduce breaking changes so please refer to this document before upgrading. +## [v1.1.7](https://github.com/ubccr/coldfront/releases/tag/v1.1.7) + +This release upgrades to [django-q2](https://github.com/django-q2/django-q2) +which requires database migrations. We also now use [uv](https://docs.astral.sh/uv/) +and highly recommend switching to this for deployments. + +After upgrading be sure to run any outstanding database migrations: + +``` +$ coldfront migrate +``` + ## [v1.1.4](https://github.com/ubccr/coldfront/releases/tag/v1.1.4) This release includes a new Project Attribute feature which requires a database diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index fafc825b87..0000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,18 +0,0 @@ -Jinja2==3.1.6 -jsmin==3.0.1 -Markdown==3.7 -MarkupSafe==3.0.2 -mkdocs==1.6.1 -mkdocs-autorefs==1.4.1 -mkdocs-awesome-pages-plugin==2.10.1 -mkdocs-get-deps==0.2.0 -mkdocs-material==9.6.9 -mkdocs-material-extensions==1.3.1 -mkdocs-minify-plugin==0.8.0 -mkdocstrings==0.29.0 -mkdocstrings-python==1.16.5 -Pygments==2.19.1 -pymdown-extensions==10.14.3 -PyYAML==6.0.2 -six==1.17.0 -importlib_metadata==8.6.1 diff --git a/pyproject.toml b/pyproject.toml index 6aab9482a6..c0d3553e4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,102 @@ [build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta:__legacy__" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "coldfront" +version = "1.1.6" +requires-python = ">=3.9" +authors = [ + { name = "Andrew E. Bruno" }, + { name = "Dori Sajdak" }, + { name = "Mohammad Zia" }, +] +description = "HPC Resource Allocation System" +readme = "README.md" +license = { file = "LICENSE" } +keywords = ["high-performance-computing", "resource-allocation"] +classifiers = [ + 'Programming Language :: Python :: 3', + 'Framework :: Django :: 4.2', + 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', + 'Topic :: Scientific/Engineering', + 'Topic :: System :: Systems Administration', + 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application', +] +dependencies = [ + "crispy-bootstrap4>=2024.10", + "django>4.2,<5", + "django-crispy-forms>=2.3", + "django-environ>=0.12.0", + "django-filter>=25.1", + "django-model-utils>=5.0.0", + "django-q2>=1.7.6", + "django-settings-export>=1.2.1", + "django-simple-history>=3.8.0", + "django-split-settings>=1.3.2", + "django-su>=1.0.0", + "djangorestframework>=3.16.0", + "doi2bib>=0.4.0", + "fontawesome-free>=5.15.4", + "formencode>=2.1.1", + "gunicorn>=23.0.0", + "humanize>=4.12.2", + "python-dateutil>=2.9.0.post0", + "redis>=5.2.1", +] + +[project.urls] +"Bug Tracker" = "https://github.com/ubccr/coldfront/issues" +Changelog = "https://github.com/ubccr/coldfront/blob/main/CHANGELOG.md" +Documentation = "https://coldfront.readthedocs.io" +"Source Code" = "https://github.com/ubccr/coldfront" + +[project.scripts] +coldfront = "coldfront:manage" +gunicorn = "gunicorn.app.wsgiapp:run" + +[project.optional-dependencies] +ldap = [ + "django-auth-ldap>=5.1.0", + "ldap3>=2.9.1", +] +freeipa = [ + "dbus-python>=1.4.0", + "ipaclient>=4.12.2", +] +iquota = [ + "kerberos>=1.3.1", +] +oidc = [ + "mozilla-django-oidc>=4.0.1", +] +mysql = [ + "mysqlclient>=2.2.7", +] +pg = [ + "psycopg2>=2.9.10", +] + +[dependency-groups] +dev = [ + "django-sslserver>=0.22", + "factory-boy>=3.3.3", + "faker>=37.1.0", + "pytest-django>=4.11.1", +] +docs = [ + "mkdocs>=1.6.1", + "mkdocs-awesome-pages-plugin>=2.10.1", + "mkdocs-material>=9.6.11", + "mkdocstrings>=0.29.1", + "mkdocstrings-python>=1.16.10", + "pygments", + "pymdown-extensions", +] +lint = ["ruff>=0.11.4"] + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e7b5d4a7a7..0000000000 --- a/requirements.txt +++ /dev/null @@ -1,42 +0,0 @@ -arrow==1.3.0 -asgiref==3.7.2 -bibtexparser==1.4.1 -blessed==1.20.0 -certifi==2024.2.2 -chardet==5.2.0 -charset-normalizer==3.3.2 -Django==4.2.11 -django-crispy-forms==2.1 -crispy-bootstrap4==2024.1 -django-environ==0.11.2 -django-filter==24.2 -django-model-utils==4.4.0 -django-picklefield==3.1 -django-q==1.3.9 -django-settings-export==1.2.1 -django-simple-history==3.5.0 -django-split-settings==1.3.0 -django-sslserver==0.22 -django-su==1.0.0 -djangorestframework==3.15.2 -doi2bib==0.4.0 -factory-boy==3.3.0 -Faker==24.1.0 -fontawesome-free==5.15.4 -FormEncode==2.1.0 -future==1.0.0 -humanize==4.9.0 -idna==3.6 -pyparsing==3.1.2 -python-dateutil==2.9.0.post0 -python-memcached==1.62 -pytz==2024.1 -redis==3.5.3 -requests==2.31.0 -six==1.16.0 -sqlparse==0.4.4 -text-unidecode==1.3 -types-python-dateutil==2.8.19.20240106 -typing_extensions==4.10.0 -urllib3==2.2.1 -wcwidth==0.2.13 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000..4b07ba1635 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1597 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, +] + +[[package]] +name = "backrefs" +version = "5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/46/caba1eb32fa5784428ab401a5487f73db4104590ecd939ed9daaf18b47e0/backrefs-5.8.tar.gz", hash = "sha256:2cab642a205ce966af3dd4b38ee36009b31fa9502a35fd61d59ccc116e40a6bd", size = 6773994 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/cb/d019ab87fe70e0fe3946196d50d6a4428623dc0c38a6669c8cae0320fbf3/backrefs-5.8-py310-none-any.whl", hash = "sha256:c67f6638a34a5b8730812f5101376f9d41dc38c43f1fdc35cb54700f6ed4465d", size = 380337 }, + { url = "https://files.pythonhosted.org/packages/a9/86/abd17f50ee21b2248075cb6924c6e7f9d23b4925ca64ec660e869c2633f1/backrefs-5.8-py311-none-any.whl", hash = "sha256:2e1c15e4af0e12e45c8701bd5da0902d326b2e200cafcd25e49d9f06d44bb61b", size = 392142 }, + { url = "https://files.pythonhosted.org/packages/b3/04/7b415bd75c8ab3268cc138c76fa648c19495fcc7d155508a0e62f3f82308/backrefs-5.8-py312-none-any.whl", hash = "sha256:bbef7169a33811080d67cdf1538c8289f76f0942ff971222a16034da88a73486", size = 398021 }, + { url = "https://files.pythonhosted.org/packages/04/b8/60dcfb90eb03a06e883a92abbc2ab95c71f0d8c9dd0af76ab1d5ce0b1402/backrefs-5.8-py313-none-any.whl", hash = "sha256:e3a63b073867dbefd0536425f43db618578528e3896fb77be7141328642a1585", size = 399915 }, + { url = "https://files.pythonhosted.org/packages/0c/37/fb6973edeb700f6e3d6ff222400602ab1830446c25c7b4676d8de93e65b8/backrefs-5.8-py39-none-any.whl", hash = "sha256:a66851e4533fb5b371aa0628e1fee1af05135616b86140c9d787a2ffdf4b8fdc", size = 380336 }, +] + +[[package]] +name = "bibtexparser" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/8d/e296c7af03757debd8fc80df2898cbed4fb69fc61ed2c9b4a1d42e923a9e/bibtexparser-1.4.3.tar.gz", hash = "sha256:a9c7ded64bc137720e4df0b1b7f12734edc1361185f1c9097048ff7c35af2b8f", size = 55582 } + +[[package]] +name = "bracex" +version = "2.5.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/6c/57418c4404cd22fe6275b8301ca2b46a8cdaa8157938017a9ae0b3edf363/bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6", size = 26641 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/02/8db98cdc1a58e0abd6716d5e63244658e6e63513c65f469f34b6f1053fd0/bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6", size = 11558 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, + { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, + { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, + { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, + { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, + { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, + { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, + { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, + { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, + { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, + { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, + { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 }, + { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 }, + { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 }, + { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 }, + { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 }, + { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 }, + { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 }, + { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 }, + { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 }, + { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 }, + { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 }, + { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 }, + { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "coldfront" +version = "1.1.6" +source = { editable = "." } +dependencies = [ + { name = "crispy-bootstrap4" }, + { name = "django" }, + { name = "django-crispy-forms" }, + { name = "django-environ" }, + { name = "django-filter" }, + { name = "django-model-utils" }, + { name = "django-q2" }, + { name = "django-settings-export" }, + { name = "django-simple-history" }, + { name = "django-split-settings" }, + { name = "django-su" }, + { name = "djangorestframework" }, + { name = "doi2bib" }, + { name = "fontawesome-free" }, + { name = "formencode" }, + { name = "gunicorn" }, + { name = "humanize" }, + { name = "python-dateutil" }, + { name = "redis" }, +] + +[package.optional-dependencies] +freeipa = [ + { name = "dbus-python" }, + { name = "ipaclient" }, +] +iquota = [ + { name = "kerberos" }, +] +ldap = [ + { name = "django-auth-ldap" }, + { name = "ldap3" }, +] +mysql = [ + { name = "mysqlclient" }, +] +oidc = [ + { name = "mozilla-django-oidc" }, +] +pg = [ + { name = "psycopg2" }, +] + +[package.dev-dependencies] +dev = [ + { name = "django-sslserver" }, + { name = "factory-boy" }, + { name = "faker" }, + { name = "pytest-django" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-awesome-pages-plugin" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings" }, + { name = "mkdocstrings-python" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, +] +lint = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "crispy-bootstrap4", specifier = ">=2024.10" }, + { name = "dbus-python", marker = "extra == 'freeipa'", specifier = ">=1.4.0" }, + { name = "django", specifier = ">4.2,<5" }, + { name = "django-auth-ldap", marker = "extra == 'ldap'", specifier = ">=5.1.0" }, + { name = "django-crispy-forms", specifier = ">=2.3" }, + { name = "django-environ", specifier = ">=0.12.0" }, + { name = "django-filter", specifier = ">=25.1" }, + { name = "django-model-utils", specifier = ">=5.0.0" }, + { name = "django-q2", specifier = ">=1.7.6" }, + { name = "django-settings-export", specifier = ">=1.2.1" }, + { name = "django-simple-history", specifier = ">=3.8.0" }, + { name = "django-split-settings", specifier = ">=1.3.2" }, + { name = "django-su", specifier = ">=1.0.0" }, + { name = "djangorestframework", specifier = ">=3.16.0" }, + { name = "doi2bib", specifier = ">=0.4.0" }, + { name = "fontawesome-free", specifier = ">=5.15.4" }, + { name = "formencode", specifier = ">=2.1.1" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "humanize", specifier = ">=4.12.2" }, + { name = "ipaclient", marker = "extra == 'freeipa'", specifier = ">=4.12.2" }, + { name = "kerberos", marker = "extra == 'iquota'", specifier = ">=1.3.1" }, + { name = "ldap3", marker = "extra == 'ldap'", specifier = ">=2.9.1" }, + { name = "mozilla-django-oidc", marker = "extra == 'oidc'", specifier = ">=4.0.1" }, + { name = "mysqlclient", marker = "extra == 'mysql'", specifier = ">=2.2.7" }, + { name = "psycopg2", marker = "extra == 'pg'", specifier = ">=2.9.10" }, + { name = "python-dateutil", specifier = ">=2.9.0.post0" }, + { name = "redis", specifier = ">=5.2.1" }, +] +provides-extras = ["ldap", "freeipa", "iquota", "oidc", "mysql", "pg"] + +[package.metadata.requires-dev] +dev = [ + { name = "django-sslserver", specifier = ">=0.22" }, + { name = "factory-boy", specifier = ">=3.3.3" }, + { name = "faker", specifier = ">=37.1.0" }, + { name = "pytest-django", specifier = ">=4.11.1" }, +] +docs = [ + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-awesome-pages-plugin", specifier = ">=2.10.1" }, + { name = "mkdocs-material", specifier = ">=9.6.11" }, + { name = "mkdocstrings", specifier = ">=0.29.1" }, + { name = "mkdocstrings-python", specifier = ">=1.16.10" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, +] +lint = [{ name = "ruff", specifier = ">=0.11.4" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "crispy-bootstrap4" +version = "2024.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-crispy-forms" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/0b/6a3e2ab27d9eab3fd95628e45212454ac486b2c501def355f3c425cf4ae3/crispy-bootstrap4-2024.10.tar.gz", hash = "sha256:503e8922b0f3b5262a6fdf303a3a94eb2a07514812f1ca130b88f7c02dd25e2b", size = 35301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/a9/2a22c0e6b72323205a6780f9a93e8121bc2c81338d34a0ddc1f6d1a958e7/crispy_bootstrap4-2024.10-py3-none-any.whl", hash = "sha256:138a97884044ae4c4799c80595b36c42066e4e933431e2e971611e251c84f96c", size = 23060 }, +] + +[[package]] +name = "cryptography" +version = "44.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 }, + { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 }, + { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 }, + { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 }, + { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 }, + { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 }, + { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 }, + { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 }, + { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 }, + { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 }, + { url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 }, + { url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 }, + { url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 }, + { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 }, + { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 }, + { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 }, + { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 }, + { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 }, + { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 }, + { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 }, + { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 }, + { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, + { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 }, + { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, + { url = "https://files.pythonhosted.org/packages/99/10/173be140714d2ebaea8b641ff801cbcb3ef23101a2981cbf08057876f89e/cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb", size = 3396886 }, + { url = "https://files.pythonhosted.org/packages/2f/b4/424ea2d0fce08c24ede307cead3409ecbfc2f566725d4701b9754c0a1174/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", size = 3892387 }, + { url = "https://files.pythonhosted.org/packages/28/20/8eaa1a4f7c68a1cb15019dbaad59c812d4df4fac6fd5f7b0b9c5177f1edd/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", size = 4109922 }, + { url = "https://files.pythonhosted.org/packages/11/25/5ed9a17d532c32b3bc81cc294d21a36c772d053981c22bd678396bc4ae30/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", size = 3895715 }, + { url = "https://files.pythonhosted.org/packages/63/31/2aac03b19c6329b62c45ba4e091f9de0b8f687e1b0cd84f101401bece343/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", size = 4109876 }, + { url = "https://files.pythonhosted.org/packages/99/ec/6e560908349843718db1a782673f36852952d52a55ab14e46c42c8a7690a/cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d", size = 3131719 }, + { url = "https://files.pythonhosted.org/packages/d6/d7/f30e75a6aa7d0f65031886fa4a1485c2fbfe25a1896953920f6a9cfe2d3b/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", size = 3887513 }, + { url = "https://files.pythonhosted.org/packages/9c/b4/7a494ce1032323ca9db9a3661894c66e0d7142ad2079a4249303402d8c71/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", size = 4107432 }, + { url = "https://files.pythonhosted.org/packages/45/f8/6b3ec0bc56123b344a8d2b3264a325646d2dcdbdd9848b5e6f3d37db90b3/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", size = 3891421 }, + { url = "https://files.pythonhosted.org/packages/57/ff/f3b4b2d007c2a646b0f69440ab06224f9cf37a977a72cdb7b50632174e8a/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", size = 4107081 }, +] + +[[package]] +name = "dbus-python" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/24/63118050c7dd7be04b1ccd60eab53fef00abe844442e1b6dec92dae505d6/dbus-python-1.4.0.tar.gz", hash = "sha256:991666e498f60dbf3e49b8b7678f5559b8a65034fdf61aae62cdecdb7d89c770", size = 232490 } + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, +] + +[[package]] +name = "django" +version = "4.2.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/bb/2fad5edc1af2945cb499a2e322ac28e4714fc310bd5201ed1f5a9f73a342/django-4.2.21.tar.gz", hash = "sha256:b54ac28d6aa964fc7c2f7335138a54d78980232011e0cd2231d04eed393dcb0d", size = 10424638 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/4f/aeaa3098da18b625ed672f3da6d1cd94e188d1b2cc27c2c841b2f9666282/django-4.2.21-py3-none-any.whl", hash = "sha256:1d658c7bf5d31c7d0cac1cab58bc1f822df89255080fec81909256c30e6180b3", size = 7993839 }, +] + +[[package]] +name = "django-auth-ldap" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "python-ldap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/e4/2e8781840cc54f719be3241e16640524a9aabf94a599f5e083b0115042ce/django_auth_ldap-5.1.0.tar.gz", hash = "sha256:9c607e8d9c53cf2a0ccafbe0acfc33eb1d1fd474c46ec52d30aee0dca1da9668", size = 55059 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/47/f3492884addbb17672cc9a6053381162010d6e40ccd8440dedf22f72bc7f/django_auth_ldap-5.1.0-py3-none-any.whl", hash = "sha256:a5f7bdb54b2ab80e4e9eb080cd3e06e89e4c9d2d534ddb39b66cd970dd6d3536", size = 20833 }, +] + +[[package]] +name = "django-crispy-forms" +version = "2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/f6/5bce7ae3512171c7c0ca3de31689e2a1ced8b030f156fcf13d2870e5468e/django_crispy_forms-2.3.tar.gz", hash = "sha256:2db17ae08527201be1273f0df789e5f92819e23dd28fec69cffba7f3762e1a38", size = 278849 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/3b/5dc3faf8739d1ce7a73cedaff508b4af8f6aa1684120ded6185ca0c92734/django_crispy_forms-2.3-py3-none-any.whl", hash = "sha256:efc4c31e5202bbec6af70d383a35e12fc80ea769d464fb0e7fe21768bb138a20", size = 31411 }, +] + +[[package]] +name = "django-environ" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/04/65d2521842c42f4716225f20d8443a50804920606aec018188bbee30a6b0/django_environ-0.12.0.tar.gz", hash = "sha256:227dc891453dd5bde769c3449cf4a74b6f2ee8f7ab2361c93a07068f4179041a", size = 56804 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b3/0a3bec4ecbfee960f39b1842c2f91e4754251e0a6ed443db9fe3f666ba8f/django_environ-0.12.0-py2.py3-none-any.whl", hash = "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca", size = 19957 }, +] + +[[package]] +name = "django-filter" +version = "25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/40/c702a6fe8cccac9bf426b55724ebdf57d10a132bae80a17691d0cf0b9bac/django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", size = 143021 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114 }, +] + +[[package]] +name = "django-model-utils" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/60/5e232c32a2c977cc1af8c70a38ef436598bc649ad89c2c4568454edde2c9/django_model_utils-5.0.0.tar.gz", hash = "sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb", size = 80559 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/13/87a42048700c54bfce35900a34e2031245132775fb24363fc0e33664aa9c/django_model_utils-5.0.0-py3-none-any.whl", hash = "sha256:fec78e6c323d565a221f7c4edc703f4567d7bb1caeafe1acd16a80c5ff82056b", size = 42630 }, +] + +[[package]] +name = "django-picklefield" +version = "3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/70/11411d4f528e4fad57a17dae81c85da039feba90c001b64e677fc0925a97/django-picklefield-3.3.tar.gz", hash = "sha256:4e76dd20f2e95ffdaf18d641226ccecc169ff0473b0d6bec746f3ab97c26b8cb", size = 9559 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/e1/988fa6ded7275bb11e373ccd4b708af477f12027d3ee86b7cb5fc5779412/django_picklefield-3.3-py3-none-any.whl", hash = "sha256:d6f6fd94a17177fe0d16b0b452a9860b8a1da97b6e70633ab53ade4975f1ce9a", size = 9565 }, +] + +[[package]] +name = "django-q2" +version = "1.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-picklefield" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/48/e4dc1d22ec1c366347b59c4eb9a65c2337d3cfc6139f00778d25464ae591/django_q2-1.7.6.tar.gz", hash = "sha256:5210b121573cf65b97d495dbebefe6cfac394d8c0aec9ca2117e8e56e2fda17d", size = 76849 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/ee/5c292a0d30b8a250a2389cc31bfa4a13c8c41a9fc29f4678ca6de882e23e/django_q2-1.7.6-py3-none-any.whl", hash = "sha256:9060f4d68e1f3a8a748e0ebd0bd83c8c24bc13036105035873faab9d85b0e8f6", size = 89478 }, +] + +[[package]] +name = "django-settings-export" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/72/9848a2d631dad70d7ea582540f0619e1a7ecf31b3a117de9d9f2b6b28029/django-settings-export-1.2.1.tar.gz", hash = "sha256:fceeae49fc597f654c1217415d8e049fc81c930b7154f5d8f28c432db738ff79", size = 4951 } + +[[package]] +name = "django-simple-history" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/46/4cbf411f9a8e426ed721785beb2cfd37c47cd5462697d92ff29dc6943d38/django_simple_history-3.8.0.tar.gz", hash = "sha256:e70d70fb4cc2af60a50904f1420d5a6440d24efddceba3daeff8b02d269ebdf0", size = 233906 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/f0/f09f87199a0b4e21a10314b02e04472a8cc601ead4990e813a0b3cc43f09/django_simple_history-3.8.0-py3-none-any.whl", hash = "sha256:7f8bbdaa5b2c4c1c9a48c89a95ff3389eda6c82cf9de9b09ae99b558205d132f", size = 142593 }, +] + +[[package]] +name = "django-split-settings" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/b9/1c13089454afd7a42d492b8aa8a0c7e49eeca58c0f2ad331f361a067c876/django_split_settings-1.3.2.tar.gz", hash = "sha256:d3975aa3601e37f65c59b9977b6bcb1de8bc27496930054078589c7d56998a9d", size = 5751 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/69/d94db8dac55bcfb6b3243578a3096cfda6c42ea5da292c36919768152ec6/django_split_settings-1.3.2-py3-none-any.whl", hash = "sha256:72bd7dd9f12602585681074d1f859643fb4f6b196b584688fab86bdd73a57dff", size = 6435 }, +] + +[[package]] +name = "django-sslserver" +version = "0.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/97/e4011f3944f83a7d2aaaf893c3689ad70e8d2ae46fb6e14fd0e3b0c6ce0b/django_sslserver-0.22-py3-none-any.whl", hash = "sha256:c598a363d2ccdc2421c08ddb3d8b0973f80e8e47a3a5b74e4a2896f21c2947c5", size = 10295 }, +] + +[[package]] +name = "django-su" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/cf/5d5bdaff569468dba3053a7a623f64fcf5e36d5a936a5617a1c1972a7da4/django-su-1.0.0.tar.gz", hash = "sha256:1a3f98b2f757a3f47e33e90047c0a81cf965805fd7f91f67089292bdd461bd1a", size = 23677 } + +[[package]] +name = "djangorestframework" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/97/112c5a72e6917949b6d8a18ad6c6e72c46da4290c8f36ee5f1c1dcbc9901/djangorestframework-3.16.0.tar.gz", hash = "sha256:f022ff46613584de994c0c6a4aebbace5fd700555fbe9d33b865ebf173eba6c9", size = 1068408 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/3e/2448e93f4f87fc9a9f35e73e3c05669e0edd0c2526834686e949bb1fd303/djangorestframework-3.16.0-py3-none-any.whl", hash = "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", size = 1067305 }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, +] + +[[package]] +name = "doi2bib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bibtexparser" }, + { name = "future" }, + { name = "requests" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/a0/6dd085ee0856e8c0ea39c5851bdf6fc72da392f734dca66b070b89dd1bf8/doi2bib-0.4.0-py3-none-any.whl", hash = "sha256:09401f766b1533c6d17d428ba26c65433009478f1b8d67a2b7f32871e9e8f90d", size = 6047 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "factory-boy" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036 }, +] + +[[package]] +name = "faker" +version = "37.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/b77f42021308ec8b134502343da882c0905d725a4d661c7adeaf7acaf515/faker-37.1.0.tar.gz", hash = "sha256:ad9dc66a3b84888b837ca729e85299a96b58fdaef0323ed0baace93c9614af06", size = 1875707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a1/8936bc8e79af80ca38288dd93ed44ed1f9d63beb25447a4c59e746e01f8d/faker-37.1.0-py3-none-any.whl", hash = "sha256:dc2f730be71cb770e9c715b13374d80dbcee879675121ab51f9683d262ae9a1c", size = 1918783 }, +] + +[[package]] +name = "fontawesome-free" +version = "5.15.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/68/6ed8f4c7efa69a479c8421d9a9d2905132c3869b118554014b8ab7291912/fontawesome_free-5.15.4-py3-none-any.whl", hash = "sha256:5d3d0edbf6ce0f7cd56978a31ea4ea697a8bb28103b5c528b3aa1f0a4474d9a1", size = 20862662 }, +] + +[[package]] +name = "formencode" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/c3/f68b78f6f062bec9db8ab1c78a648cfc8885acded1941903a3218b9b2571/formencode-2.1.1.tar.gz", hash = "sha256:e17f16199d232e54f67912004f3ad333cdbbb81a1a1a10238acf09bab99f9199", size = 277607 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/7e/31b40531ae4bae04f795f849b6428245ee49a49bcd8472f9839fb2602ff4/FormEncode-2.1.1-py3-none-any.whl", hash = "sha256:2194d0c9bfe15c3bf9c331cca0cb73de3746f64d327cff06f097a5abb8552d2d", size = 179554 }, +] + +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326 }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, +] + +[[package]] +name = "griffe" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/08/7df7e90e34d08ad890bd71d7ba19451052f88dc3d2c483d228d1331a4736/griffe-1.7.2.tar.gz", hash = "sha256:98d396d803fab3b680c2608f300872fd57019ed82f0672f5b5323a9ad18c540c", size = 394919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/5e/38b408f41064c9fcdbb0ea27c1bd13a1c8657c4846e04dab9f5ea770602c/griffe-1.7.2-py3-none-any.whl", hash = "sha256:1ed9c2e338a75741fc82083fe5a1bc89cb6142efe126194cc313e34ee6af5423", size = 129187 }, +] + +[[package]] +name = "gssapi" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/2f/fcffb772a00e658f608e657791484e3111a19a722b464e893fef35f35097/gssapi-1.9.0.tar.gz", hash = "sha256:f468fac8f3f5fca8f4d1ca19e3cd4d2e10bd91074e7285464b22715d13548afe", size = 94285 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/47/aa7f24009de06c6a20f7eee2c4accfea615452875dc15c44e5dc3292722d/gssapi-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:261e00ac426d840055ddb2199f4989db7e3ce70fa18b1538f53e392b4823e8f1", size = 708121 }, + { url = "https://files.pythonhosted.org/packages/3a/79/54f11022e09d214b3c037f9fd0c91f0a876b225e884770ef81e7dfbe0903/gssapi-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:14a1ae12fdf1e4c8889206195ba1843de09fe82587fa113112887cd5894587c6", size = 684749 }, + { url = "https://files.pythonhosted.org/packages/18/8c/1ea407d8c60be3e3e3c1d07e7b2ef3c94666e89289b9267b0ca265d2b8aa/gssapi-1.9.0-cp310-cp310-win32.whl", hash = "sha256:2a9c745255e3a810c3e8072e267b7b302de0705f8e9a0f2c5abc92fe12b9475e", size = 778871 }, + { url = "https://files.pythonhosted.org/packages/16/fd/5e073a430ced9babe0accde37c0a645124da475a617dfc741af1fff59e78/gssapi-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:dfc1b4c0bfe9f539537601c9f187edc320daf488f694e50d02d0c1eb37416962", size = 870707 }, + { url = "https://files.pythonhosted.org/packages/d1/14/39d320ac0c8c8ab05f4b48322d38aacb1572f7a51b2c5b908e51f141e367/gssapi-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:67d9be5e34403e47fb5749d5a1ad4e5a85b568e6a9add1695edb4a5b879f7560", size = 707912 }, + { url = "https://files.pythonhosted.org/packages/cc/04/5d46c5b37b96f87a8efb320ab347e876db2493e1aedaa29068936b063097/gssapi-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11e9b92cef11da547fc8c210fa720528fd854038504103c1b15ae2a89dce5fcd", size = 683779 }, + { url = "https://files.pythonhosted.org/packages/05/29/b673b4ed994796e133e3e7eeec0d8991b7dcbed6b0b4bfc95ac0fe3871ff/gssapi-1.9.0-cp311-cp311-win32.whl", hash = "sha256:6c5f8a549abd187687440ec0b72e5b679d043d620442b3637d31aa2766b27cbe", size = 776532 }, + { url = "https://files.pythonhosted.org/packages/31/07/3bb8521da3ca89e202b50f8de46a9e8e793be7f24318a4f7aaaa022d15d1/gssapi-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:59e1a1a9a6c5dc430dc6edfcf497f5ca00cf417015f781c9fac2e85652cd738f", size = 874225 }, + { url = "https://files.pythonhosted.org/packages/98/f1/76477c66aa9f2abc9ab53f936e9085402d6697db93834437e5ee651e5106/gssapi-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b66a98827fbd2864bf8993677a039d7ba4a127ca0d2d9ed73e0ef4f1baa7fd7f", size = 698148 }, + { url = "https://files.pythonhosted.org/packages/96/34/b737e2a46efc63c6a6ad3baf0f3a8484d7698e673874b060a7d52abfa7b4/gssapi-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bddd1cc0c9859c5e0fd96d4d88eb67bd498fdbba45b14cdccfe10bfd329479f", size = 681597 }, + { url = "https://files.pythonhosted.org/packages/71/4b/4cbb8b6bc34ed02591e05af48bd4722facb99b10defc321e3b177114dbeb/gssapi-1.9.0-cp312-cp312-win32.whl", hash = "sha256:10134db0cf01bd7d162acb445762dbcc58b5c772a613e17c46cf8ad956c4dfec", size = 770295 }, + { url = "https://files.pythonhosted.org/packages/c1/73/33a65e9d6c5ea43cdb1ee184b201678adaf3a7bbb4f7a1c7a80195c884ac/gssapi-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:e28c7d45da68b7e36ed3fb3326744bfe39649f16e8eecd7b003b082206039c76", size = 867625 }, + { url = "https://files.pythonhosted.org/packages/bc/bb/6fbbeff852b6502e1d33858865822ab2e0efd84764caad1ce9e3ed182b53/gssapi-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cea344246935b5337e6f8a69bb6cc45619ab3a8d74a29fcb0a39fd1e5843c89c", size = 686934 }, + { url = "https://files.pythonhosted.org/packages/c9/72/89eeb28a2cebe8ec3a560be79e89092913d6cf9dc68b32eb4774e8bac785/gssapi-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a5786bd9fcf435bd0c87dc95ae99ad68cefcc2bcc80c71fef4cb0ccdfb40f1e", size = 672249 }, + { url = "https://files.pythonhosted.org/packages/5f/f7/3d9d4a198e34b844dc4acb25891e2405f8dca069a8f346f51127196436bc/gssapi-1.9.0-cp313-cp313-win32.whl", hash = "sha256:c99959a9dd62358e370482f1691e936cb09adf9a69e3e10d4f6a097240e9fd28", size = 755372 }, + { url = "https://files.pythonhosted.org/packages/67/00/f4be5211d5dd8e9ca551ded3071b1433880729006768123e1ee7b744b1d8/gssapi-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a2e43f50450e81fe855888c53df70cdd385ada979db79463b38031710a12acd9", size = 845005 }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a4406651de13fced3c1ea18ddb52fbd19498deaf62c5d76df2a6bc10a4b0/gssapi-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cbc93fdadd5aab9bae594538b2128044b8c5cdd1424fe015a465d8a8a587411a", size = 712110 }, + { url = "https://files.pythonhosted.org/packages/84/d3/731b84430ed06fbf3f1e07b265a5f6880dfbcf17c665383b5f616307034b/gssapi-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b2a3c0a9beb895942d4b8e31f515e52c17026e55aeaa81ee0df9bbfdac76098", size = 688419 }, + { url = "https://files.pythonhosted.org/packages/e9/b8/8a100d57d9723aba471a557153cb48c517920221e9e5e8ed94046e3652bc/gssapi-1.9.0-cp39-cp39-win32.whl", hash = "sha256:060b58b455d29ab8aca74770e667dca746264bee660ac5b6a7a17476edc2c0b8", size = 781559 }, + { url = "https://files.pythonhosted.org/packages/88/14/2a448c2d4a5a29b6471ef1202fa151cf3a9a5210b913a7b1e9f323d3345f/gssapi-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:11c9fe066edb0fa0785697eb0cecf2719c7ad1d9f2bf27be57b647a617bcfaa5", size = 874036 }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 }, +] + +[[package]] +name = "humanize" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/84/ae8e64a6ffe3291105e9688f4e28fa65eba7924e0fe6053d85ca00556385/humanize-4.12.2.tar.gz", hash = "sha256:ce0715740e9caacc982bb89098182cf8ded3552693a433311c6a4ce6f4e12a2c", size = 80871 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/c7/6f89082f619c76165feb633446bd0fee32b0e0cbad00d22480e5aea26ade/humanize-4.12.2-py3-none-any.whl", hash = "sha256:e4e44dced598b7e03487f3b1c6fd5b1146c30ea55a110e71d5d4bca3e094259e", size = 128305 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "ipaclient" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "ipalib" }, + { name = "ipapython" }, + { name = "qrcode" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/23/dcb83bd7ae4727a0a655c0082566c6c500c215deb110ce55218a17f588b3/ipaclient-4.12.2-py2.py3-none-any.whl", hash = "sha256:f22a02acea8426a3ebd0dbefc1491618f0ce61bc87934eed213cd35175c7b7bf", size = 586136 }, +] + +[[package]] +name = "ipalib" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipaplatform" }, + { name = "ipapython" }, + { name = "netaddr" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, + { name = "six" }, + { name = "urllib3" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/4e/e13b27d2aaa1e07b607a38bf6d06b7b2577a3dab0165abe9b2f9d581f55b/ipalib-4.12.2-py2.py3-none-any.whl", hash = "sha256:203170ff3e17466aa192aeb7da3001326a9f101343b200daba0d0dbf4f72608c", size = 180848 }, +] + +[[package]] +name = "ipaplatform" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "ipapython" }, + { name = "pyasn1" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/0f/ef74842d75dcbf03c455bf4082370d7fbd49700773d4b9455c8a32b0fde4/ipaplatform-4.12.2-py2.py3-none-any.whl", hash = "sha256:3f3ab6aa30869db16c003f329a9ecb7aa10d3b63a6a44e9cc1fb71fe5b2b395a", size = 90946 }, +] + +[[package]] +name = "ipapython" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "cryptography" }, + { name = "dnspython" }, + { name = "gssapi" }, + { name = "ipaplatform" }, + { name = "netaddr" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ed/e9cec0946f8c2cf247b4bb4d231b64b4059a3d3e20e48a25139205650c8c/ipapython-4.12.2-py2.py3-none-any.whl", hash = "sha256:5b95f03d1c83ac0c2ec8d1cc0ca8297e2fbb69aa1d9cddec235c71f954af9e45", size = 123738 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "josepy" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/29/e7c14150f200c5cd49d1a71b413f61b97406f57872ad693857982c0869c9/josepy-2.0.0.tar.gz", hash = "sha256:e7d7acd2fe77435cda76092abe4950bb47b597243a8fb733088615fa6de9ec40", size = 55767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/de/4e1509bdf222503941c6cfcfa79369aa00f385c02e55eef3bfcb84f5e0f8/josepy-2.0.0-py3-none-any.whl", hash = "sha256:eb50ec564b1b186b860c7738769274b97b19b5b831239669c0f3d5c86b62a4c0", size = 28923 }, +] + +[[package]] +name = "kerberos" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f98699a6e806b9d974ea1d3376b91f09edcb90415adbf31e3b56ee99ba64/kerberos-1.3.1.tar.gz", hash = "sha256:cdd046142a4e0060f96a00eb13d82a5d9ebc0f2d7934393ed559bac773460a2c", size = 19126 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/9a/d10386fa7da4588e61fdafdbac2953576f7de6f693d112c74f09a9749fb6/kerberos-1.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2002b3b1541fc51e2c081ee7048f55e5d9ca63dd09f0d7b951c263920db3a0bb", size = 20248 }, +] + +[[package]] +name = "ldap3" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192 }, +] + +[[package]] +name = "markdown" +version = "3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, + { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/44/140469d87379c02f1e1870315f3143718036a983dd0416650827b8883192/mkdocs_autorefs-1.4.1.tar.gz", hash = "sha256:4b5b6235a4becb2b10425c2fa191737e415b37aa3418919db33e5d774c9db079", size = 4131355 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/29/1125f7b11db63e8e32bcfa0752a4eea30abff3ebd0796f808e14571ddaa2/mkdocs_autorefs-1.4.1-py3-none-any.whl", hash = "sha256:9793c5ac06a6ebbe52ec0f8439256e66187badf4b5334b5fde0b128ec134df4f", size = 5782047 }, +] + +[[package]] +name = "mkdocs-awesome-pages-plugin" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mkdocs" }, + { name = "natsort" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/e8/6ae9c18d8174a5d74ce4ade7a7f4c350955063968bc41ff1e5833cff4a2b/mkdocs_awesome_pages_plugin-2.10.1.tar.gz", hash = "sha256:cda2cb88c937ada81a4785225f20ef77ce532762f4500120b67a1433c1cdbb2f", size = 16303 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/61/19fc1e9c579dbfd4e8a402748f1d63cab7aabe8f8d91eb0235e45b32d040/mkdocs_awesome_pages_plugin-2.10.1-py3-none-any.whl", hash = "sha256:c6939dbea37383fc3cf8c0a4e892144ec3d2f8a585e16fdc966b34e7c97042a7", size = 15118 }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, +] + +[[package]] +name = "mkdocs-material" +version = "9.6.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/7e/c65e330e99daa5813e7594e57a09219ad041ed631604a72588ec7c11b34b/mkdocs_material-9.6.11.tar.gz", hash = "sha256:0b7f4a0145c5074cdd692e4362d232fb25ef5b23328d0ec1ab287af77cc0deff", size = 3951595 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/91/79a15a772151aca0d505f901f6bbd4b85ee1fe54100256a6702056bab121/mkdocs_material-9.6.11-py3-none-any.whl", hash = "sha256:47f21ef9cbf4f0ebdce78a2ceecaa5d413581a55141e4464902224ebbc0b1263", size = 8703720 }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, +] + +[[package]] +name = "mkdocstrings" +version = "0.29.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/e8/d22922664a627a0d3d7ff4a6ca95800f5dde54f411982591b4621a76225d/mkdocstrings-0.29.1.tar.gz", hash = "sha256:8722f8f8c5cd75da56671e0a0c1bbed1df9946c0cef74794d6141b34011abd42", size = 1212686 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/14/22533a578bf8b187e05d67e2c1721ce10e3f526610eebaf7a149d557ea7a/mkdocstrings-0.29.1-py3-none-any.whl", hash = "sha256:37a9736134934eea89cbd055a513d40a020d87dfcae9e3052c2a6b8cd4af09b6", size = 1631075 }, +] + +[[package]] +name = "mkdocstrings-python" +version = "1.16.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/c8/600c4201b6b9e72bab16802316d0c90ce04089f8e6bb5e064cd2a5abba7e/mkdocstrings_python-1.16.10.tar.gz", hash = "sha256:f9eedfd98effb612ab4d0ed6dd2b73aff6eba5215e0a65cea6d877717f75502e", size = 205771 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/37/19549c5e0179785308cc988a68e16aa7550e4e270ec8a9878334e86070c6/mkdocstrings_python-1.16.10-py3-none-any.whl", hash = "sha256:63bb9f01f8848a644bdb6289e86dc38ceddeaa63ecc2e291e3b2ca52702a6643", size = 124112 }, +] + +[[package]] +name = "mozilla-django-oidc" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "django" }, + { name = "josepy" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/f9/1ca554a62bf8a4fd31b68209df8603075c2b7436400ea3f7ddd597f204a5/mozilla-django-oidc-4.0.1.tar.gz", hash = "sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918", size = 49027 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d6/2b75bf4e742c54028ae07a1fb5a2624e5a73e9cfd2185c2df0e22cbfe14e/mozilla_django_oidc-4.0.1-py2.py3-none-any.whl", hash = "sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca", size = 29059 }, +] + +[[package]] +name = "mysqlclient" +version = "2.2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/68/810093cb579daae426794bbd9d88aa830fae296e85172d18cb0f0e5dd4bc/mysqlclient-2.2.7.tar.gz", hash = "sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845", size = 91383 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/24/cdaaef42aac7d53c0a01bb638da64961c293b1b6d204efd47400a68029d4/mysqlclient-2.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:2e3c11f7625029d7276ca506f8960a7fd3c5a0a0122c9e7404e6a8fe961b3d22", size = 207748 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3e2de3f93cd60dd63bd229ec3e3b679f682982614bf513d046c2722aa4ce/mysqlclient-2.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687", size = 207745 }, + { url = "https://files.pythonhosted.org/packages/bb/b5/2a8a4bcba3440550f358b839638fe8ec9146fa3c9194890b4998a530c926/mysqlclient-2.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:4b4c0200890837fc64014cc938ef2273252ab544c1b12a6c1d674c23943f3f2e", size = 208032 }, + { url = "https://files.pythonhosted.org/packages/29/01/e80141f1cd0459e4c9a5dd309dee135bbae41d6c6c121252fdd853001a8a/mysqlclient-2.2.7-cp313-cp313-win_amd64.whl", hash = "sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5", size = 208000 }, + { url = "https://files.pythonhosted.org/packages/0e/e0/524b0777524e0d410f71987f556dd6a0e3273fdb06cd6e91e96afade7220/mysqlclient-2.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:199dab53a224357dd0cb4d78ca0e54018f9cee9bf9ec68d72db50e0a23569076", size = 207857 }, + { url = "https://files.pythonhosted.org/packages/16/cc/5b1570be9f8597ee41e2a0bd7b62ba861ec2c81898d9449f3d6bfbe15d29/mysqlclient-2.2.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585", size = 207800 }, + { url = "https://files.pythonhosted.org/packages/20/40/b5d03494c1caa8f4da171db41d8d9d5b0d8959f893761597d97420083362/mysqlclient-2.2.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069", size = 207965 }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268 }, +] + +[[package]] +name = "netaddr" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/90/188b2a69654f27b221fba92fda7217778208532c962509e959a9cee5229d/netaddr-1.3.0.tar.gz", hash = "sha256:5c3c3d9895b551b763779ba7db7a03487dc1f8e3b385af819af341ae9ef6e48a", size = 2260504 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cc/f4fe2c7ce68b92cbf5b2d379ca366e1edae38cccaad00f69f529b460c3ef/netaddr-1.3.0-py3-none-any.whl", hash = "sha256:c2c6a8ebe5554ce33b7d5b3a306b71bbb373e000bbbf2350dd5213cc56e3dbbe", size = 2262023 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "psycopg2" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a9/146b6bdc0d33539a359f5e134ee6dda9173fb8121c5b96af33fa299e50c4/psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716", size = 1024527 }, + { url = "https://files.pythonhosted.org/packages/47/50/c509e56f725fd2572b59b69bd964edaf064deebf1c896b2452f6b46fdfb3/psycopg2-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:c6f7b8561225f9e711a9c47087388a97fdc948211c10a4bccbf0ba68ab7b3b5a", size = 1163735 }, + { url = "https://files.pythonhosted.org/packages/20/a2/c51ca3e667c34e7852157b665e3d49418e68182081060231d514dd823225/psycopg2-2.9.10-cp311-cp311-win32.whl", hash = "sha256:47c4f9875125344f4c2b870e41b6aad585901318068acd01de93f3677a6522c2", size = 1024538 }, + { url = "https://files.pythonhosted.org/packages/33/39/5a9a229bb5414abeb86e33b8fc8143ab0aecce5a7f698a53e31367d30caa/psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4", size = 1163736 }, + { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113 }, + { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951 }, + { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060 }, + { url = "https://files.pythonhosted.org/packages/5f/29/bc9639b9c50abd93a8274fd2deffbf70b2a65aa9e7881e63ea6bc4319e84/psycopg2-2.9.10-cp39-cp39-win32.whl", hash = "sha256:9d5b3b94b79a844a986d029eee38998232451119ad653aea42bb9220a8c5066b", size = 1025259 }, + { url = "https://files.pythonhosted.org/packages/2c/f8/0be7d99d24656b689d83ac167240c3527efb0b161d814fb1dd58329ddf75/psycopg2-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:88138c8dedcbfa96408023ea2b0c369eda40fe5d75002c0964c78f46f11fa442", size = 1163878 }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/44/e6de2fdc880ad0ec7547ca2e087212be815efbc9a425a8d5ba9ede602cbb/pymdown_extensions-10.14.3.tar.gz", hash = "sha256:41e576ce3f5d650be59e900e4ceff231e0aed2a88cf30acaee41e02f063a061b", size = 846846 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/f5/b9e2a42aa8f9e34d52d66de87941ecd236570c7ed2e87775ed23bbe4e224/pymdown_extensions-10.14.3-py3-none-any.whl", hash = "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9", size = 264467 }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, +] + +[[package]] +name = "pytest-django" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "python-ldap" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/8b/1eeb4025dc1d3ac2f16678f38dec9ebdde6271c74955b72db5ce7a4dbfbd/python-ldap-3.4.4.tar.gz", hash = "sha256:7edb0accec4e037797705f3a05cbf36a9fde50d08c8f67f2aef99a2628fab828", size = 377889 } + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911 }, +] + +[[package]] +name = "qrcode" +version = "8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/d4/d222d00f65c81945b55e8f64011c33cb11a2931957ba3e2845fb0874fffe/qrcode-8.1.tar.gz", hash = "sha256:e8df73caf72c3bace3e93d9fa0af5aa78267d4f3f5bc7ab1b208f271605a5e48", size = 41549 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/e6/273de1f5cda537b00bc2947082be747f1d76358db8b945f3a60837bcd0f6/qrcode-8.1-py3-none-any.whl", hash = "sha256:9beba317d793ab8b3838c52af72e603b8ad2599c4e9bbd5c3da37c7dcc13c5cf", size = 45711 }, +] + +[[package]] +name = "redis" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "ruff" +version = "0.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/5b/3ae20f89777115944e89c2d8c2e795dcc5b9e04052f76d5347e35e0da66e/ruff-0.11.4.tar.gz", hash = "sha256:f45bd2fb1a56a5a85fae3b95add03fb185a0b30cf47f5edc92aa0355ca1d7407", size = 3933063 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/db/baee59ac88f57527fcbaad3a7b309994e42329c6bc4d4d2b681a3d7b5426/ruff-0.11.4-py3-none-linux_armv6l.whl", hash = "sha256:d9f4a761ecbde448a2d3e12fb398647c7f0bf526dbc354a643ec505965824ed2", size = 10106493 }, + { url = "https://files.pythonhosted.org/packages/c1/d6/9a0962cbb347f4ff98b33d699bf1193ff04ca93bed4b4222fd881b502154/ruff-0.11.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c1747d903447d45ca3d40c794d1a56458c51e5cc1bc77b7b64bd2cf0b1626cc", size = 10876382 }, + { url = "https://files.pythonhosted.org/packages/3a/8f/62bab0c7d7e1ae3707b69b157701b41c1ccab8f83e8501734d12ea8a839f/ruff-0.11.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:51a6494209cacca79e121e9b244dc30d3414dac8cc5afb93f852173a2ecfc906", size = 10237050 }, + { url = "https://files.pythonhosted.org/packages/09/96/e296965ae9705af19c265d4d441958ed65c0c58fc4ec340c27cc9d2a1f5b/ruff-0.11.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f171605f65f4fc49c87f41b456e882cd0c89e4ac9d58e149a2b07930e1d466f", size = 10424984 }, + { url = "https://files.pythonhosted.org/packages/e5/56/644595eb57d855afed6e54b852e2df8cd5ca94c78043b2f29bdfb29882d5/ruff-0.11.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebf99ea9af918878e6ce42098981fc8c1db3850fef2f1ada69fb1dcdb0f8e79e", size = 9957438 }, + { url = "https://files.pythonhosted.org/packages/86/83/9d3f3bed0118aef3e871ded9e5687fb8c5776bde233427fd9ce0a45db2d4/ruff-0.11.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edad2eac42279df12e176564a23fc6f4aaeeb09abba840627780b1bb11a9d223", size = 11547282 }, + { url = "https://files.pythonhosted.org/packages/40/e6/0c6e4f5ae72fac5ccb44d72c0111f294a5c2c8cc5024afcb38e6bda5f4b3/ruff-0.11.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f103a848be9ff379fc19b5d656c1f911d0a0b4e3e0424f9532ececf319a4296e", size = 12182020 }, + { url = "https://files.pythonhosted.org/packages/b5/92/4aed0e460aeb1df5ea0c2fbe8d04f9725cccdb25d8da09a0d3f5b8764bf8/ruff-0.11.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:193e6fac6eb60cc97b9f728e953c21cc38a20077ed64f912e9d62b97487f3f2d", size = 11679154 }, + { url = "https://files.pythonhosted.org/packages/1b/d3/7316aa2609f2c592038e2543483eafbc62a0e1a6a6965178e284808c095c/ruff-0.11.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7af4e5f69b7c138be8dcffa5b4a061bf6ba6a3301f632a6bce25d45daff9bc99", size = 13905985 }, + { url = "https://files.pythonhosted.org/packages/63/80/734d3d17546e47ff99871f44ea7540ad2bbd7a480ed197fe8a1c8a261075/ruff-0.11.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:126b1bf13154aa18ae2d6c3c5efe144ec14b97c60844cfa6eb960c2a05188222", size = 11348343 }, + { url = "https://files.pythonhosted.org/packages/04/7b/70fc7f09a0161dce9613a4671d198f609e653d6f4ff9eee14d64c4c240fb/ruff-0.11.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8806daaf9dfa881a0ed603f8a0e364e4f11b6ed461b56cae2b1c0cab0645304", size = 10308487 }, + { url = "https://files.pythonhosted.org/packages/1a/22/1cdd62dabd678d75842bf4944fd889cf794dc9e58c18cc547f9eb28f95ed/ruff-0.11.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5d94bb1cc2fc94a769b0eb975344f1b1f3d294da1da9ddbb5a77665feb3a3019", size = 9929091 }, + { url = "https://files.pythonhosted.org/packages/9f/20/40e0563506332313148e783bbc1e4276d657962cc370657b2fff20e6e058/ruff-0.11.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:995071203d0fe2183fc7a268766fd7603afb9996785f086b0d76edee8755c896", size = 10924659 }, + { url = "https://files.pythonhosted.org/packages/b5/41/eef9b7aac8819d9e942f617f9db296f13d2c4576806d604aba8db5a753f1/ruff-0.11.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a37ca937e307ea18156e775a6ac6e02f34b99e8c23fe63c1996185a4efe0751", size = 11428160 }, + { url = "https://files.pythonhosted.org/packages/ff/61/c488943414fb2b8754c02f3879de003e26efdd20f38167ded3fb3fc1cda3/ruff-0.11.4-py3-none-win32.whl", hash = "sha256:0e9365a7dff9b93af933dab8aebce53b72d8f815e131796268709890b4a83270", size = 10311496 }, + { url = "https://files.pythonhosted.org/packages/b6/2b/2a1c8deb5f5dfa3871eb7daa41492c4d2b2824a74d2b38e788617612a66d/ruff-0.11.4-py3-none-win_amd64.whl", hash = "sha256:5a9fa1c69c7815e39fcfb3646bbfd7f528fa8e2d4bebdcf4c2bd0fa037a255fb", size = 11399146 }, + { url = "https://files.pythonhosted.org/packages/4f/03/3aec4846226d54a37822e4c7ea39489e4abd6f88388fba74e3d4abe77300/ruff-0.11.4-py3-none-win_arm64.whl", hash = "sha256:d435db6b9b93d02934cf61ef332e66af82da6d8c69aefdea5994c89997c7a0fc", size = 10450306 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390 }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389 }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020 }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, + { url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390 }, + { url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386 }, + { url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017 }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380 }, + { url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903 }, + { url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +] + +[[package]] +name = "wcmatch" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/ab/b3a52228538ccb983653c446c1656eddf1d5303b9cb8b9aef6a91299f862/wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a", size = 115578 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/df/4ee467ab39cc1de4b852c212c1ed3becfec2e486a51ac1ce0091f85f38d7/wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a", size = 39347 }, +] + +[[package]] +name = "zipp" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, +] From c50f216b1f4fe7b36e4343fe7e353e5d8e36ba9a Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Sun, 1 Jun 2025 00:12:10 -0400 Subject: [PATCH 017/110] Remove setup.py Signed-off-by: Andrew E. Bruno --- setup.py | 84 -------------------------------------------------------- 1 file changed, 84 deletions(-) delete mode 100644 setup.py diff --git a/setup.py b/setup.py deleted file mode 100644 index 7c532f5fe0..0000000000 --- a/setup.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup, find_packages -import coldfront - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name='coldfront', - version=coldfront.VERSION, - description='HPC Resource Allocation System ', - long_description=long_description, - long_description_content_type="text/markdown", - keywords='high-performance-computing resource-allocation', - url='https://coldfront.readthedocs.io', - project_urls={ - 'Bug Tracker': 'https://github.com/ubccr/coldfront/issues', - 'Documentation': 'https://coldfront.readthedocs.io', - 'Source Code': 'https://github.com/ubccr/coldfront', - }, - author='Andrew E. Bruno, Dori Sajdak, Mohammad Zia', - license='GNU General Public License v3 (GPLv3)', - python_requires='>=3.8', - packages=find_packages(), - install_requires=[ - 'arrow==1.3.0', - 'asgiref==3.7.2', - 'bibtexparser==1.4.1', - 'blessed==1.20.0', - 'certifi==2024.2.2', - 'chardet==5.2.0', - 'charset-normalizer==3.3.2', - 'Django==4.2.11', - 'django-crispy-forms==2.1', - 'crispy-bootstrap4==2024.1', - 'django-environ==0.11.2', - 'django-filter==24.2', - 'django-model-utils==4.4.0', - 'django-picklefield==3.1', - 'django-q==1.3.9', - 'django-settings-export==1.2.1', - 'django-simple-history==3.5.0', - 'django-split-settings==1.3.0', - 'django-sslserver==0.22', - 'django-su==1.0.0', - 'djangorestframework==3.15.2', - 'doi2bib==0.4.0', - 'factory-boy==3.3.0', - 'Faker==24.1.0', - 'fontawesome-free==5.15.4', - 'FormEncode==2.1.0', - 'future==1.0.0', - 'humanize==4.9.0', - 'idna==3.6', - 'pyparsing==3.1.2', - 'python-dateutil==2.9.0.post0', - 'python-memcached==1.62', - 'pytz==2024.1', - 'redis==3.5.3', - 'requests==2.31.0', - 'six==1.16.0', - 'sqlparse==0.4.4', - 'text-unidecode==1.3', - 'types-python-dateutil==2.8.19.20240106', - 'typing_extensions==4.10.0', - 'urllib3==2.2.1', - 'wcwidth==0.2.13', - ], - entry_points={ - 'console_scripts': [ - 'coldfront = coldfront:manage', - ], - }, - include_package_data = True, - classifiers=[ - 'Programming Language :: Python :: 3', - 'Framework :: Django :: 3.2', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Topic :: Scientific/Engineering', - 'Topic :: System :: Systems Administration', - 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application', - ] -) From d5a92cf2f0a74d59af4fe276be59e5468485fbb5 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Sun, 1 Jun 2025 11:37:15 -0400 Subject: [PATCH 018/110] Fix linting, formatting, reuse compliant. Signed-off-by: Andrew E. Bruno --- .github/workflows/ci.yml | 30 + LICENSE | 674 ------ LICENSES/AGPL-3.0-or-later.txt | 235 ++ LICENSES/CC-BY-NC-ND-4.0.txt | 155 ++ LICENSES/CC0-1.0.txt | 121 ++ LICENSES/ISC.txt | 8 + LICENSES/MIT.txt | 18 + README.md | 2 +- REUSE.toml | 90 + coldfront/__init__.py | 7 +- coldfront/config/__init__.py | 3 + coldfront/config/auth.py | 40 +- coldfront/config/base.py | 180 +- coldfront/config/core.py | 129 +- coldfront/config/database.py | 22 +- coldfront/config/email.py | 60 +- coldfront/config/env.py | 14 +- coldfront/config/logging.py | 44 +- coldfront/config/plugins/__init__.py | 3 + coldfront/config/plugins/api.py | 25 +- coldfront/config/plugins/freeipa.py | 18 +- coldfront/config/plugins/iquota.py | 14 +- coldfront/config/plugins/ldap.py | 66 +- coldfront/config/plugins/ldap_user_search.py | 31 +- coldfront/config/plugins/openid.py | 46 +- coldfront/config/plugins/slurm.py | 14 +- coldfront/config/plugins/system_monitor.py | 16 +- coldfront/config/plugins/xdmod.py | 12 +- coldfront/config/settings.py | 49 +- coldfront/config/urls.py | 51 +- coldfront/config/wsgi.py | 4 + coldfront/core/__init__.py | 3 + coldfront/core/allocation/__init__.py | 3 + coldfront/core/allocation/admin.py | 331 ++- coldfront/core/allocation/apps.py | 6 +- coldfront/core/allocation/forms.py | 208 +- .../core/allocation/management/__init__.py | 3 + .../management/commands/__init__.py | 3 + .../commands/add_allocation_defaults.py | 101 +- .../enable_change_requests_globally.py | 11 +- .../allocation/migrations/0001_initial.py | 693 ++++-- .../migrations/0002_auto_20190718_1451.py | 31 +- .../migrations/0003_auto_20191018_1049.py | 15 +- .../migrations/0004_auto_20211102_1017.py | 272 ++- .../migrations/0005_auto_20211117_1413.py | 15 +- .../core/allocation/migrations/__init__.py | 3 + coldfront/core/allocation/models.py | 367 ++-- coldfront/core/allocation/signals.py | 18 +- coldfront/core/allocation/tasks.py | 276 +-- coldfront/core/allocation/test_models.py | 11 +- coldfront/core/allocation/test_views.py | 177 +- coldfront/core/allocation/urls.py | 108 +- coldfront/core/allocation/utils.py | 35 +- coldfront/core/allocation/views.py | 1894 +++++++++-------- coldfront/core/attribute_expansion.py | 248 +-- coldfront/core/field_of_science/__init__.py | 6 +- coldfront/core/field_of_science/admin.py | 20 +- coldfront/core/field_of_science/apps.py | 8 +- .../field_of_science/management/__init__.py | 3 + .../management/commands/__init__.py | 3 + .../commands/import_field_of_science_data.py | 26 +- .../migrations/0001_initial.py | 47 +- .../0002_alter_fieldofscience_description.py | 11 +- .../field_of_science/migrations/__init__.py | 3 + coldfront/core/field_of_science/models.py | 14 +- coldfront/core/field_of_science/tests.py | 38 +- coldfront/core/field_of_science/views.py | 4 +- coldfront/core/grant/__init__.py | 3 + coldfront/core/grant/admin.py | 64 +- coldfront/core/grant/apps.py | 6 +- coldfront/core/grant/forms.py | 36 +- coldfront/core/grant/management/__init__.py | 3 + .../grant/management/commands/__init__.py | 3 + .../commands/add_default_grant_options.py | 35 +- .../core/grant/migrations/0001_initial.py | 272 ++- .../migrations/0002_auto_20230406_1310.py | 15 +- coldfront/core/grant/migrations/__init__.py | 3 + coldfront/core/grant/models.py | 74 +- coldfront/core/grant/tests.py | 93 +- coldfront/core/grant/urls.py | 18 +- coldfront/core/grant/views.py | 279 +-- coldfront/core/portal/__init__.py | 3 + coldfront/core/portal/admin.py | 24 +- coldfront/core/portal/apps.py | 6 +- coldfront/core/portal/migrations/__init__.py | 3 + coldfront/core/portal/models.py | 4 +- .../core/portal/templatetags/__init__.py | 3 + .../core/portal/templatetags/portal_tags.py | 4 + coldfront/core/portal/tests.py | 4 +- coldfront/core/portal/utils.py | 87 +- coldfront/core/portal/views.py | 272 ++- coldfront/core/project/__init__.py | 6 +- coldfront/core/project/admin.py | 274 ++- coldfront/core/project/apps.py | 6 +- coldfront/core/project/forms.py | 154 +- coldfront/core/project/management/__init__.py | 3 + .../project/management/commands/__init__.py | 3 + .../commands/add_default_project_choices.py | 60 +- .../management/commands/add_project_codes.py | 42 +- .../core/project/migrations/0001_initial.py | 552 +++-- .../0002_projectusermessage_is_private.py | 11 +- .../migrations/0003_auto_20221013_1215.py | 331 ++- .../migrations/0004_auto_20230406_1133.py | 19 +- ...lter_historicalproject_options_and_more.py | 97 +- coldfront/core/project/migrations/__init__.py | 3 + coldfront/core/project/models.py | 196 +- coldfront/core/project/signals.py | 14 +- coldfront/core/project/test_views.py | 166 +- coldfront/core/project/tests.py | 119 +- coldfront/core/project/urls.py | 76 +- coldfront/core/project/utils.py | 34 +- coldfront/core/project/views.py | 1361 ++++++------ coldfront/core/publication/__init__.py | 3 + coldfront/core/publication/admin.py | 13 +- coldfront/core/publication/apps.py | 6 +- coldfront/core/publication/forms.py | 19 +- .../core/publication/management/__init__.py | 3 + .../management/commands/__init__.py | 3 + .../add_default_publication_sources.py | 12 +- .../publication/migrations/0001_initial.py | 161 +- .../migrations/0002_auto_20191223_1115.py | 19 +- .../migrations/0003_auto_20200104_1700.py | 15 +- .../0004_add_manual_publication_source.py | 16 +- .../core/publication/migrations/__init__.py | 3 + coldfront/core/publication/models.py | 21 +- coldfront/core/publication/tests.py | 108 +- coldfront/core/publication/urls.py | 36 +- coldfront/core/publication/views.py | 401 ++-- coldfront/core/research_output/__init__.py | 3 + coldfront/core/research_output/admin.py | 23 +- coldfront/core/research_output/apps.py | 6 +- coldfront/core/research_output/forms.py | 8 +- .../migrations/0001_initial.py | 109 +- .../research_output/migrations/__init__.py | 3 + coldfront/core/research_output/models.py | 14 +- coldfront/core/research_output/tests.py | 30 +- coldfront/core/research_output/urls.py | 16 +- coldfront/core/research_output/views.py | 63 +- coldfront/core/resource/__init__.py | 3 + coldfront/core/resource/admin.py | 116 +- coldfront/core/resource/apps.py | 6 +- coldfront/core/resource/forms.py | 53 +- .../core/resource/management/__init__.py | 3 + .../resource/management/commands/__init__.py | 3 + .../commands/add_resource_defaults.py | 86 +- .../core/resource/migrations/0001_initial.py | 440 +++- .../migrations/0002_auto_20191017_1141.py | 15 +- .../core/resource/migrations/__init__.py | 3 + coldfront/core/resource/models.py | 149 +- coldfront/core/resource/tests.py | 4 +- coldfront/core/resource/urls.py | 26 +- coldfront/core/resource/views.py | 257 ++- coldfront/core/test_helpers/decorators.py | 8 +- coldfront/core/test_helpers/factories.py | 203 +- coldfront/core/test_helpers/utils.py | 13 +- coldfront/core/user/__init__.py | 6 +- coldfront/core/user/admin.py | 15 +- coldfront/core/user/apps.py | 10 +- coldfront/core/user/forms.py | 28 +- .../core/user/migrations/0001_initial.py | 18 +- coldfront/core/user/migrations/__init__.py | 3 + coldfront/core/user/models.py | 10 +- coldfront/core/user/signals.py | 5 +- coldfront/core/user/tests.py | 27 +- coldfront/core/user/urls.py | 42 +- coldfront/core/user/utils.py | 69 +- coldfront/core/user/views.py | 229 +- coldfront/core/utils/__init__.py | 3 + coldfront/core/utils/admin.py | 4 +- coldfront/core/utils/apps.py | 8 +- coldfront/core/utils/common.py | 15 +- coldfront/core/utils/fixtures/__init__.py | 3 + coldfront/core/utils/mail.py | 163 +- coldfront/core/utils/management/__init__.py | 3 + .../utils/management/commands/__init__.py | 3 + .../commands/add_scheduled_tasks.py | 33 +- .../management/commands/initial_setup.py | 48 +- .../management/commands/load_test_data.py | 743 ++++--- ..._users_in_project_but_not_in_allocation.py | 29 +- coldfront/core/utils/migrations/__init__.py | 3 + coldfront/core/utils/mixins/__init__.py | 3 + coldfront/core/utils/mixins/views.py | 33 +- coldfront/core/utils/models.py | 4 +- coldfront/core/utils/templatetags/__init__.py | 3 + .../core/utils/templatetags/common_tags.py | 37 +- coldfront/core/utils/tests.py | 4 +- coldfront/core/utils/validate.py | 33 +- coldfront/core/utils/views.py | 4 +- coldfront/plugins/__init__.py | 3 + coldfront/plugins/api/__init__.py | 3 + coldfront/plugins/api/apps.py | 20 +- coldfront/plugins/api/serializers.py | 119 +- coldfront/plugins/api/tests.py | 35 +- coldfront/plugins/api/urls.py | 25 +- coldfront/plugins/api/views.py | 204 +- coldfront/plugins/freeipa/__init__.py | 6 +- coldfront/plugins/freeipa/apps.py | 13 +- .../plugins/freeipa/management/__init__.py | 3 + .../freeipa/management/commands/__init__.py | 3 + .../management/commands/freeipa_check.py | 152 +- .../commands/freeipa_expire_users.py | 105 +- coldfront/plugins/freeipa/search.py | 67 +- coldfront/plugins/freeipa/signals.py | 25 +- coldfront/plugins/freeipa/tasks.py | 108 +- coldfront/plugins/freeipa/utils.py | 35 +- coldfront/plugins/iquota/__init__.py | 3 + coldfront/plugins/iquota/admin.py | 4 +- coldfront/plugins/iquota/apps.py | 6 +- coldfront/plugins/iquota/exceptions.py | 7 + .../plugins/iquota/migrations/__init__.py | 3 + coldfront/plugins/iquota/urls.py | 6 +- coldfront/plugins/iquota/utils.py | 77 +- coldfront/plugins/iquota/views.py | 13 +- .../plugins/ldap_user_search/__init__.py | 3 + coldfront/plugins/ldap_user_search/admin.py | 4 +- coldfront/plugins/ldap_user_search/apps.py | 6 +- .../ldap_user_search/migrations/__init__.py | 3 + coldfront/plugins/ldap_user_search/models.py | 4 +- coldfront/plugins/ldap_user_search/tests.py | 4 +- coldfront/plugins/ldap_user_search/utils.py | 93 +- coldfront/plugins/ldap_user_search/views.py | 4 +- coldfront/plugins/mokey_oidc/__init__.py | 3 + coldfront/plugins/mokey_oidc/apps.py | 6 +- coldfront/plugins/mokey_oidc/auth.py | 51 +- .../plugins/mokey_oidc/migrations/__init__.py | 3 + coldfront/plugins/slurm/__init__.py | 6 +- coldfront/plugins/slurm/apps.py | 6 +- coldfront/plugins/slurm/associations.py | 129 +- .../plugins/slurm/management/__init__.py | 3 + .../slurm/management/commands/__init__.py | 3 + .../slurm/management/commands/slurm_check.py | 188 +- .../slurm/management/commands/slurm_dump.py | 23 +- .../plugins/slurm/tests/test_associations.py | 49 +- coldfront/plugins/slurm/utils.py | 94 +- coldfront/plugins/system_monitor/__init__.py | 3 + coldfront/plugins/system_monitor/utils.py | 102 +- coldfront/plugins/xdmod/__init__.py | 6 +- coldfront/plugins/xdmod/apps.py | 6 +- .../plugins/xdmod/management/__init__.py | 3 + .../xdmod/management/commands/__init__.py | 3 + .../xdmod/management/commands/xdmod_usage.py | 474 +++-- coldfront/plugins/xdmod/utils.py | 172 +- docs/pages/index.md | 4 +- manage.py | 5 + pyproject.toml | 19 +- uv.lock | 99 +- 246 files changed, 11053 insertions(+), 7753 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 LICENSE create mode 100644 LICENSES/AGPL-3.0-or-later.txt create mode 100644 LICENSES/CC-BY-NC-ND-4.0.txt create mode 100644 LICENSES/CC0-1.0.txt create mode 100644 LICENSES/ISC.txt create mode 100644 LICENSES/MIT.txt create mode 100644 REUSE.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..09b2603a25 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + env: + PLUGIN_API: True + + steps: + - uses: actions/checkout@v4 + + - name: Install uv and set the python version + uses: astral-sh/setup-uv@v5 + + - name: Install the project + run: uv sync --locked --dev + + - name: Check for lint violations + run: uv run ruff check + + - name: Check formatting + run: uv run ruff format --check + + - name: Run tests + run: uv run coldfront test diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 94a9ed024d..0000000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt new file mode 100644 index 0000000000..0c97efd25b --- /dev/null +++ b/LICENSES/AGPL-3.0-or-later.txt @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/LICENSES/CC-BY-NC-ND-4.0.txt b/LICENSES/CC-BY-NC-ND-4.0.txt new file mode 100644 index 0000000000..6f2a684c1a --- /dev/null +++ b/LICENSES/CC-BY-NC-ND-4.0.txt @@ -0,0 +1,155 @@ +Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International + + Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. + +Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. + +Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. + +Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. + +Section 1 – Definitions. + + a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. + + b. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. + + c. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. + + d. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. + + e. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. + + f. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. + + g. Licensor means the individual(s) or entity(ies) granting rights under this Public License. + + h. NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange. + + i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. + +Section 2 – Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: + + A. reproduce and Share the Licensed Material, in whole or in part, for NonCommercial purposes only; and + + B. produce and reproduce, but not Share, Adapted Material for NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. + + 3. Term. The term of this Public License is specified in Section 6(a). + + 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. + + 5. Downstream recipients. + A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. + + B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. + + 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this Public License. + + 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties, including when the Licensed Material is used other than for NonCommercial purposes. + +Section 3 – License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material, You must: + + A. retain the following if it is supplied by the Licensor with the Licensed Material: + + i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of warranties; + + v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; + + B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and + + C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. + + For the avoidance of doubt, You do not have permission under this Public License to Share Adapted Material. + + 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. + + 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + +Section 4 – Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database for NonCommercial purposes only and provided You do not Share Adapted Material; + + b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. +For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. + +Section 5 – Disclaimer of Warranties and Limitation of Liability. + + a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. + + b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. + + c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + +Section 6 – Term and Termination. + + a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +Section 7 – Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. + +Section 8 – Interpretation. + + a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. + + c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. + + d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. + +Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000000..0e259d42c9 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/LICENSES/ISC.txt b/LICENSES/ISC.txt new file mode 100644 index 0000000000..b9c199c98f --- /dev/null +++ b/LICENSES/ISC.txt @@ -0,0 +1,8 @@ +ISC License: + +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC") +Copyright (c) 1995-2003 by Internet Software Consortium + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000000..d817195dad --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index f3cf1dbb43..9729f4df83 100644 --- a/README.md +++ b/README.md @@ -47,4 +47,4 @@ subscribe ccr-open-coldfront-list@listserv.buffalo.edu first_name last_name ## License -ColdFront is released under the GPLv3 license. See the LICENSE file. +ColdFront is released under the AGPLv3 license. See REUSE.toml. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000000..65525be752 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,90 @@ +version = 1 +SPDX-PackageName = "coldfront" +SPDX-PackageDownloadLocation = "https://github.com/ubccr/coldfront" + +[[annotations]] +path = [ + ".python-version", + "Dockerfile", + "MANIFEST.in", + "docs/pages/.pages", + "pyproject.toml", + "**.yaml", + "**.yml", + "**.md", + "**.txt", + "**.csv", + "**.sql", + "**.gitignore", + "**.html", + "**.css", + "**.webmanifest", +] +SPDX-FileCopyrightText = "(C) ColdFront Authors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + +[[annotations]] +path = [ + "docs/pages/images/*", + "**.ico", +] +SPDX-FileCopyrightText = "(C) ColdFront Authors" +SPDX-License-Identifier = "CC-BY-NC-ND-4.0" + +[[annotations]] +path = [ + "uv.lock", + "coldfront/**.png", + "coldfront/**.jpg", +] +SPDX-FileCopyrightText = "NONE" +SPDX-License-Identifier = "CC0-1.0" + +[[annotations]] +path = [ + "coldfront/static/bootstrap/*", +] +SPDX-FileCopyrightText = "Copyright (c) 2011-2025 The Bootstrap Authors" +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = [ + "coldfront/static/c3js/*", +] +SPDX-FileCopyrightText = "Copyright (c) 2013 Masayuki Tanaka" +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = [ + "coldfront/static/datatable/*", +] +SPDX-FileCopyrightText = "Copyright (C) 2008-2025, SpryMedia Ltd." +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = [ + "coldfront/static/flatpickr/*", +] +SPDX-FileCopyrightText = "Copyright (c) 2017 Gregory Petrosyan" +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = [ + "coldfront/static/jquery/*", +] +SPDX-FileCopyrightText = "Copyright OpenJS Foundation and other contributors" +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = [ + "coldfront/static/select2/*", +] +SPDX-FileCopyrightText = "Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors" +SPDX-License-Identifier = "MIT" + +[[annotations]] +path = [ + "coldfront/static/d3/*", +] +SPDX-FileCopyrightText = "Copyright 2010-2023 Mike Bostock" +SPDX-License-Identifier = "ISC" diff --git a/coldfront/__init__.py b/coldfront/__init__.py index 912fb13f1c..104addcbbb 100644 --- a/coldfront/__init__.py +++ b/coldfront/__init__.py @@ -1,11 +1,16 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os import sys -__version__ = '1.1.6' +__version__ = "1.1.6" VERSION = __version__ def manage(): os.environ.setdefault("DJANGO_SETTINGS_MODULE", "coldfront.config.settings") from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) diff --git a/coldfront/config/__init__.py b/coldfront/config/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/config/__init__.py +++ b/coldfront/config/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/config/auth.py b/coldfront/config/auth.py index 6e7f865bd0..3dc2d9e27e 100644 --- a/coldfront/config/auth.py +++ b/coldfront/config/auth.py @@ -1,29 +1,39 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import AUTHENTICATION_BACKENDS, INSTALLED_APPS, TEMPLATES from coldfront.config.env import ENV -from coldfront.config.base import INSTALLED_APPS, AUTHENTICATION_BACKENDS, TEMPLATES -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # ColdFront default authentication settings -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ AUTHENTICATION_BACKENDS += [ - 'django.contrib.auth.backends.ModelBackend', + "django.contrib.auth.backends.ModelBackend", ] -LOGIN_URL = '/user/login' -LOGIN_REDIRECT_URL = '/' -LOGOUT_REDIRECT_URL = ENV.str('LOGOUT_REDIRECT_URL', LOGIN_URL) +LOGIN_URL = "/user/login" +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = ENV.str("LOGOUT_REDIRECT_URL", LOGIN_URL) SU_LOGIN_CALLBACK = "coldfront.core.utils.common.su_login_callback" SU_LOGOUT_REDIRECT_URL = "/admin/auth/user/" -SESSION_COOKIE_AGE = ENV.int('SESSION_INACTIVITY_TIMEOUT', default=60 * 60) +SESSION_COOKIE_AGE = ENV.int("SESSION_INACTIVITY_TIMEOUT", default=60 * 60) SESSION_SAVE_EVERY_REQUEST = True -SESSION_COOKIE_SAMESITE = 'Strict' +SESSION_COOKIE_SAMESITE = "Strict" SESSION_COOKIE_SECURE = True -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable administrators to login as other users -#------------------------------------------------------------------------------ -if ENV.bool('ENABLE_SU', default=True): - AUTHENTICATION_BACKENDS += ['django_su.backends.SuBackend', ] - INSTALLED_APPS.insert(0, 'django_su') - TEMPLATES[0]['OPTIONS']['context_processors'].extend(['django_su.context_processors.is_su', ]) +# ------------------------------------------------------------------------------ +if ENV.bool("ENABLE_SU", default=True): + AUTHENTICATION_BACKENDS += [ + "django_su.backends.SuBackend", + ] + INSTALLED_APPS.insert(0, "django_su") + TEMPLATES[0]["OPTIONS"]["context_processors"].extend( + [ + "django_su.context_processors.is_su", + ] + ) diff --git a/coldfront/config/base.py b/coldfront/config/base.py index c37af2d230..3727357205 100644 --- a/coldfront/config/base.py +++ b/coldfront/config/base.py @@ -1,165 +1,169 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + """ Base Django settings for ColdFront project. """ + +import importlib.util import os import sys -import coldfront + from django.core.exceptions import ImproperlyConfigured from django.core.management.utils import get_random_secret_key + +import coldfront from coldfront.config.env import ENV, PROJECT_ROOT -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Base Django config for ColdFront -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ VERSION = coldfront.VERSION BASE_DIR = PROJECT_ROOT() -ALLOWED_HOSTS = ENV.list('ALLOWED_HOSTS', default=['*']) -DEBUG = ENV.bool('DEBUG', default=False) -WSGI_APPLICATION = 'coldfront.config.wsgi.application' -ROOT_URLCONF = 'coldfront.config.urls' +ALLOWED_HOSTS = ENV.list("ALLOWED_HOSTS", default=["*"]) +DEBUG = ENV.bool("DEBUG", default=False) +WSGI_APPLICATION = "coldfront.config.wsgi.application" +ROOT_URLCONF = "coldfront.config.urls" -SECRET_KEY = ENV.str('SECRET_KEY', default='') +SECRET_KEY = ENV.str("SECRET_KEY", default="") if len(SECRET_KEY) == 0: SECRET_KEY = get_random_secret_key() -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Locale settings -#------------------------------------------------------------------------------ -LANGUAGE_CODE = ENV.str('LANGUAGE_CODE', default='en-us') -TIME_ZONE = ENV.str('TIME_ZONE', default='America/New_York') +# ------------------------------------------------------------------------------ +LANGUAGE_CODE = ENV.str("LANGUAGE_CODE", default="en-us") +TIME_ZONE = ENV.str("TIME_ZONE", default="America/New_York") USE_I18N = True USE_L10N = True USE_TZ = True -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django Apps -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # See: https://docs.djangoproject.com/en/3.2/releases/3.2/#customizing-type-of-auto-created-primary-keys # We should change this to BigAutoField at some point -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.humanize', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", ] # Additional Apps # Hack to fix fontawesome. Will be fixed in version 6 -sys.modules['fontawesome_free'] = __import__('fontawesome-free') +sys.modules["fontawesome_free"] = __import__("fontawesome-free") INSTALLED_APPS += [ - 'crispy_forms', - 'crispy_bootstrap4', - 'django_q', - 'simple_history', - 'fontawesome_free', + "crispy_forms", + "crispy_bootstrap4", + "django_q", + "simple_history", + "fontawesome_free", ] -if DEBUG: - try: - import sslserver - INSTALLED_APPS += [ - 'sslserver', - ] - except ImportError: - pass +if DEBUG and importlib.util.find_spec("sslserver") is not None: + INSTALLED_APPS += [ + "sslserver", + ] # ColdFront Apps INSTALLED_APPS += [ - 'coldfront.core.user', - 'coldfront.core.field_of_science', - 'coldfront.core.utils', - 'coldfront.core.portal', - 'coldfront.core.project', - 'coldfront.core.resource', - 'coldfront.core.allocation', - 'coldfront.core.grant', - 'coldfront.core.publication', - 'coldfront.core.research_output', + "coldfront.core.user", + "coldfront.core.field_of_science", + "coldfront.core.utils", + "coldfront.core.portal", + "coldfront.core.project", + "coldfront.core.resource", + "coldfront.core.allocation", + "coldfront.core.grant", + "coldfront.core.publication", + "coldfront.core.research_output", ] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django Middleware -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ 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', - 'simple_history.middleware.HistoryRequestMiddleware', + "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", + "simple_history.middleware.HistoryRequestMiddleware", ] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django authentication backend. See auth.py -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ AUTHENTICATION_BACKENDS = [] -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django Q -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ Q_CLUSTER = { - 'timeout': ENV.int('Q_CLUSTER_TIMEOUT', default=120), - 'retry': ENV.int('Q_CLUSTER_RETRY', default=120), + "timeout": ENV.int("Q_CLUSTER_TIMEOUT", default=120), + "retry": ENV.int("Q_CLUSTER_RETRY", default=120), } -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Django template and site settings -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - PROJECT_ROOT('site/templates'), - '/usr/share/coldfront/site/templates', - PROJECT_ROOT('coldfront/templates'), + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + PROJECT_ROOT("site/templates"), + "/usr/share/coldfront/site/templates", + PROJECT_ROOT("coldfront/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', - 'django_settings_export.settings_export', + "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", + "django_settings_export.settings_export", ], }, }, ] # Add local site templates files if set -SITE_TEMPLATES = ENV.str('SITE_TEMPLATES', default='') +SITE_TEMPLATES = ENV.str("SITE_TEMPLATES", default="") if len(SITE_TEMPLATES) > 0: if os.path.isdir(SITE_TEMPLATES): - TEMPLATES[0]['DIRS'].insert(0, SITE_TEMPLATES) + TEMPLATES[0]["DIRS"].insert(0, SITE_TEMPLATES) else: - raise ImproperlyConfigured('SITE_TEMPLATES should be a path to a directory') + raise ImproperlyConfigured("SITE_TEMPLATES should be a path to a directory") -CRISPY_TEMPLATE_PACK = 'bootstrap4' +CRISPY_TEMPLATE_PACK = "bootstrap4" SETTINGS_EXPORT = [] -STATIC_URL = '/static/' -STATIC_ROOT = ENV.str('STATIC_ROOT', default=PROJECT_ROOT('static_root')) +STATIC_URL = "/static/" +STATIC_ROOT = ENV.str("STATIC_ROOT", default=PROJECT_ROOT("static_root")) STATICFILES_DIRS = [ - PROJECT_ROOT('coldfront/static'), + PROJECT_ROOT("coldfront/static"), ] # Add local site static files if set -SITE_STATIC = ENV.str('SITE_STATIC', default='') +SITE_STATIC = ENV.str("SITE_STATIC", default="") if len(SITE_STATIC) > 0: if os.path.isdir(SITE_STATIC): STATICFILES_DIRS.insert(0, SITE_STATIC) else: - raise ImproperlyConfigured('SITE_STATIC should be a path to a directory') + raise ImproperlyConfigured("SITE_STATIC should be a path to a directory") # Add system site static files -if os.path.isdir('/usr/share/coldfront/site/static'): - STATICFILES_DIRS.insert(0, '/usr/share/coldfront/site/static') +if os.path.isdir("/usr/share/coldfront/site/static"): + STATICFILES_DIRS.insert(0, "/usr/share/coldfront/site/static") diff --git a/coldfront/config/core.py b/coldfront/config/core.py index 3b7c5a3746..c5dac7ebd0 100644 --- a/coldfront/config/core.py +++ b/coldfront/config/core.py @@ -1,84 +1,99 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import SETTINGS_EXPORT from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Advanced ColdFront configurations -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # General Center Information -#------------------------------------------------------------------------------ -CENTER_NAME = ENV.str('CENTER_NAME', default='HPC Center') -CENTER_HELP_URL = ENV.str('CENTER_HELP_URL', default='') -CENTER_PROJECT_RENEWAL_HELP_URL = ENV.str('CENTER_PROJECT_RENEWAL_HELP_URL', default='') -CENTER_BASE_URL = ENV.str('CENTER_BASE_URL', default='') +# ------------------------------------------------------------------------------ +CENTER_NAME = ENV.str("CENTER_NAME", default="HPC Center") +CENTER_HELP_URL = ENV.str("CENTER_HELP_URL", default="") +CENTER_PROJECT_RENEWAL_HELP_URL = ENV.str("CENTER_PROJECT_RENEWAL_HELP_URL", default="") +CENTER_BASE_URL = ENV.str("CENTER_BASE_URL", default="") -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable Research Outputs, Grants, Publications -#------------------------------------------------------------------------------ -RESEARCH_OUTPUT_ENABLE = ENV.bool('RESEARCH_OUTPUT_ENABLE', default=True) -GRANT_ENABLE = ENV.bool('GRANT_ENABLE', default=True) -PUBLICATION_ENABLE = ENV.bool('PUBLICATION_ENABLE', default=True) +# ------------------------------------------------------------------------------ +RESEARCH_OUTPUT_ENABLE = ENV.bool("RESEARCH_OUTPUT_ENABLE", default=True) +GRANT_ENABLE = ENV.bool("GRANT_ENABLE", default=True) +PUBLICATION_ENABLE = ENV.bool("PUBLICATION_ENABLE", default=True) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable Project Review -#------------------------------------------------------------------------------ -PROJECT_ENABLE_PROJECT_REVIEW = ENV.bool('PROJECT_ENABLE_PROJECT_REVIEW', default=True) +# ------------------------------------------------------------------------------ +PROJECT_ENABLE_PROJECT_REVIEW = ENV.bool("PROJECT_ENABLE_PROJECT_REVIEW", default=True) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable EULA force agreement -#------------------------------------------------------------------------------ -ALLOCATION_EULA_ENABLE = ENV.bool('ALLOCATION_EULA_ENABLE', default=False) +# ------------------------------------------------------------------------------ +ALLOCATION_EULA_ENABLE = ENV.bool("ALLOCATION_EULA_ENABLE", default=False) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Allocation related -#------------------------------------------------------------------------------ -ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = ENV.bool('ALLOCATION_ENABLE_CHANGE_REQUESTS', default=True) -ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = ENV.list('ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS', cast=int, default=[30, 60, 90]) -ALLOCATION_ENABLE_ALLOCATION_RENEWAL = ENV.bool('ALLOCATION_ENABLE_ALLOCATION_RENEWAL', default=True) -ALLOCATION_FUNCS_ON_EXPIRE = ['coldfront.core.allocation.utils.test_allocation_function', ] +# ------------------------------------------------------------------------------ +ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = ENV.bool("ALLOCATION_ENABLE_CHANGE_REQUESTS", default=True) +ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = ENV.list( + "ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS", cast=int, default=[30, 60, 90] +) +ALLOCATION_ENABLE_ALLOCATION_RENEWAL = ENV.bool("ALLOCATION_ENABLE_ALLOCATION_RENEWAL", default=True) +ALLOCATION_FUNCS_ON_EXPIRE = [ + "coldfront.core.allocation.utils.test_allocation_function", +] # This is in days -ALLOCATION_DEFAULT_ALLOCATION_LENGTH = ENV.int('ALLOCATION_DEFAULT_ALLOCATION_LENGTH', default=365) +ALLOCATION_DEFAULT_ALLOCATION_LENGTH = ENV.int("ALLOCATION_DEFAULT_ALLOCATION_LENGTH", default=365) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Allow user to select account name for allocation -#------------------------------------------------------------------------------ -ALLOCATION_ACCOUNT_ENABLED = ENV.bool('ALLOCATION_ACCOUNT_ENABLED', default=False) -ALLOCATION_ACCOUNT_MAPPING = ENV.dict('ALLOCATION_ACCOUNT_MAPPING', default={}) +# ------------------------------------------------------------------------------ +ALLOCATION_ACCOUNT_ENABLED = ENV.bool("ALLOCATION_ACCOUNT_ENABLED", default=False) +ALLOCATION_ACCOUNT_MAPPING = ENV.dict("ALLOCATION_ACCOUNT_MAPPING", default={}) SETTINGS_EXPORT += [ - 'ALLOCATION_ACCOUNT_ENABLED', - 'CENTER_HELP_URL', - 'ALLOCATION_EULA_ENABLE', - 'RESEARCH_OUTPUT_ENABLE', - 'GRANT_ENABLE', - 'PUBLICATION_ENABLE', + "ALLOCATION_ACCOUNT_ENABLED", + "CENTER_HELP_URL", + "ALLOCATION_EULA_ENABLE", + "RESEARCH_OUTPUT_ENABLE", + "GRANT_ENABLE", + "PUBLICATION_ENABLE", ] -ADMIN_COMMENTS_SHOW_EMPTY = ENV.bool('ADMIN_COMMENTS_SHOW_EMPTY', default=True) +ADMIN_COMMENTS_SHOW_EMPTY = ENV.bool("ADMIN_COMMENTS_SHOW_EMPTY", default=True) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # List of Allocation Attributes to display on view page -#------------------------------------------------------------------------------ -ALLOCATION_ATTRIBUTE_VIEW_LIST = ENV.list('ALLOCATION_ATTRIBUTE_VIEW_LIST', default=['slurm_account_name', 'freeipa_group', 'Cloud Account Name', ]) - -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +ALLOCATION_ATTRIBUTE_VIEW_LIST = ENV.list( + "ALLOCATION_ATTRIBUTE_VIEW_LIST", + default=[ + "slurm_account_name", + "freeipa_group", + "Cloud Account Name", + ], +) + +# ------------------------------------------------------------------------------ # Enable invoice functionality -#------------------------------------------------------------------------------ -INVOICE_ENABLED = ENV.bool('INVOICE_ENABLED', default=True) +# ------------------------------------------------------------------------------ +INVOICE_ENABLED = ENV.bool("INVOICE_ENABLED", default=True) # Override default 'Pending Payment' status -INVOICE_DEFAULT_STATUS = ENV.str('INVOICE_DEFAULT_STATUS', default='New') +INVOICE_DEFAULT_STATUS = ENV.str("INVOICE_DEFAULT_STATUS", default="New") -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable Open OnDemand integration -#------------------------------------------------------------------------------ -ONDEMAND_URL = ENV.str('ONDEMAND_URL', default=None) +# ------------------------------------------------------------------------------ +ONDEMAND_URL = ENV.str("ONDEMAND_URL", default=None) -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Default Strings. Override these in local_settings.py -#------------------------------------------------------------------------------ -LOGIN_FAIL_MESSAGE = ENV.str('LOGIN_FAIL_MESSAGE', '') +# ------------------------------------------------------------------------------ +LOGIN_FAIL_MESSAGE = ENV.str("LOGIN_FAIL_MESSAGE", "") EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL = """ You recently applied for renewal of your account, however, to date you have not entered any publication nor grant info in the ColdFront system. I am reluctant to approve your renewal without understanding why. If there are no relevant publications or grants yet, then please let me know. If there are, then I would appreciate it if you would take the time to enter the data (I have done it myself and it took about 15 minutes). We use this information to help make the case to the university for continued investment in our department and it is therefore important that faculty enter the data when appropriate. Please email xxx-helpexample.com if you need technical assistance. @@ -93,15 +108,15 @@ Phone: (xxx) xxx-xxx """ -ACCOUNT_CREATION_TEXT = '''University faculty can submit a help ticket to request an account. +ACCOUNT_CREATION_TEXT = """University faculty can submit a help ticket to request an account. Please see instructions on our website. Staff, students, and external collaborators must request an account through a university faculty member. -''' +""" -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Provide institution project code. -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ -PROJECT_CODE = ENV.str('PROJECT_CODE', default=None) -PROJECT_CODE_PADDING = ENV.int('PROJECT_CODE_PADDING', default=None) \ No newline at end of file +PROJECT_CODE = ENV.str("PROJECT_CODE", default=None) +PROJECT_CODE_PADDING = ENV.int("PROJECT_CODE_PADDING", default=None) diff --git a/coldfront/config/database.py b/coldfront/config/database.py index 13fcd8c7aa..3de4c60881 100644 --- a/coldfront/config/database.py +++ b/coldfront/config/database.py @@ -1,9 +1,14 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os + from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Database settings -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Set this using the DB_URL env variable. Defaults to sqlite. # # Examples: @@ -13,18 +18,13 @@ # # Postgresql: # DB_URL=psql://user:password@127.0.0.1:5432/database -#------------------------------------------------------------------------------ -DATABASES = { - 'default': ENV.db_url( - var='DB_URL', - default='sqlite:///' + os.path.join(os.getcwd(), 'coldfront.db') - ) -} +# ------------------------------------------------------------------------------ +DATABASES = {"default": ENV.db_url(var="DB_URL", default="sqlite:///" + os.path.join(os.getcwd(), "coldfront.db"))} -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Custom Database settings -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # You can also override this manually in local_settings.py, for example: # # NOTE: For mysql you need to: pip install mysqlclient diff --git a/coldfront/config/email.py b/coldfront/config/email.py index 6e79e94d44..70d2256c14 100644 --- a/coldfront/config/email.py +++ b/coldfront/config/email.py @@ -1,29 +1,37 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Email/Notification settings -#------------------------------------------------------------------------------ -EMAIL_ENABLED = ENV.bool('EMAIL_ENABLED', default=False) -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = ENV.str('EMAIL_HOST', default='localhost') -EMAIL_PORT = ENV.int('EMAIL_PORT', default=25) -EMAIL_HOST_USER = ENV.str('EMAIL_HOST_USER', default='') -EMAIL_HOST_PASSWORD = ENV.str('EMAIL_HOST_PASSWORD', default='') -EMAIL_USE_TLS = ENV.bool('EMAIL_USE_TLS', default=False) -EMAIL_TIMEOUT = ENV.int('EMAIL_TIMEOUT', default=3) -EMAIL_SUBJECT_PREFIX = ENV.str('EMAIL_SUBJECT_PREFIX', default='[ColdFront]') -EMAIL_ADMIN_LIST = ENV.list('EMAIL_ADMIN_LIST', default=[]) -EMAIL_SENDER = ENV.str('EMAIL_SENDER', default='') -EMAIL_TICKET_SYSTEM_ADDRESS = ENV.str('EMAIL_TICKET_SYSTEM_ADDRESS', default='') -EMAIL_DIRECTOR_EMAIL_ADDRESS = ENV.str('EMAIL_DIRECTOR_EMAIL_ADDRESS', default='') -EMAIL_PROJECT_REVIEW_CONTACT = ENV.str('EMAIL_PROJECT_REVIEW_CONTACT', default='') -EMAIL_DEVELOPMENT_EMAIL_LIST = ENV.list('EMAIL_DEVELOPMENT_EMAIL_LIST', default=[]) -EMAIL_OPT_OUT_INSTRUCTION_URL = ENV.str('EMAIL_OPT_OUT_INSTRUCTION_URL', default='') -EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS = ENV.list('EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS', cast=int, default=[7, 14, 30]) -EMAIL_SIGNATURE = ENV.str('EMAIL_SIGNATURE', default='', multiline=True) -EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = ENV.bool('EMAIL_ADMINS_ON_ALLOCATION_EXPIRE', default=False) -EMAIL_ALLOCATION_EULA_REMINDERS = ENV.bool('EMAIL_ALLOCATION_EULA_REMINDERS', default=False) -EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = ENV.bool('EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT', default=False) -EMAIL_ALLOCATION_EULA_CONFIRMATIONS = ENV.bool('EMAIL_ALLOCATION_EULA_CONFIRMATIONS', default=False) -EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS = ENV.bool('EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS', default=False) -EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA = ENV.bool('EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA', default=False) \ No newline at end of file +# ------------------------------------------------------------------------------ +EMAIL_ENABLED = ENV.bool("EMAIL_ENABLED", default=False) +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = ENV.str("EMAIL_HOST", default="localhost") +EMAIL_PORT = ENV.int("EMAIL_PORT", default=25) +EMAIL_HOST_USER = ENV.str("EMAIL_HOST_USER", default="") +EMAIL_HOST_PASSWORD = ENV.str("EMAIL_HOST_PASSWORD", default="") +EMAIL_USE_TLS = ENV.bool("EMAIL_USE_TLS", default=False) +EMAIL_TIMEOUT = ENV.int("EMAIL_TIMEOUT", default=3) +EMAIL_SUBJECT_PREFIX = ENV.str("EMAIL_SUBJECT_PREFIX", default="[ColdFront]") +EMAIL_ADMIN_LIST = ENV.list("EMAIL_ADMIN_LIST", default=[]) +EMAIL_SENDER = ENV.str("EMAIL_SENDER", default="") +EMAIL_TICKET_SYSTEM_ADDRESS = ENV.str("EMAIL_TICKET_SYSTEM_ADDRESS", default="") +EMAIL_DIRECTOR_EMAIL_ADDRESS = ENV.str("EMAIL_DIRECTOR_EMAIL_ADDRESS", default="") +EMAIL_PROJECT_REVIEW_CONTACT = ENV.str("EMAIL_PROJECT_REVIEW_CONTACT", default="") +EMAIL_DEVELOPMENT_EMAIL_LIST = ENV.list("EMAIL_DEVELOPMENT_EMAIL_LIST", default=[]) +EMAIL_OPT_OUT_INSTRUCTION_URL = ENV.str("EMAIL_OPT_OUT_INSTRUCTION_URL", default="") +EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS = ENV.list( + "EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS", cast=int, default=[7, 14, 30] +) +EMAIL_SIGNATURE = ENV.str("EMAIL_SIGNATURE", default="", multiline=True) +EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = ENV.bool("EMAIL_ADMINS_ON_ALLOCATION_EXPIRE", default=False) +EMAIL_ALLOCATION_EULA_REMINDERS = ENV.bool("EMAIL_ALLOCATION_EULA_REMINDERS", default=False) +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = ENV.bool("EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT", default=False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS = ENV.bool("EMAIL_ALLOCATION_EULA_CONFIRMATIONS", default=False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS = ENV.bool( + "EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS", default=False +) +EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA = ENV.bool("EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA", default=False) diff --git a/coldfront/config/env.py b/coldfront/config/env.py index 226d587ef4..3ea9c12269 100644 --- a/coldfront/config/env.py +++ b/coldfront/config/env.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import environ ENV = environ.Env() @@ -5,17 +9,17 @@ # Default paths to environment files env_paths = [ - PROJECT_ROOT.path('.env'), - environ.Path('/etc/coldfront/coldfront.env'), + PROJECT_ROOT.path(".env"), + environ.Path("/etc/coldfront/coldfront.env"), ] -if ENV.str('COLDFRONT_ENV', default='') != '': - env_paths.insert(0, environ.Path(ENV.str('COLDFRONT_ENV'))) +if ENV.str("COLDFRONT_ENV", default="") != "": + env_paths.insert(0, environ.Path(ENV.str("COLDFRONT_ENV"))) # Read in any environment files for e in env_paths: try: - e.file('') + e.file("") ENV.read_env(e()) except FileNotFoundError: pass diff --git a/coldfront/config/logging.py b/coldfront/config/logging.py index 8d533a7766..09f1386300 100644 --- a/coldfront/config/logging.py +++ b/coldfront/config/logging.py @@ -1,38 +1,44 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib.messages import constants as messages -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # ColdFront logging config -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ MESSAGE_TAGS = { - messages.DEBUG: 'info', - messages.INFO: 'info', - messages.SUCCESS: 'success', - messages.WARNING: 'warning', - messages.ERROR: 'danger', + messages.DEBUG: "info", + messages.INFO: "info", + messages.SUCCESS: "success", + messages.WARNING: "warning", + messages.ERROR: "danger", } LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", }, # 'file': { # 'class': 'logging.FileHandler', # 'filename': '/tmp/debug.log', # }, }, - 'loggers': { - 'django_auth_ldap': { - 'level': 'WARN', + "loggers": { + "django_auth_ldap": { + "level": "WARN", # 'handlers': ['console', 'file'], - 'handlers': ['console', ], + "handlers": [ + "console", + ], }, - 'django': { - 'handlers': ['console'], - 'level': 'INFO', + "django": { + "handlers": ["console"], + "level": "INFO", }, }, } diff --git a/coldfront/config/plugins/__init__.py b/coldfront/config/plugins/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/config/plugins/__init__.py +++ b/coldfront/config/plugins/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/config/plugins/api.py b/coldfront/config/plugins/api.py index 4c15fa8261..cbaab2f58a 100644 --- a/coldfront/config/plugins/api.py +++ b/coldfront/config/plugins/api.py @@ -1,23 +1,18 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import INSTALLED_APPS -INSTALLED_APPS += [ - 'django_filters', - 'rest_framework', - 'rest_framework.authtoken', - 'coldfront.plugins.api' - ] +INSTALLED_APPS += ["django_filters", "rest_framework", "rest_framework.authtoken", "coldfront.plugins.api"] REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.TokenAuthentication', - 'rest_framework.authentication.SessionAuthentication', + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", # only use BasicAuthentication for test purposes # 'rest_framework.authentication.BasicAuthentication', ), - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' - ], - 'DEFAULT_FILTER_BACKENDS': [ - 'django_filters.rest_framework.DjangoFilterBackend' - ], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } diff --git a/coldfront/config/plugins/freeipa.py b/coldfront/config/plugins/freeipa.py index c38656dac7..51e7ee7ab8 100644 --- a/coldfront/config/plugins/freeipa.py +++ b/coldfront/config/plugins/freeipa.py @@ -1,12 +1,18 @@ -from coldfront.config.base import INSTALLED_APPS, ENV +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV INSTALLED_APPS += [ - 'coldfront.plugins.freeipa', + "coldfront.plugins.freeipa", ] -FREEIPA_KTNAME = ENV.str('FREEIPA_KTNAME') -FREEIPA_SERVER = ENV.str('FREEIPA_SERVER') -FREEIPA_USER_SEARCH_BASE = ENV.str('FREEIPA_USER_SEARCH_BASE') +FREEIPA_KTNAME = ENV.str("FREEIPA_KTNAME") +FREEIPA_SERVER = ENV.str("FREEIPA_SERVER") +FREEIPA_USER_SEARCH_BASE = ENV.str("FREEIPA_USER_SEARCH_BASE") FREEIPA_ENABLE_SIGNALS = False -ADDITIONAL_USER_SEARCH_CLASSES = ['coldfront.plugins.freeipa.search.LDAPUserSearch',] +ADDITIONAL_USER_SEARCH_CLASSES = [ + "coldfront.plugins.freeipa.search.LDAPUserSearch", +] diff --git a/coldfront/config/plugins/iquota.py b/coldfront/config/plugins/iquota.py index ca572ff0de..69cc62cfa9 100644 --- a/coldfront/config/plugins/iquota.py +++ b/coldfront/config/plugins/iquota.py @@ -1,11 +1,15 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV INSTALLED_APPS += [ - 'coldfront.plugins.iquota', + "coldfront.plugins.iquota", ] -IQUOTA_KEYTAB = ENV.str('IQUOTA_KEYTAB') -IQUOTA_CA_CERT = ENV.str('IQUOTA_CA_CERT') -IQUOTA_API_HOST = ENV.str('IQUOTA_API_HOST') -IQUOTA_API_PORT = ENV.str('IQUOTA_API_PORT', default='8080') +IQUOTA_KEYTAB = ENV.str("IQUOTA_KEYTAB") +IQUOTA_CA_CERT = ENV.str("IQUOTA_CA_CERT") +IQUOTA_API_HOST = ENV.str("IQUOTA_API_HOST") +IQUOTA_API_PORT = ENV.str("IQUOTA_API_PORT", default="8080") diff --git a/coldfront/config/plugins/ldap.py b/coldfront/config/plugins/ldap.py index 7833c36322..8203f446ef 100644 --- a/coldfront/config/plugins/ldap.py +++ b/coldfront/config/plugins/ldap.py @@ -1,41 +1,51 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.core.exceptions import ImproperlyConfigured + from coldfront.config.base import AUTHENTICATION_BACKENDS from coldfront.config.env import ENV -from django.core.exceptions import ImproperlyConfigured try: import ldap from django_auth_ldap.config import GroupOfNamesType, LDAPSearch except ImportError: - raise ImproperlyConfigured('Please run: pip install ldap3 django_auth_ldap') + raise ImproperlyConfigured("Please run: pip install ldap3 django_auth_ldap") -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # LDAP user authentication using django-auth-ldap. This will enable LDAP # user/password logins. You can also override this in local_settings.py -#------------------------------------------------------------------------------ -AUTH_COLDFRONT_LDAP_SEARCH_SCOPE = ENV.str('AUTH_COLDFRONT_LDAP_SEARCH_SCOPE', default='ONELEVEL') - -AUTH_LDAP_SERVER_URI = ENV.str('AUTH_LDAP_SERVER_URI') -AUTH_LDAP_USER_SEARCH_BASE = ENV.str('AUTH_LDAP_USER_SEARCH_BASE') -AUTH_LDAP_START_TLS = ENV.bool('AUTH_LDAP_START_TLS', default=True) -AUTH_LDAP_BIND_DN = ENV.str('AUTH_LDAP_BIND_DN', default='') -AUTH_LDAP_BIND_PASSWORD = ENV.str('AUTH_LDAP_BIND_PASSWORD', default='') -AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = ENV.bool('AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', default=False) -AUTH_LDAP_MIRROR_GROUPS = ENV.bool('AUTH_LDAP_MIRROR_GROUPS', default=True) -AUTH_LDAP_GROUP_SEARCH_BASE = ENV.str('AUTH_LDAP_GROUP_SEARCH_BASE') - -if AUTH_COLDFRONT_LDAP_SEARCH_SCOPE == 'SUBTREE': - AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_SUBTREE, '(uid=%(user)s)') - AUTH_LDAP_GROUP_SEARCH = LDAPSearch(AUTH_LDAP_GROUP_SEARCH_BASE, ldap.SCOPE_SUBTREE, '(objectClass=groupOfNames)') +# ------------------------------------------------------------------------------ +AUTH_COLDFRONT_LDAP_SEARCH_SCOPE = ENV.str("AUTH_COLDFRONT_LDAP_SEARCH_SCOPE", default="ONELEVEL") + +AUTH_LDAP_SERVER_URI = ENV.str("AUTH_LDAP_SERVER_URI") +AUTH_LDAP_USER_SEARCH_BASE = ENV.str("AUTH_LDAP_USER_SEARCH_BASE") +AUTH_LDAP_START_TLS = ENV.bool("AUTH_LDAP_START_TLS", default=True) +AUTH_LDAP_BIND_DN = ENV.str("AUTH_LDAP_BIND_DN", default="") +AUTH_LDAP_BIND_PASSWORD = ENV.str("AUTH_LDAP_BIND_PASSWORD", default="") +AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = ENV.bool("AUTH_LDAP_BIND_AS_AUTHENTICATING_USER", default=False) +AUTH_LDAP_MIRROR_GROUPS = ENV.bool("AUTH_LDAP_MIRROR_GROUPS", default=True) +AUTH_LDAP_GROUP_SEARCH_BASE = ENV.str("AUTH_LDAP_GROUP_SEARCH_BASE") + +if AUTH_COLDFRONT_LDAP_SEARCH_SCOPE == "SUBTREE": + AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_SUBTREE, "(uid=%(user)s)") + AUTH_LDAP_GROUP_SEARCH = LDAPSearch(AUTH_LDAP_GROUP_SEARCH_BASE, ldap.SCOPE_SUBTREE, "(objectClass=groupOfNames)") else: - AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_ONELEVEL, '(uid=%(user)s)') - AUTH_LDAP_GROUP_SEARCH = LDAPSearch(AUTH_LDAP_GROUP_SEARCH_BASE, ldap.SCOPE_ONELEVEL, '(objectClass=groupOfNames)') + AUTH_LDAP_USER_SEARCH = LDAPSearch(AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_ONELEVEL, "(uid=%(user)s)") + AUTH_LDAP_GROUP_SEARCH = LDAPSearch(AUTH_LDAP_GROUP_SEARCH_BASE, ldap.SCOPE_ONELEVEL, "(objectClass=groupOfNames)") AUTH_LDAP_GROUP_TYPE = GroupOfNamesType() -AUTH_LDAP_USER_ATTR_MAP = ENV.dict('AUTH_LDAP_USER_ATTR_MAP', default ={ - 'username': 'uid', - 'first_name': 'givenName', - 'last_name': 'sn', - 'email': 'mail', - }) - -AUTHENTICATION_BACKENDS += ['django_auth_ldap.backend.LDAPBackend',] +AUTH_LDAP_USER_ATTR_MAP = ENV.dict( + "AUTH_LDAP_USER_ATTR_MAP", + default={ + "username": "uid", + "first_name": "givenName", + "last_name": "sn", + "email": "mail", + }, +) + +AUTHENTICATION_BACKENDS += [ + "django_auth_ldap.backend.LDAPBackend", +] diff --git a/coldfront/config/plugins/ldap_user_search.py b/coldfront/config/plugins/ldap_user_search.py index 1438d96d66..8ca527d799 100644 --- a/coldfront/config/plugins/ldap_user_search.py +++ b/coldfront/config/plugins/ldap_user_search.py @@ -1,25 +1,30 @@ -from coldfront.config.env import ENV +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib.util + from django.core.exceptions import ImproperlyConfigured -try: - import ldap -except ImportError: - raise ImproperlyConfigured('Please run: pip install ldap3') +from coldfront.config.env import ENV + +if importlib.util.find_spec("ldap") is None: + raise ImproperlyConfigured("Please install required ldap module") # ---------------------------------------------------------------------------- # This enables searching for users via LDAP # ---------------------------------------------------------------------------- -LDAP_USER_SEARCH_SERVER_URI = ENV.str('LDAP_USER_SEARCH_SERVER_URI') -LDAP_USER_SEARCH_BASE = ENV.str('LDAP_USER_SEARCH_BASE') -LDAP_USER_SEARCH_BIND_DN = ENV.str('LDAP_USER_SEARCH_BIND_DN', default=None) -LDAP_USER_SEARCH_BIND_PASSWORD = ENV.str('LDAP_USER_SEARCH_BIND_PASSWORD', default=None) -LDAP_USER_SEARCH_CONNECT_TIMEOUT = ENV.float('LDAP_USER_SEARCH_CONNECT_TIMEOUT', default=2.5) -LDAP_USER_SEARCH_USE_SSL = ENV.bool('LDAP_USER_SEARCH_USE_SSL', default=True) -LDAP_USER_SEARCH_USE_TLS = ENV.bool('LDAP_USER_SEARCH_USE_TLS', default=False) +LDAP_USER_SEARCH_SERVER_URI = ENV.str("LDAP_USER_SEARCH_SERVER_URI") +LDAP_USER_SEARCH_BASE = ENV.str("LDAP_USER_SEARCH_BASE") +LDAP_USER_SEARCH_BIND_DN = ENV.str("LDAP_USER_SEARCH_BIND_DN", default=None) +LDAP_USER_SEARCH_BIND_PASSWORD = ENV.str("LDAP_USER_SEARCH_BIND_PASSWORD", default=None) +LDAP_USER_SEARCH_CONNECT_TIMEOUT = ENV.float("LDAP_USER_SEARCH_CONNECT_TIMEOUT", default=2.5) +LDAP_USER_SEARCH_USE_SSL = ENV.bool("LDAP_USER_SEARCH_USE_SSL", default=True) +LDAP_USER_SEARCH_USE_TLS = ENV.bool("LDAP_USER_SEARCH_USE_TLS", default=False) LDAP_USER_SEARCH_PRIV_KEY_FILE = ENV.str("LDAP_USER_SEARCH_PRIV_KEY_FILE", default=None) LDAP_USER_SEARCH_CERT_FILE = ENV.str("LDAP_USER_SEARCH_CERT_FILE", default=None) LDAP_USER_SEARCH_CACERT_FILE = ENV.str("LDAP_USER_SEARCH_CACERT_FILE", default=None) LDAP_USER_SEARCH_CERT_VALIDATE_MODE = ENV.str("LDAP_USER_SEARCH_CERT_VALIDATE_MODE", default=None) -ADDITIONAL_USER_SEARCH_CLASSES = ['coldfront.plugins.ldap_user_search.utils.LDAPUserSearch'] +ADDITIONAL_USER_SEARCH_CLASSES = ["coldfront.plugins.ldap_user_search.utils.LDAPUserSearch"] diff --git a/coldfront/config/plugins/openid.py b/coldfront/config/plugins/openid.py index b1e0226148..5d54422619 100644 --- a/coldfront/config/plugins/openid.py +++ b/coldfront/config/plugins/openid.py @@ -1,40 +1,44 @@ -from coldfront.config.base import INSTALLED_APPS, MIDDLEWARE, AUTHENTICATION_BACKENDS +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from coldfront.config.base import AUTHENTICATION_BACKENDS, INSTALLED_APPS, MIDDLEWARE from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable OpenID Connect Authentication Backend -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ INSTALLED_APPS += [ - 'mozilla_django_oidc', + "mozilla_django_oidc", ] -if ENV.bool('PLUGIN_MOKEY', default=False): - #------------------------------------------------------------------------------ +if ENV.bool("PLUGIN_MOKEY", default=False): + # ------------------------------------------------------------------------------ # Enable Mokey/Hydra OpenID Connect Authentication Backend - #------------------------------------------------------------------------------ + # ------------------------------------------------------------------------------ INSTALLED_APPS += [ - 'coldfront.plugins.mokey_oidc', + "coldfront.plugins.mokey_oidc", ] AUTHENTICATION_BACKENDS += [ - 'coldfront.plugins.mokey_oidc.auth.OIDCMokeyAuthenticationBackend', + "coldfront.plugins.mokey_oidc.auth.OIDCMokeyAuthenticationBackend", ] - MOKEY_OIDC_PI_GROUP= ENV.str('MOKEY_OIDC_PI_GROUP') + MOKEY_OIDC_PI_GROUP = ENV.str("MOKEY_OIDC_PI_GROUP") else: AUTHENTICATION_BACKENDS += [ - 'mozilla_django_oidc.auth.OIDCAuthenticationBackend', + "mozilla_django_oidc.auth.OIDCAuthenticationBackend", ] MIDDLEWARE += [ - 'mozilla_django_oidc.middleware.SessionRefresh', + "mozilla_django_oidc.middleware.SessionRefresh", ] -OIDC_OP_JWKS_ENDPOINT = ENV.str('OIDC_OP_JWKS_ENDPOINT') -OIDC_RP_SIGN_ALGO = ENV.str('OIDC_RP_SIGN_ALGO') -OIDC_RP_CLIENT_ID = ENV.str('OIDC_RP_CLIENT_ID') -OIDC_RP_CLIENT_SECRET = ENV.str('OIDC_RP_CLIENT_SECRET') -OIDC_OP_AUTHORIZATION_ENDPOINT = ENV.str('OIDC_OP_AUTHORIZATION_ENDPOINT') -OIDC_OP_TOKEN_ENDPOINT = ENV.str('OIDC_OP_TOKEN_ENDPOINT') -OIDC_OP_USER_ENDPOINT = ENV.str('OIDC_OP_USER_ENDPOINT') -OIDC_VERIFY_SSL = ENV.bool('OIDC_VERIFY_SSL', default=True) -OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = ENV.int('OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS', default=3600) +OIDC_OP_JWKS_ENDPOINT = ENV.str("OIDC_OP_JWKS_ENDPOINT") +OIDC_RP_SIGN_ALGO = ENV.str("OIDC_RP_SIGN_ALGO") +OIDC_RP_CLIENT_ID = ENV.str("OIDC_RP_CLIENT_ID") +OIDC_RP_CLIENT_SECRET = ENV.str("OIDC_RP_CLIENT_SECRET") +OIDC_OP_AUTHORIZATION_ENDPOINT = ENV.str("OIDC_OP_AUTHORIZATION_ENDPOINT") +OIDC_OP_TOKEN_ENDPOINT = ENV.str("OIDC_OP_TOKEN_ENDPOINT") +OIDC_OP_USER_ENDPOINT = ENV.str("OIDC_OP_USER_ENDPOINT") +OIDC_VERIFY_SSL = ENV.bool("OIDC_VERIFY_SSL", default=True) +OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS = ENV.int("OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS", default=3600) diff --git a/coldfront/config/plugins/slurm.py b/coldfront/config/plugins/slurm.py index 56f690c9e8..e1843c708f 100644 --- a/coldfront/config/plugins/slurm.py +++ b/coldfront/config/plugins/slurm.py @@ -1,11 +1,15 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV INSTALLED_APPS += [ - 'coldfront.plugins.slurm', + "coldfront.plugins.slurm", ] -SLURM_SACCTMGR_PATH = ENV.str('SLURM_SACCTMGR_PATH', default='/usr/bin/sacctmgr') -SLURM_NOOP = ENV.bool('SLURM_NOOP', False) -SLURM_IGNORE_USERS = ENV.list('SLURM_IGNORE_USERS', default=['root']) -SLURM_IGNORE_ACCOUNTS = ENV.list('SLURM_IGNORE_ACCOUNTS', default=[]) +SLURM_SACCTMGR_PATH = ENV.str("SLURM_SACCTMGR_PATH", default="/usr/bin/sacctmgr") +SLURM_NOOP = ENV.bool("SLURM_NOOP", False) +SLURM_IGNORE_USERS = ENV.list("SLURM_IGNORE_USERS", default=["root"]) +SLURM_IGNORE_ACCOUNTS = ENV.list("SLURM_IGNORE_ACCOUNTS", default=[]) diff --git a/coldfront/config/plugins/system_monitor.py b/coldfront/config/plugins/system_monitor.py index d2db6552b7..ac3cda3912 100644 --- a/coldfront/config/plugins/system_monitor.py +++ b/coldfront/config/plugins/system_monitor.py @@ -1,11 +1,13 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV -INSTALLED_APPS += [ - 'coldfront.plugins.system_monitor' -] +INSTALLED_APPS += ["coldfront.plugins.system_monitor"] -SYSTEM_MONITOR_PANEL_TITLE = ENV.str('SYSMON_TITLE', default='HPC Cluster Status') -SYSTEM_MONITOR_ENDPOINT = ENV.str('SYSMON_ENDPOINT') -SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK = ENV.str('SYSMON_LINK') -SYSTEM_MONITOR_DISPLAY_XDMOD_LINK = ENV.str('SYSMON_XDMOD_LINK') +SYSTEM_MONITOR_PANEL_TITLE = ENV.str("SYSMON_TITLE", default="HPC Cluster Status") +SYSTEM_MONITOR_ENDPOINT = ENV.str("SYSMON_ENDPOINT") +SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK = ENV.str("SYSMON_LINK") +SYSTEM_MONITOR_DISPLAY_XDMOD_LINK = ENV.str("SYSMON_XDMOD_LINK") diff --git a/coldfront/config/plugins/xdmod.py b/coldfront/config/plugins/xdmod.py index f5931dc651..943687a4f6 100644 --- a/coldfront/config/plugins/xdmod.py +++ b/coldfront/config/plugins/xdmod.py @@ -1,11 +1,15 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from coldfront.config.base import INSTALLED_APPS from coldfront.config.env import ENV -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ # Enable XDMoD support -#------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ INSTALLED_APPS += [ - 'coldfront.plugins.xdmod', + "coldfront.plugins.xdmod", ] -XDMOD_API_URL = ENV.str('XDMOD_API_URL') +XDMOD_API_URL = ENV.str("XDMOD_API_URL") diff --git a/coldfront/config/settings.py b/coldfront/config/settings.py index de2b08b879..3ce5e79e59 100644 --- a/coldfront/config/settings.py +++ b/coldfront/config/settings.py @@ -1,28 +1,33 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import environ -from split_settings.tools import optional, include +from split_settings.tools import include, optional + from coldfront.config.env import ENV, PROJECT_ROOT # ColdFront split settings coldfront_configs = [ - 'base.py', - 'database.py', - 'auth.py', - 'logging.py', - 'core.py', - 'email.py', + "base.py", + "database.py", + "auth.py", + "logging.py", + "core.py", + "email.py", ] # ColdFront plugin settings plugin_configs = { - 'PLUGIN_SLURM': 'plugins/slurm.py', - 'PLUGIN_IQUOTA': 'plugins/iquota.py', - 'PLUGIN_FREEIPA': 'plugins/freeipa.py', - 'PLUGIN_SYSMON': 'plugins/system_monitor.py', - 'PLUGIN_XDMOD': 'plugins/xdmod.py', - 'PLUGIN_AUTH_OIDC': 'plugins/openid.py', - 'PLUGIN_AUTH_LDAP': 'plugins/ldap.py', - 'PLUGIN_LDAP_USER_SEARCH': 'plugins/ldap_user_search.py', - 'PLUGIN_API': 'plugins/api.py', + "PLUGIN_SLURM": "plugins/slurm.py", + "PLUGIN_IQUOTA": "plugins/iquota.py", + "PLUGIN_FREEIPA": "plugins/freeipa.py", + "PLUGIN_SYSMON": "plugins/system_monitor.py", + "PLUGIN_XDMOD": "plugins/xdmod.py", + "PLUGIN_AUTH_OIDC": "plugins/openid.py", + "PLUGIN_AUTH_LDAP": "plugins/ldap.py", + "PLUGIN_LDAP_USER_SEARCH": "plugins/ldap_user_search.py", + "PLUGIN_API": "plugins/api.py", } # This allows plugins to be enabled via environment variables. Can alternatively @@ -34,18 +39,16 @@ # Local settings overrides local_configs = [ # Local settings relative to coldfront.config package - 'local_settings.py', - + "local_settings.py", # System wide settings for production deployments - '/etc/coldfront/local_settings.py', - + "/etc/coldfront/local_settings.py", # Local settings relative to coldfront project root - PROJECT_ROOT('local_settings.py') + PROJECT_ROOT("local_settings.py"), ] -if ENV.str('COLDFRONT_CONFIG', default='') != '': +if ENV.str("COLDFRONT_CONFIG", default="") != "": # Local settings from path specified via environment variable - local_configs.append(environ.Path(ENV.str('COLDFRONT_CONFIG'))()) + local_configs.append(environ.Path(ENV.str("COLDFRONT_CONFIG"))()) for lc in local_configs: coldfront_configs.append(optional(lc)) diff --git a/coldfront/config/urls.py b/coldfront/config/urls.py index 0afbfa30cd..0511edc552 100644 --- a/coldfront/config/urls.py +++ b/coldfront/config/urls.py @@ -1,6 +1,11 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + """ ColdFront URL Configuration """ + from django.conf import settings from django.contrib import admin from django.urls import include, path @@ -8,39 +13,39 @@ import coldfront.core.portal.views as portal_views -admin.site.site_header = 'ColdFront Administration' -admin.site.site_title = 'ColdFront Administration' +admin.site.site_header = "ColdFront Administration" +admin.site.site_title = "ColdFront Administration" urlpatterns = [ - path('admin/', admin.site.urls), - path('robots.txt', TemplateView.as_view(template_name='robots.txt', content_type='text/plain'), name="robots"), - path('', portal_views.home, name='home'), - path('center-summary', portal_views.center_summary, name='center-summary'), - path('allocation-summary', portal_views.allocation_summary, name='allocation-summary'), - path('allocation-by-fos', portal_views.allocation_by_fos, name='allocation-by-fos'), - path('user/', include('coldfront.core.user.urls')), - path('project/', include('coldfront.core.project.urls')), - path('allocation/', include('coldfront.core.allocation.urls')), - path('resource/', include('coldfront.core.resource.urls')), + path("admin/", admin.site.urls), + path("robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), name="robots"), + path("", portal_views.home, name="home"), + path("center-summary", portal_views.center_summary, name="center-summary"), + path("allocation-summary", portal_views.allocation_summary, name="allocation-summary"), + path("allocation-by-fos", portal_views.allocation_by_fos, name="allocation-by-fos"), + path("user/", include("coldfront.core.user.urls")), + path("project/", include("coldfront.core.project.urls")), + path("allocation/", include("coldfront.core.allocation.urls")), + path("resource/", include("coldfront.core.resource.urls")), ] if settings.GRANT_ENABLE: - urlpatterns.append(path('grant/', include('coldfront.core.grant.urls'))) + urlpatterns.append(path("grant/", include("coldfront.core.grant.urls"))) if settings.PUBLICATION_ENABLE: - urlpatterns.append(path('publication/', include('coldfront.core.publication.urls'))) + urlpatterns.append(path("publication/", include("coldfront.core.publication.urls"))) if settings.RESEARCH_OUTPUT_ENABLE: - urlpatterns.append(path('research-output/', include('coldfront.core.research_output.urls'))) + urlpatterns.append(path("research-output/", include("coldfront.core.research_output.urls"))) -if 'coldfront.plugins.api' in settings.INSTALLED_APPS: - urlpatterns.append(path('api/', include('coldfront.plugins.api.urls'))) +if "coldfront.plugins.api" in settings.INSTALLED_APPS: + urlpatterns.append(path("api/", include("coldfront.plugins.api.urls"))) -if 'coldfront.plugins.iquota' in settings.INSTALLED_APPS: - urlpatterns.append(path('iquota/', include('coldfront.plugins.iquota.urls'))) +if "coldfront.plugins.iquota" in settings.INSTALLED_APPS: + urlpatterns.append(path("iquota/", include("coldfront.plugins.iquota.urls"))) -if 'mozilla_django_oidc' in settings.INSTALLED_APPS: - urlpatterns.append(path('oidc/', include('mozilla_django_oidc.urls'))) +if "mozilla_django_oidc" in settings.INSTALLED_APPS: + urlpatterns.append(path("oidc/", include("mozilla_django_oidc.urls"))) -if 'django_su.backends.SuBackend' in settings.AUTHENTICATION_BACKENDS: - urlpatterns.append(path('su/', include('django_su.urls'))) +if "django_su.backends.SuBackend" in settings.AUTHENTICATION_BACKENDS: + urlpatterns.append(path("su/", include("django_su.urls"))) diff --git a/coldfront/config/wsgi.py b/coldfront/config/wsgi.py index f4cdb2c78a..3caedadd48 100644 --- a/coldfront/config/wsgi.py +++ b/coldfront/config/wsgi.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + """ WSGI config for ColdFront project. diff --git a/coldfront/core/__init__.py b/coldfront/core/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/__init__.py +++ b/coldfront/core/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/__init__.py b/coldfront/core/allocation/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/allocation/__init__.py +++ b/coldfront/core/allocation/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/admin.py b/coldfront/core/allocation/admin.py index 729c2faad7..972396858b 100644 --- a/coldfront/core/allocation/admin.py +++ b/coldfront/core/allocation/admin.py @@ -1,74 +1,119 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import textwrap from django.contrib import admin from django.utils.translation import gettext_lazy as _ from simple_history.admin import SimpleHistoryAdmin -from coldfront.core.allocation.models import (Allocation, AllocationAccount, - AllocationAdminNote, - AllocationAttribute, - AllocationAttributeType, - AllocationAttributeUsage, - AllocationChangeRequest, - AllocationAttributeChangeRequest, - AllocationStatusChoice, - AllocationChangeStatusChoice, - AllocationUser, - AllocationUserNote, - AllocationUserStatusChoice, - AttributeType) +from coldfront.core.allocation.models import ( + Allocation, + AllocationAccount, + AllocationAdminNote, + AllocationAttribute, + AllocationAttributeChangeRequest, + AllocationAttributeType, + AllocationAttributeUsage, + AllocationChangeRequest, + AllocationChangeStatusChoice, + AllocationStatusChoice, + AllocationUser, + AllocationUserNote, + AllocationUserStatusChoice, + AttributeType, +) @admin.register(AllocationStatusChoice) class AllocationStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) class AllocationUserInline(admin.TabularInline): model = AllocationUser extra = 0 - fields = ('user', 'status', ) - raw_id_fields = ('user', ) + fields = ( + "user", + "status", + ) + raw_id_fields = ("user",) class AllocationAttributeInline(admin.TabularInline): model = AllocationAttribute extra = 0 - fields = ('allocation_attribute_type', 'value',) + fields = ( + "allocation_attribute_type", + "value", + ) class AllocationAdminNoteInline(admin.TabularInline): model = AllocationAdminNote extra = 0 - fields = ('note', 'author', 'created'), - readonly_fields = ('author', 'created') + fields = (("note", "author", "created"),) + readonly_fields = ("author", "created") class AllocationUserNoteInline(admin.TabularInline): model = AllocationUserNote extra = 0 - fields = ('note', 'author', 'created'), - readonly_fields = ('author', 'created') + fields = (("note", "author", "created"),) + readonly_fields = ("author", "created") @admin.register(Allocation) class AllocationAdmin(SimpleHistoryAdmin): readonly_fields_change = ( - 'project', 'justification', 'created', 'modified',) - fields_change = ('project', 'resources', 'quantity', 'justification', - 'status', 'start_date', 'end_date', 'description', 'created', 'modified', 'is_locked', 'is_changeable') - list_display = ('pk', 'project_title', 'project_pi', 'resource', 'quantity', - 'justification', 'start_date', 'end_date', 'status', 'created', 'modified', ) - inlines = [AllocationUserInline, - AllocationAttributeInline, - AllocationAdminNoteInline, - AllocationUserNoteInline] - list_filter = ('resources__resource_type__name', - 'status', 'resources__name', 'is_locked') - search_fields = ['project__pi__username', 'project__pi__first_name', 'project__pi__last_name', 'resources__name', - 'allocationuser__user__first_name', 'allocationuser__user__last_name', 'allocationuser__user__username'] - filter_horizontal = ['resources', ] - raw_id_fields = ('project',) + "project", + "justification", + "created", + "modified", + ) + fields_change = ( + "project", + "resources", + "quantity", + "justification", + "status", + "start_date", + "end_date", + "description", + "created", + "modified", + "is_locked", + "is_changeable", + ) + list_display = ( + "pk", + "project_title", + "project_pi", + "resource", + "quantity", + "justification", + "start_date", + "end_date", + "status", + "created", + "modified", + ) + inlines = [AllocationUserInline, AllocationAttributeInline, AllocationAdminNoteInline, AllocationUserNoteInline] + list_filter = ("resources__resource_type__name", "status", "resources__name", "is_locked") + search_fields = [ + "project__pi__username", + "project__pi__first_name", + "project__pi__last_name", + "resources__name", + "allocationuser__user__first_name", + "allocationuser__user__last_name", + "allocationuser__user__username", + ] + filter_horizontal = [ + "resources", + ] + raw_id_fields = ("project",) def resource(self, obj): return obj.get_parent_resource @@ -111,12 +156,12 @@ def save_formset(self, request, form, formset, change): @admin.register(AttributeType) class AttributeTypeAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) @admin.register(AllocationAttributeType) class AllocationAttributeTypeAdmin(admin.ModelAdmin): - list_display = ('pk', 'name', 'attribute_type', 'has_usage', 'is_private') + list_display = ("pk", "name", "attribute_type", "has_usage", "is_private") class AllocationAttributeUsageInline(admin.TabularInline): @@ -125,59 +170,74 @@ class AllocationAttributeUsageInline(admin.TabularInline): class UsageValueFilter(admin.SimpleListFilter): - title = _('value') + title = _("value") - parameter_name = 'value' + parameter_name = "value" def lookups(self, request, model_admin): return ( - ('>=0', _('Greater than or equal to 0')), - ('>10', _('Greater than 10')), - ('>100', _('Greater than 100')), - ('>1000', _('Greater than 1000')), - ('>10000', _('Greater than 10000')), + (">=0", _("Greater than or equal to 0")), + (">10", _("Greater than 10")), + (">100", _("Greater than 100")), + (">1000", _("Greater than 1000")), + (">10000", _("Greater than 10000")), ) def queryset(self, request, queryset): - - if self.value() == '>=0': + if self.value() == ">=0": return queryset.filter(allocationattributeusage__value__gte=0) - if self.value() == '>10': + if self.value() == ">10": return queryset.filter(allocationattributeusage__value__gte=10) - if self.value() == '>100': + if self.value() == ">100": return queryset.filter(allocationattributeusage__value__gte=100) - if self.value() == '>1000': + if self.value() == ">1000": return queryset.filter(allocationattributeusage__value__gte=1000) @admin.register(AllocationAttribute) class AllocationAttributeAdmin(SimpleHistoryAdmin): - readonly_fields_change = ( - 'allocation', 'allocation_attribute_type', 'created', 'modified', 'project_title') - fields_change = ('project_title', 'allocation', - 'allocation_attribute_type', 'value', 'created', 'modified',) - list_display = ('pk', 'project', 'pi', 'resource', 'allocation_status', - 'allocation_attribute_type', 'value', 'usage', 'created', 'modified',) - inlines = [AllocationAttributeUsageInline, ] - list_filter = (UsageValueFilter, 'allocation_attribute_type', - 'allocation__status', 'allocation__resources') + readonly_fields_change = ("allocation", "allocation_attribute_type", "created", "modified", "project_title") + fields_change = ( + "project_title", + "allocation", + "allocation_attribute_type", + "value", + "created", + "modified", + ) + list_display = ( + "pk", + "project", + "pi", + "resource", + "allocation_status", + "allocation_attribute_type", + "value", + "usage", + "created", + "modified", + ) + inlines = [ + AllocationAttributeUsageInline, + ] + list_filter = (UsageValueFilter, "allocation_attribute_type", "allocation__status", "allocation__resources") search_fields = ( - 'allocation__project__pi__first_name', - 'allocation__project__pi__last_name', - 'allocation__project__pi__username', - 'allocation__allocationuser__user__first_name', - 'allocation__allocationuser__user__last_name', - 'allocation__allocationuser__user__username', + "allocation__project__pi__first_name", + "allocation__project__pi__last_name", + "allocation__project__pi__username", + "allocation__allocationuser__user__first_name", + "allocation__allocationuser__user__last_name", + "allocation__allocationuser__user__username", ) def usage(self, obj): - if hasattr(obj, 'allocationattributeusage'): + if hasattr(obj, "allocationattributeusage"): return obj.allocationattributeusage.value else: - return 'N/A' + return "N/A" def resource(self, obj): return obj.allocation.get_parent_resource @@ -186,7 +246,11 @@ def allocation_status(self, obj): return obj.allocation.status def pi(self, obj): - return '{} {} ({})'.format(obj.allocation.project.pi.first_name, obj.allocation.project.pi.last_name, obj.allocation.project.pi.username) + return "{} {} ({})".format( + obj.allocation.project.pi.first_name, + obj.allocation.project.pi.last_name, + obj.allocation.project.pi.username, + ) def project(self, obj): return textwrap.shorten(obj.allocation.project.title, width=50) @@ -217,29 +281,56 @@ def get_inline_instances(self, request, obj=None): @admin.register(AllocationUserStatusChoice) class AllocationUserStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name',) + list_display = ("name",) @admin.register(AllocationUser) class AllocationUserAdmin(SimpleHistoryAdmin): - readonly_fields_change = ('allocation', 'user', - 'resource', 'created', 'modified',) - fields_change = ('allocation', 'user', 'status', 'created', 'modified',) - list_display = ('pk', 'project', 'project_pi', 'resource', 'allocation_status', - 'user_info', 'status', 'created', 'modified',) - list_filter = ('status', 'allocation__status', 'allocation__resources',) + readonly_fields_change = ( + "allocation", + "user", + "resource", + "created", + "modified", + ) + fields_change = ( + "allocation", + "user", + "status", + "created", + "modified", + ) + list_display = ( + "pk", + "project", + "project_pi", + "resource", + "allocation_status", + "user_info", + "status", + "created", + "modified", + ) + list_filter = ( + "status", + "allocation__status", + "allocation__resources", + ) search_fields = ( - 'user__first_name', - 'user__last_name', - 'user__username', + "user__first_name", + "user__last_name", + "user__username", + ) + raw_id_fields = ( + "allocation", + "user", ) - raw_id_fields = ('allocation', 'user', ) def allocation_status(self, obj): return obj.allocation.status def user_info(self, obj): - return '{} {} ({})'.format(obj.user.first_name, obj.user.last_name, obj.user.username) + return "{} {} ({})".format(obj.user.first_name, obj.user.last_name, obj.user.username) def resource(self, obj): return obj.allocation.resources.first() @@ -271,17 +362,13 @@ def get_inline_instances(self, request, obj=None): return super().get_inline_instances(request) def set_active(self, request, queryset): - queryset.update( - status=AllocationUserStatusChoice.objects.get(name='Active')) + queryset.update(status=AllocationUserStatusChoice.objects.get(name="Active")) def set_denied(self, request, queryset): - queryset.update( - status=AllocationUserStatusChoice.objects.get(name='Denied')) + queryset.update(status=AllocationUserStatusChoice.objects.get(name="Denied")) def set_removed(self, request, queryset): - - queryset.update( - status=AllocationUserStatusChoice.objects.get(name='Removed')) + queryset.update(status=AllocationUserStatusChoice.objects.get(name="Removed")) set_active.short_description = "Set Selected User's Status To Active" @@ -297,41 +384,51 @@ def set_removed(self, request, queryset): class ValueFilter(admin.SimpleListFilter): - title = _('value') + title = _("value") - parameter_name = 'value' + parameter_name = "value" def lookups(self, request, model_admin): return ( - ('>0', _('Greater than > 0')), - ('>10', _('Greater than > 10')), - ('>100', _('Greater than > 100')), - ('>1000', _('Greater than > 1000')), + (">0", _("Greater than > 0")), + (">10", _("Greater than > 10")), + (">100", _("Greater than > 100")), + (">1000", _("Greater than > 1000")), ) def queryset(self, request, queryset): - - if self.value() == '>0': + if self.value() == ">0": return queryset.filter(value__gt=0) - if self.value() == '>10': + if self.value() == ">10": return queryset.filter(value__gt=10) - if self.value() == '>100': + if self.value() == ">100": return queryset.filter(value__gt=100) - if self.value() == '>1000': + if self.value() == ">1000": return queryset.filter(value__gt=1000) @admin.register(AllocationAttributeUsage) class AllocationAttributeUsageAdmin(SimpleHistoryAdmin): - list_display = ('allocation_attribute', 'project', - 'project_pi', 'resource', 'value',) - readonly_fields = ('allocation_attribute',) - fields = ('allocation_attribute', 'value',) - list_filter = ('allocation_attribute__allocation_attribute_type', - 'allocation_attribute__allocation__resources', ValueFilter, ) + list_display = ( + "allocation_attribute", + "project", + "project_pi", + "resource", + "value", + ) + readonly_fields = ("allocation_attribute",) + fields = ( + "allocation_attribute", + "value", + ) + list_filter = ( + "allocation_attribute__allocation_attribute_type", + "allocation_attribute__allocation__resources", + ValueFilter, + ) def resource(self, obj): return obj.allocation_attribute.allocation.resources.first().name @@ -345,20 +442,34 @@ def project_pi(self, obj): @admin.register(AllocationAccount) class AllocationAccountAdmin(SimpleHistoryAdmin): - list_display = ('name', 'user', ) + list_display = ( + "name", + "user", + ) @admin.register(AllocationChangeStatusChoice) class AllocationChangeStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) @admin.register(AllocationChangeRequest) class AllocationChangeRequestAdmin(admin.ModelAdmin): - list_display = ('pk', 'allocation', 'status', 'end_date_extension', 'justification', 'notes', ) + list_display = ( + "pk", + "allocation", + "status", + "end_date_extension", + "justification", + "notes", + ) @admin.register(AllocationAttributeChangeRequest) -class AllocationChangeStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('pk', 'allocation_change_request', 'allocation_attribute', 'new_value', ) - +class AllocationAttributeChangeRequestAdmin(admin.ModelAdmin): + list_display = ( + "pk", + "allocation_change_request", + "allocation_attribute", + "new_value", + ) diff --git a/coldfront/core/allocation/apps.py b/coldfront/core/allocation/apps.py index ba463b89e4..480892b636 100644 --- a/coldfront/core/allocation/apps.py +++ b/coldfront/core/allocation/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class AllocationConfig(AppConfig): - name = 'coldfront.core.allocation' + name = "coldfront.core.allocation" diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py index 9700244779..8068ba43a1 100644 --- a/coldfront/core/allocation/forms.py +++ b/coldfront/core/allocation/forms.py @@ -1,73 +1,82 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms from django.db.models.functions import Lower from django.shortcuts import get_object_or_404 -from coldfront.core.allocation.models import (Allocation, AllocationAccount, - AllocationAttributeType, - AllocationAttribute, - AllocationStatusChoice) +from coldfront.core.allocation.models import ( + AllocationAccount, + AllocationAttribute, + AllocationAttributeType, + AllocationStatusChoice, +) from coldfront.core.allocation.utils import get_user_resources from coldfront.core.project.models import Project from coldfront.core.resource.models import Resource, ResourceType from coldfront.core.utils.common import import_from_settings -ALLOCATION_ACCOUNT_ENABLED = import_from_settings( - 'ALLOCATION_ACCOUNT_ENABLED', False) -ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = import_from_settings( - 'ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS', []) +ALLOCATION_ACCOUNT_ENABLED = import_from_settings("ALLOCATION_ACCOUNT_ENABLED", False) +ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = import_from_settings("ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS", []) class AllocationForm(forms.Form): resource = forms.ModelChoiceField(queryset=None, empty_label=None) justification = forms.CharField(widget=forms.Textarea) quantity = forms.IntegerField(required=True) - users = forms.MultipleChoiceField( - widget=forms.CheckboxSelectMultiple, required=False) + users = forms.MultipleChoiceField(widget=forms.CheckboxSelectMultiple, required=False) allocation_account = forms.ChoiceField(required=False) - def __init__(self, request_user, project_pk, *args, **kwargs): + def __init__(self, request_user, project_pk, *args, **kwargs): super().__init__(*args, **kwargs) project_obj = get_object_or_404(Project, pk=project_pk) - self.fields['resource'].queryset = get_user_resources(request_user).order_by(Lower("name")) - self.fields['quantity'].initial = 1 - user_query_set = project_obj.projectuser_set.select_related('user').filter( - status__name__in=['Active', ]).order_by("user__username") + self.fields["resource"].queryset = get_user_resources(request_user).order_by(Lower("name")) + self.fields["quantity"].initial = 1 + user_query_set = ( + project_obj.projectuser_set.select_related("user") + .filter( + status__name__in=[ + "Active", + ] + ) + .order_by("user__username") + ) user_query_set = user_query_set.exclude(user=project_obj.pi) if user_query_set: - self.fields['users'].choices = ((user.user.username, "%s %s (%s)" % ( - user.user.first_name, user.user.last_name, user.user.username)) for user in user_query_set) - self.fields['users'].help_text = '
Select users in your project to add to this allocation.' + self.fields["users"].choices = ( + (user.user.username, "%s %s (%s)" % (user.user.first_name, user.user.last_name, user.user.username)) + for user in user_query_set + ) + self.fields["users"].help_text = "
Select users in your project to add to this allocation." else: - self.fields['users'].widget = forms.HiddenInput() + self.fields["users"].widget = forms.HiddenInput() if ALLOCATION_ACCOUNT_ENABLED: - allocation_accounts = AllocationAccount.objects.filter( - user=request_user) + allocation_accounts = AllocationAccount.objects.filter(user=request_user) if allocation_accounts: - self.fields['allocation_account'].choices = (((account.name, account.name)) - for account in allocation_accounts) + self.fields["allocation_account"].choices = ( + ((account.name, account.name)) for account in allocation_accounts + ) - self.fields['allocation_account'].help_text = '
Select account name to associate with resource. Click here to create an account name!' + self.fields[ + "allocation_account" + ].help_text = '
Select account name to associate with resource. Click here to create an account name!' else: - self.fields['allocation_account'].widget = forms.HiddenInput() + self.fields["allocation_account"].widget = forms.HiddenInput() - self.fields['justification'].help_text = '
Justification for requesting this allocation.' + self.fields["justification"].help_text = "
Justification for requesting this allocation." class AllocationUpdateForm(forms.Form): status = forms.ModelChoiceField( - queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")), empty_label=None) + queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")), empty_label=None + ) start_date = forms.DateField( - label='Start Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) - end_date = forms.DateField( - label='End Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) - description = forms.CharField(max_length=512, - label='Description', - required=False) + label="Start Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) + end_date = forms.DateField(label="End Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False) + description = forms.CharField(max_length=512, label="Description", required=False) is_locked = forms.BooleanField(required=False) is_changeable = forms.BooleanField(required=False) @@ -77,14 +86,16 @@ def clean(self): end_date = cleaned_data.get("end_date") if start_date and end_date and end_date < start_date: - raise forms.ValidationError( - 'End date cannot be less than start date' - ) + raise forms.ValidationError("End date cannot be less than start date") class AllocationInvoiceUpdateForm(forms.Form): - status = forms.ModelChoiceField(queryset=AllocationStatusChoice.objects.filter(name__in=[ - 'Payment Pending', 'Payment Requested', 'Payment Declined', 'Paid']).order_by(Lower("name")), empty_label=None) + status = forms.ModelChoiceField( + queryset=AllocationStatusChoice.objects.filter( + name__in=["Payment Pending", "Payment Requested", "Payment Declined", "Paid"] + ).order_by(Lower("name")), + empty_label=None, + ) class AllocationAddUserForm(forms.Form): @@ -111,49 +122,43 @@ class AllocationAttributeDeleteForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() class AllocationSearchForm(forms.Form): - project = forms.CharField(label='Project Title', - max_length=100, required=False) - username = forms.CharField( - label='Username', max_length=100, required=False) + project = forms.CharField(label="Project Title", max_length=100, required=False) + username = forms.CharField(label="Username", max_length=100, required=False) resource_type = forms.ModelChoiceField( - label='Resource Type', - queryset=ResourceType.objects.all().order_by(Lower("name")), - required=False) + label="Resource Type", queryset=ResourceType.objects.all().order_by(Lower("name")), required=False + ) resource_name = forms.ModelMultipleChoiceField( - label='Resource Name', - queryset=Resource.objects.filter( - is_allocatable=True).order_by(Lower("name")), - required=False) + label="Resource Name", + queryset=Resource.objects.filter(is_allocatable=True).order_by(Lower("name")), + required=False, + ) allocation_attribute_name = forms.ModelChoiceField( - label='Allocation Attribute Name', + label="Allocation Attribute Name", queryset=AllocationAttributeType.objects.all().order_by(Lower("name")), - required=False) - allocation_attribute_value = forms.CharField( - label='Allocation Attribute Value', max_length=100, required=False) - end_date = forms.DateField( - label='End Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + required=False, + ) + allocation_attribute_value = forms.CharField(label="Allocation Attribute Value", max_length=100, required=False) + end_date = forms.DateField(label="End Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False) active_from_now_until_date = forms.DateField( - label='Active from Now Until Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + label="Active from Now Until Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) status = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple, queryset=AllocationStatusChoice.objects.all().order_by(Lower("name")), - required=False) + required=False, + ) show_all_allocations = forms.BooleanField(initial=False, required=False) class AllocationReviewUserForm(forms.Form): ALLOCATION_REVIEW_USER_CHOICES = ( - ('keep_in_allocation_and_project', 'Keep in allocation and project'), - ('keep_in_project_only', 'Remove from this allocation only'), - ('remove_from_project', 'Remove from project'), + ("keep_in_allocation_and_project", "Keep in allocation and project"), + ("keep_in_project_only", "Remove from this allocation only"), + ("remove_from_project", "Remove from project"), ) username = forms.CharField(max_length=150, disabled=True) @@ -166,20 +171,20 @@ class AllocationReviewUserForm(forms.Form): class AllocationInvoiceNoteDeleteForm(forms.Form): pk = forms.IntegerField(required=False, disabled=True) note = forms.CharField(widget=forms.Textarea, disabled=True) - author = forms.CharField( - max_length=512, required=False, disabled=True) + author = forms.CharField(max_length=512, required=False, disabled=True) selected = forms.BooleanField(initial=False, required=False) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() class AllocationAccountForm(forms.ModelForm): - class Meta: model = AllocationAccount - fields = ['name', ] + fields = [ + "name", + ] class AllocationAttributeChangeForm(forms.Form): @@ -190,14 +195,14 @@ class AllocationAttributeChangeForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() def clean(self): cleaned_data = super().clean() - if cleaned_data.get('new_value') != "": - allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get('pk')) - allocation_attribute.value = cleaned_data.get('new_value') + if cleaned_data.get("new_value") != "": + allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get("pk")) + allocation_attribute.value = cleaned_data.get("new_value") allocation_attribute.clean() @@ -210,52 +215,57 @@ class AllocationAttributeUpdateForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['change_pk'].widget = forms.HiddenInput() - self.fields['attribute_pk'].widget = forms.HiddenInput() + self.fields["change_pk"].widget = forms.HiddenInput() + self.fields["attribute_pk"].widget = forms.HiddenInput() def clean(self): cleaned_data = super().clean() - allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get('attribute_pk')) + allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get("attribute_pk")) - allocation_attribute.value = cleaned_data.get('new_value') + allocation_attribute.value = cleaned_data.get("new_value") allocation_attribute.clean() class AllocationChangeForm(forms.Form): - EXTENSION_CHOICES = [ - (0, "No Extension") - ] + EXTENSION_CHOICES = [(0, "No Extension")] for choice in ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS: EXTENSION_CHOICES.append((choice, "{} days".format(choice))) end_date_extension = forms.TypedChoiceField( - label='Request End Date Extension', - choices = EXTENSION_CHOICES, + label="Request End Date Extension", + choices=EXTENSION_CHOICES, coerce=int, required=False, - empty_value=0,) + empty_value=0, + ) justification = forms.CharField( - label='Justification for Changes', + label="Justification for Changes", widget=forms.Textarea, required=True, - help_text='Justification for requesting this allocation change request.') + help_text="Justification for requesting this allocation change request.", + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) class AllocationChangeNoteForm(forms.Form): - notes = forms.CharField( - max_length=512, - label='Notes', - required=False, - widget=forms.Textarea, - help_text="Leave any feedback about the allocation change request.") + notes = forms.CharField( + max_length=512, + label="Notes", + required=False, + widget=forms.Textarea, + help_text="Leave any feedback about the allocation change request.", + ) + class AllocationAttributeCreateForm(forms.ModelForm): class Meta: model = AllocationAttribute - fields = '__all__' + fields = "__all__" + def __init__(self, *args, **kwargs): - super(AllocationAttributeCreateForm, self).__init__(*args, **kwargs) - self.fields['allocation_attribute_type'].queryset = self.fields['allocation_attribute_type'].queryset.order_by(Lower('name')) + super(AllocationAttributeCreateForm, self).__init__(*args, **kwargs) + self.fields["allocation_attribute_type"].queryset = self.fields["allocation_attribute_type"].queryset.order_by( + Lower("name") + ) diff --git a/coldfront/core/allocation/management/__init__.py b/coldfront/core/allocation/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/allocation/management/__init__.py +++ b/coldfront/core/allocation/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/management/commands/__init__.py b/coldfront/core/allocation/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/allocation/management/commands/__init__.py +++ b/coldfront/core/allocation/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/management/commands/add_allocation_defaults.py b/coldfront/core/allocation/management/commands/add_allocation_defaults.py index d2fd6237cb..9f7dc019ba 100644 --- a/coldfront/core/allocation/management/commands/add_allocation_defaults.py +++ b/coldfront/core/allocation/management/commands/add_allocation_defaults.py @@ -1,57 +1,78 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand -from coldfront.core.allocation.models import (AttributeType, - AllocationAttributeType, - AllocationStatusChoice, - AllocationChangeStatusChoice, - AllocationUserStatusChoice) +from coldfront.core.allocation.models import ( + AllocationAttributeType, + AllocationChangeStatusChoice, + AllocationStatusChoice, + AllocationUserStatusChoice, + AttributeType, +) class Command(BaseCommand): - help = 'Add default allocation related choices' + help = "Add default allocation related choices" def handle(self, *args, **options): - - for attribute_type in ('Date', 'Float', 'Int', 'Text', 'Yes/No', 'No', - 'Attribute Expanded Text'): + for attribute_type in ("Date", "Float", "Int", "Text", "Yes/No", "No", "Attribute Expanded Text"): AttributeType.objects.get_or_create(name=attribute_type) - for choice in ('Active', 'Denied', 'Expired', - 'New', 'Paid', 'Payment Pending', - 'Payment Requested', 'Payment Declined', - 'Renewal Requested', 'Revoked', 'Unpaid',): + for choice in ( + "Active", + "Denied", + "Expired", + "New", + "Paid", + "Payment Pending", + "Payment Requested", + "Payment Declined", + "Renewal Requested", + "Revoked", + "Unpaid", + ): AllocationStatusChoice.objects.get_or_create(name=choice) - for choice in ('Pending', 'Approved', 'Denied',): + for choice in ( + "Pending", + "Approved", + "Denied", + ): AllocationChangeStatusChoice.objects.get_or_create(name=choice) - for choice in ('Active', 'Error', 'Removed', 'PendingEULA', 'DeclinedEULA'): + for choice in ("Active", "Error", "Removed", "PendingEULA", "DeclinedEULA"): AllocationUserStatusChoice.objects.get_or_create(name=choice) for name, attribute_type, has_usage, is_private in ( - ('Cloud Account Name', 'Text', False, False), - ('CLOUD_USAGE_NOTIFICATION', 'Yes/No', False, True), - ('Core Usage (Hours)', 'Int', True, False), - ('Accelerator Usage (Hours)', 'Int', True, False), - ('Cloud Storage Quota (TB)', 'Float', True, False), - ('EXPIRE NOTIFICATION', 'Yes/No', False, True), - ('freeipa_group', 'Text', False, False), - ('Is Course?', 'Yes/No', False, True), - ('Paid', 'Float', False, False), - ('Paid Cloud Support (Hours)', 'Float', True, True), - ('Paid Network Support (Hours)', 'Float', True, True), - ('Paid Storage Support (Hours)', 'Float', True, True), - ('Purchase Order Number', 'Int', False, True), - ('send_expiry_email_on_date', 'Date', False, True), - ('slurm_account_name', 'Text', False, False), - ('slurm_specs', 'Attribute Expanded Text', False, True), - ('slurm_specs_attriblist', 'Text', False, True), - ('slurm_user_specs', 'Attribute Expanded Text', False, True), - ('slurm_user_specs_attriblist', 'Text', False, True), - ('Storage Quota (GB)', 'Int', False, False), - ('Storage_Group_Name', 'Text', False, False), - ('SupportersQOS', 'Yes/No', False, False), - ('SupportersQOSExpireDate', 'Date', False, False), + ("Cloud Account Name", "Text", False, False), + ("CLOUD_USAGE_NOTIFICATION", "Yes/No", False, True), + ("Core Usage (Hours)", "Int", True, False), + ("Accelerator Usage (Hours)", "Int", True, False), + ("Cloud Storage Quota (TB)", "Float", True, False), + ("EXPIRE NOTIFICATION", "Yes/No", False, True), + ("freeipa_group", "Text", False, False), + ("Is Course?", "Yes/No", False, True), + ("Paid", "Float", False, False), + ("Paid Cloud Support (Hours)", "Float", True, True), + ("Paid Network Support (Hours)", "Float", True, True), + ("Paid Storage Support (Hours)", "Float", True, True), + ("Purchase Order Number", "Int", False, True), + ("send_expiry_email_on_date", "Date", False, True), + ("slurm_account_name", "Text", False, False), + ("slurm_specs", "Attribute Expanded Text", False, True), + ("slurm_specs_attriblist", "Text", False, True), + ("slurm_user_specs", "Attribute Expanded Text", False, True), + ("slurm_user_specs_attriblist", "Text", False, True), + ("Storage Quota (GB)", "Int", False, False), + ("Storage_Group_Name", "Text", False, False), + ("SupportersQOS", "Yes/No", False, False), + ("SupportersQOSExpireDate", "Date", False, False), ): - AllocationAttributeType.objects.get_or_create(name=name, attribute_type=AttributeType.objects.get( - name=attribute_type), has_usage=has_usage, is_private=is_private) + AllocationAttributeType.objects.get_or_create( + name=name, + attribute_type=AttributeType.objects.get(name=attribute_type), + has_usage=has_usage, + is_private=is_private, + ) diff --git a/coldfront/core/allocation/management/commands/enable_change_requests_globally.py b/coldfront/core/allocation/management/commands/enable_change_requests_globally.py index 15581770a8..912f052524 100644 --- a/coldfront/core/allocation/management/commands/enable_change_requests_globally.py +++ b/coldfront/core/allocation/management/commands/enable_change_requests_globally.py @@ -1,11 +1,16 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand from coldfront.core.allocation.models import Allocation + class Command(BaseCommand): - help = 'Enable change requests on all allocations' + help = "Enable change requests on all allocations" def handle(self, *args, **options): - answer = input(f'Enable change requests on all {Allocation.objects.count()} allocations? [y/N]: ') - if answer == 'Y' or answer == 'y': + answer = input(f"Enable change requests on all {Allocation.objects.count()} allocations? [y/N]: ") + if answer == "Y" or answer == "y": Allocation.objects.all().update(is_changeable=True) diff --git a/coldfront/core/allocation/migrations/0001_initial.py b/coldfront/core/allocation/migrations/0001_initial.py index 22ab23d556..3854f8347d 100644 --- a/coldfront/core/allocation/migrations/0001_initial.py +++ b/coldfront/core/allocation/migrations/0001_initial.py @@ -1,297 +1,640 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0001_initial'), + ("project", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Allocation', + name="Allocation", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('quantity', models.IntegerField(default=1)), - ('start_date', models.DateField(blank=True, null=True)), - ('end_date', models.DateField(blank=True, null=True)), - ('justification', models.TextField()), - ('description', models.CharField(blank=True, max_length=512, null=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("quantity", models.IntegerField(default=1)), + ("start_date", models.DateField(blank=True, null=True)), + ("end_date", models.DateField(blank=True, null=True)), + ("justification", models.TextField()), + ("description", models.CharField(blank=True, max_length=512, null=True)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), ], options={ - 'ordering': ['end_date'], - 'permissions': (('can_view_all_allocations', 'Can view all allocations'), ('can_review_allocation_requests', 'Can review allocation requests'), ('can_manage_invoice', 'Can manage invoice')), + "ordering": ["end_date"], + "permissions": ( + ("can_view_all_allocations", "Can view all allocations"), + ("can_review_allocation_requests", "Can review allocation requests"), + ("can_manage_invoice", "Can manage invoice"), + ), }, ), migrations.CreateModel( - name='AllocationAttribute', + name="AllocationAttribute", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=128)), - ('allocation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=128)), + ( + "allocation", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AllocationAttributeType', + name="AllocationAttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=50)), - ('has_usage', models.BooleanField(default=False)), - ('is_required', models.BooleanField(default=False)), - ('is_unique', models.BooleanField(default=False)), - ('is_private', models.BooleanField(default=True)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=50)), + ("has_usage", models.BooleanField(default=False)), + ("is_required", models.BooleanField(default=False)), + ("is_unique", models.BooleanField(default=False)), + ("is_private", models.BooleanField(default=True)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='AllocationStatusChoice', + name="AllocationStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='AllocationUserStatusChoice', + name="AllocationUserStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='AttributeType', + name="AttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='AllocationAttributeUsage', + name="AllocationAttributeUsage", fields=[ - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('allocation_attribute', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='allocation.AllocationAttribute')), - ('value', models.FloatField(default=0)), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "allocation_attribute", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="allocation.AllocationAttribute", + ), + ), + ("value", models.FloatField(default=0)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='HistoricalAllocationUser', + name="HistoricalAllocationUser", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.Allocation')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationUserStatusChoice', verbose_name='Allocation User Status')), - ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.Allocation", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationUserStatusChoice", + verbose_name="Allocation User Status", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation user', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation user", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocationAttributeUsage', + name="HistoricalAllocationAttributeUsage", fields=[ - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.FloatField(default=0)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation_attribute', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationAttribute')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.FloatField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation_attribute", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationAttribute", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation attribute usage', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation attribute usage", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocationAttributeType', + name="HistoricalAllocationAttributeType", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=50)), - ('has_usage', models.BooleanField(default=False)), - ('is_required', models.BooleanField(default=False)), - ('is_unique', models.BooleanField(default=False)), - ('is_private', models.BooleanField(default=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AttributeType')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=50)), + ("has_usage", models.BooleanField(default=False)), + ("is_required", models.BooleanField(default=False)), + ("is_unique", models.BooleanField(default=False)), + ("is_private", models.BooleanField(default=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AttributeType", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation attribute type', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation attribute type", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocationAttribute', + name="HistoricalAllocationAttribute", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=128)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.Allocation')), - ('allocation_attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationAttributeType')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=128)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.Allocation", + ), + ), + ( + "allocation_attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationAttributeType", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation attribute', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation attribute", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocation', + name="HistoricalAllocation", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('quantity', models.IntegerField(default=1)), - ('start_date', models.DateField(blank=True, null=True)), - ('end_date', models.DateField(blank=True, null=True)), - ('justification', models.TextField()), - ('description', models.CharField(blank=True, max_length=512, null=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationStatusChoice', verbose_name='Status')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("quantity", models.IntegerField(default=1)), + ("start_date", models.DateField(blank=True, null=True)), + ("end_date", models.DateField(blank=True, null=True)), + ("justification", models.TextField()), + ("description", models.CharField(blank=True, max_length=512, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationStatusChoice", + verbose_name="Status", + ), + ), ], options={ - 'verbose_name': 'historical allocation', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='AllocationUserNote', + name="AllocationUserNote", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('is_private', models.BooleanField(default=True)), - ('note', models.TextField()), - ('allocation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation')), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("is_private", models.BooleanField(default=True)), + ("note", models.TextField()), + ( + "allocation", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), + ), + ("author", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AllocationUser', + name="AllocationUser", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('allocation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation')), - ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationUserStatusChoice', verbose_name='Allocation User Status')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "allocation", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), + ), + ( + "status", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="allocation.AllocationUserStatusChoice", + verbose_name="Allocation User Status", + ), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'verbose_name_plural': 'Allocation User Status', + "verbose_name_plural": "Allocation User Status", }, ), migrations.AddField( - model_name='allocationattributetype', - name='attribute_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AttributeType'), + model_name="allocationattributetype", + name="attribute_type", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.AttributeType"), ), migrations.AddField( - model_name='allocationattribute', - name='allocation_attribute_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationAttributeType'), + model_name="allocationattribute", + name="allocation_attribute_type", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="allocation.AllocationAttributeType" + ), ), migrations.CreateModel( - name='AllocationAdminNote', + name="AllocationAdminNote", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('note', models.TextField()), - ('allocation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation')), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("note", models.TextField()), + ( + "allocation", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), + ), + ("author", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AllocationAccount', + name="AllocationAccount", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64, unique=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64, unique=True)), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), ] diff --git a/coldfront/core/allocation/migrations/0002_auto_20190718_1451.py b/coldfront/core/allocation/migrations/0002_auto_20190718_1451.py index db781eb739..89a26c0a11 100644 --- a/coldfront/core/allocation/migrations/0002_auto_20190718_1451.py +++ b/coldfront/core/allocation/migrations/0002_auto_20190718_1451.py @@ -1,31 +1,38 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('resource', '0001_initial'), - ('allocation', '0001_initial'), + ("resource", "0001_initial"), + ("allocation", "0001_initial"), ] operations = [ migrations.AddField( - model_name='allocation', - name='resources', - field=models.ManyToManyField(to='resource.Resource'), + model_name="allocation", + name="resources", + field=models.ManyToManyField(to="resource.Resource"), ), migrations.AddField( - model_name='allocation', - name='status', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationStatusChoice', verbose_name='Status'), + model_name="allocation", + name="status", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="allocation.AllocationStatusChoice", + verbose_name="Status", + ), ), migrations.AlterUniqueTogether( - name='allocationuser', - unique_together={('user', 'allocation')}, + name="allocationuser", + unique_together={("user", "allocation")}, ), ] diff --git a/coldfront/core/allocation/migrations/0003_auto_20191018_1049.py b/coldfront/core/allocation/migrations/0003_auto_20191018_1049.py index 7581bd0a8b..f11fa63fcc 100644 --- a/coldfront/core/allocation/migrations/0003_auto_20191018_1049.py +++ b/coldfront/core/allocation/migrations/0003_auto_20191018_1049.py @@ -1,23 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2019-10-18 14:49 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('allocation', '0002_auto_20190718_1451'), + ("allocation", "0002_auto_20190718_1451"), ] operations = [ migrations.AddField( - model_name='allocation', - name='is_locked', + model_name="allocation", + name="is_locked", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='historicalallocation', - name='is_locked', + model_name="historicalallocation", + name="is_locked", field=models.BooleanField(default=False), ), ] diff --git a/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py b/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py index 8a5b95d0a0..3acb9922e3 100644 --- a/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py +++ b/coldfront/core/allocation/migrations/0004_auto_20211102_1017.py @@ -1,135 +1,263 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.18 on 2021-11-02 14:17 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('allocation', '0003_auto_20191018_1049'), + ("allocation", "0003_auto_20191018_1049"), ] operations = [ migrations.CreateModel( - name='AllocationChangeRequest', + name="AllocationChangeRequest", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('end_date_extension', models.IntegerField(blank=True, null=True)), - ('justification', models.TextField()), - ('notes', models.CharField(blank=True, max_length=512, null=True)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("end_date_extension", models.IntegerField(blank=True, null=True)), + ("justification", models.TextField()), + ("notes", models.CharField(blank=True, max_length=512, null=True)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='AllocationChangeStatusChoice', + name="AllocationChangeStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.AddField( - model_name='allocation', - name='is_changeable', + model_name="allocation", + name="is_changeable", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='allocationattributetype', - name='is_changeable', + model_name="allocationattributetype", + name="is_changeable", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='historicalallocation', - name='is_changeable', + model_name="historicalallocation", + name="is_changeable", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='historicalallocationattributetype', - name='is_changeable', + model_name="historicalallocationattributetype", + name="is_changeable", field=models.BooleanField(default=False), ), migrations.CreateModel( - name='HistoricalAllocationChangeRequest', + name="HistoricalAllocationChangeRequest", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('end_date_extension', models.IntegerField(blank=True, null=True)), - ('justification', models.TextField()), - ('notes', models.CharField(blank=True, max_length=512, null=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.Allocation')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationChangeStatusChoice', verbose_name='Status')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("end_date_extension", models.IntegerField(blank=True, null=True)), + ("justification", models.TextField()), + ("notes", models.CharField(blank=True, max_length=512, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.Allocation", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationChangeStatusChoice", + verbose_name="Status", + ), + ), ], options={ - 'verbose_name': 'historical allocation change request', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation change request", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalAllocationAttributeChangeRequest', + name="HistoricalAllocationAttributeChangeRequest", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('new_value', models.CharField(max_length=128)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('allocation_attribute', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationAttribute')), - ('allocation_change_request', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='allocation.AllocationChangeRequest')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("new_value", models.CharField(max_length=128)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "allocation_attribute", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationAttribute", + ), + ), + ( + "allocation_change_request", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="allocation.AllocationChangeRequest", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical allocation attribute change request', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical allocation attribute change request", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.AddField( - model_name='allocationchangerequest', - name='allocation', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.Allocation'), + model_name="allocationchangerequest", + name="allocation", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.Allocation"), ), migrations.AddField( - model_name='allocationchangerequest', - name='status', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationChangeStatusChoice', verbose_name='Status'), + model_name="allocationchangerequest", + name="status", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="allocation.AllocationChangeStatusChoice", + verbose_name="Status", + ), ), migrations.CreateModel( - name='AllocationAttributeChangeRequest', + name="AllocationAttributeChangeRequest", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('new_value', models.CharField(max_length=128)), - ('allocation_attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationAttribute')), - ('allocation_change_request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='allocation.AllocationChangeRequest')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("new_value", models.CharField(max_length=128)), + ( + "allocation_attribute", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="allocation.AllocationAttribute"), + ), + ( + "allocation_change_request", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="allocation.AllocationChangeRequest" + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py b/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py index 7512c62f29..d0b18e2d05 100644 --- a/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py +++ b/coldfront/core/allocation/migrations/0005_auto_20211117_1413.py @@ -1,18 +1,25 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.24 on 2021-11-17 19:13 from django.db import migrations def create_status_choices(apps, schema_editor): - AllocationChangeStatusChoice = apps.get_model('allocation', "AllocationChangeStatusChoice") - for choice in ('Pending', 'Approved', 'Denied',): + AllocationChangeStatusChoice = apps.get_model("allocation", "AllocationChangeStatusChoice") + for choice in ( + "Pending", + "Approved", + "Denied", + ): AllocationChangeStatusChoice.objects.get_or_create(name=choice) class Migration(migrations.Migration): - dependencies = [ - ('allocation', '0004_auto_20211102_1017'), + ("allocation", "0004_auto_20211102_1017"), ] operations = [ diff --git a/coldfront/core/allocation/migrations/__init__.py b/coldfront/core/allocation/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/allocation/migrations/__init__.py +++ b/coldfront/core/allocation/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index c0122cfdce..3e6ef9bf9d 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -1,10 +1,12 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import importlib import logging from ast import literal_eval from enum import Enum -from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models @@ -13,35 +15,36 @@ from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords +import coldfront.core.attribute_expansion as attribute_expansion from coldfront.core.project.models import Project, ProjectPermission from coldfront.core.resource.models import Resource from coldfront.core.utils.common import import_from_settings -import coldfront.core.attribute_expansion as attribute_expansion logger = logging.getLogger(__name__) -ALLOCATION_ATTRIBUTE_VIEW_LIST = import_from_settings( - 'ALLOCATION_ATTRIBUTE_VIEW_LIST', []) -ALLOCATION_FUNCS_ON_EXPIRE = import_from_settings( - 'ALLOCATION_FUNCS_ON_EXPIRE', []) -ALLOCATION_RESOURCE_ORDERING = import_from_settings( - 'ALLOCATION_RESOURCE_ORDERING', - ['-is_allocatable', 'name']) +ALLOCATION_ATTRIBUTE_VIEW_LIST = import_from_settings("ALLOCATION_ATTRIBUTE_VIEW_LIST", []) +ALLOCATION_FUNCS_ON_EXPIRE = import_from_settings("ALLOCATION_FUNCS_ON_EXPIRE", []) +ALLOCATION_RESOURCE_ORDERING = import_from_settings("ALLOCATION_RESOURCE_ORDERING", ["-is_allocatable", "name"]) + class AllocationPermission(Enum): - """ An allocation permission stores the user and manager fields of a project. """ + """An allocation permission stores the user and manager fields of a project.""" + + USER = "USER" + MANAGER = "MANAGER" - USER = 'USER' - MANAGER = 'MANAGER' class AllocationStatusChoice(TimeStampedModel): - """ A project status choice indicates the status of the project. Examples include Active, Archived, and New. - + """A project status choice indicates the status of the project. Examples include Active, Archived, and New. + Attributes: name (str): name of project status choice """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class AllocationStatusChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -56,9 +59,10 @@ def __str__(self): def natural_key(self): return (self.name,) + class Allocation(TimeStampedModel): - """ An allocation provides users access to a resource. - + """An allocation provides users access to a resource. + Attributes: project (Project): links the project the allocation falls under resources (Resource): links resources that this allocation allocates @@ -73,18 +77,22 @@ class Allocation(TimeStampedModel): """ class Meta: - ordering = ['end_date', ] + ordering = [ + "end_date", + ] permissions = ( - ('can_view_all_allocations', 'Can view all allocations'), - ('can_review_allocation_requests', - 'Can review allocation requests'), - ('can_manage_invoice', 'Can manage invoice'), + ("can_view_all_allocations", "Can view all allocations"), + ("can_review_allocation_requests", "Can review allocation requests"), + ("can_manage_invoice", "Can manage invoice"), ) - project = models.ForeignKey(Project, on_delete=models.CASCADE,) + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + ) resources = models.ManyToManyField(Resource) - status = models.ForeignKey(AllocationStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + status = models.ForeignKey(AllocationStatusChoice, on_delete=models.CASCADE, verbose_name="Status") quantity = models.IntegerField(default=1) start_date = models.DateField(blank=True, null=True) end_date = models.DateField(blank=True, null=True) @@ -95,37 +103,34 @@ class Meta: history = HistoricalRecords() def clean(self): - """ Validates the allocation and raises errors if the allocation is invalid. """ + """Validates the allocation and raises errors if the allocation is invalid.""" - if self.status.name == 'Expired': + if self.status.name == "Expired": if not self.end_date: - raise ValidationError('You have to set the end date.') + raise ValidationError("You have to set the end date.") if self.end_date > datetime.datetime.now().date(): - raise ValidationError( - 'End date cannot be greater than today.') + raise ValidationError("End date cannot be greater than today.") if self.start_date > self.end_date: - raise ValidationError( - 'End date cannot be greater than start date.') + raise ValidationError("End date cannot be greater than start date.") - elif self.status.name == 'Active': + elif self.status.name == "Active": if not self.start_date: - raise ValidationError('You have to set the start date.') + raise ValidationError("You have to set the start date.") if not self.end_date: - raise ValidationError('You have to set the end date.') + raise ValidationError("You have to set the end date.") if self.start_date > self.end_date: - raise ValidationError( - 'Start date cannot be greater than the end date.') + raise ValidationError("Start date cannot be greater than the end date.") def save(self, *args, **kwargs): - """ Saves the project. """ + """Saves the project.""" if self.pk: old_obj = Allocation.objects.get(pk=self.pk) - if old_obj.status.name != self.status.name and self.status.name == 'Expired': + if old_obj.status.name != self.status.name and self.status.name == "Expired": for func_string in ALLOCATION_FUNCS_ON_EXPIRE: func_to_run = import_string(func_string) func_to_run(self.pk) @@ -134,7 +139,7 @@ def save(self, *args, **kwargs): @property def expires_in(self): - """ + """ Returns: int: the number of days until the allocation expires """ @@ -143,35 +148,38 @@ def expires_in(self): @property def get_information(self): - """ + """ Returns: str: the allocation's attribute type, usage out of total value, and usage out of total value as a percentage """ - html_string = '' + html_string = "" for attribute in self.allocationattribute_set.all(): if attribute.allocation_attribute_type.name in ALLOCATION_ATTRIBUTE_VIEW_LIST: - html_string += '%s: %s
' % ( - attribute.allocation_attribute_type.name, attribute.value) + html_string += "%s: %s
" % (attribute.allocation_attribute_type.name, attribute.value) - if hasattr(attribute, 'allocationattributeusage'): + if hasattr(attribute, "allocationattributeusage"): try: - percent = round(float(attribute.allocationattributeusage.value) / - float(attribute.value) * 10000) / 100 + percent = ( + round(float(attribute.allocationattributeusage.value) / float(attribute.value) * 10000) / 100 + ) except ValueError: - percent = 'Invalid Value' - logger.error("Allocation attribute '%s' is not an int but has a usage", - attribute.allocation_attribute_type.name) + percent = "Invalid Value" + logger.error( + "Allocation attribute '%s' is not an int but has a usage", + attribute.allocation_attribute_type.name, + ) except ZeroDivisionError: percent = 100 - logger.error("Allocation attribute '%s' == 0 but has a usage", - attribute.allocation_attribute_type.name) + logger.error( + "Allocation attribute '%s' == 0 but has a usage", attribute.allocation_attribute_type.name + ) - string = '{}: {}/{} ({} %)
'.format( + string = "{}: {}/{} ({} %)
".format( attribute.allocation_attribute_type.name, attribute.allocationattributeusage.value, attribute.value, - percent + percent, ) html_string += string @@ -184,8 +192,7 @@ def get_resources_as_string(self): str: the resources for the allocation """ - return ', '.join([ele.name for ele in self.resources.all().order_by( - *ALLOCATION_RESOURCE_ORDERING)]) + return ", ".join([ele.name for ele in self.resources.all().order_by(*ALLOCATION_RESOURCE_ORDERING)]) @property def get_resources_as_list(self): @@ -194,7 +201,7 @@ def get_resources_as_list(self): list[Resource]: the resources for the allocation """ - return [ele for ele in self.resources.all().order_by('-is_allocatable')] + return [ele for ele in self.resources.all().order_by("-is_allocatable")] @property def get_parent_resource(self): @@ -206,15 +213,13 @@ def get_parent_resource(self): if self.resources.count() == 1: return self.resources.first() else: - parent = self.resources.order_by( - *ALLOCATION_RESOURCE_ORDERING).first() + parent = self.resources.order_by(*ALLOCATION_RESOURCE_ORDERING).first() if parent: return parent # Fallback return self.resources.first() - def get_attribute(self, name, expand=True, typed=True, - extra_allocations=[]): + def get_attribute(self, name, expand=True, typed=True, extra_allocations=[]): """ Params: name (str): name of the allocation attribute type @@ -226,12 +231,10 @@ def get_attribute(self, name, expand=True, typed=True, str: the value of the first attribute found for this allocation with the specified name """ - attr = self.allocationattribute_set.filter( - allocation_attribute_type__name=name).first() + attr = self.allocationattribute_set.filter(allocation_attribute_type__name=name).first() if attr: if expand: - return attr.expanded_value( - extra_allocations=extra_allocations, typed=typed) + return attr.expanded_value(extra_allocations=extra_allocations, typed=typed) else: if typed: return attr.typed_value() @@ -246,8 +249,7 @@ def set_usage(self, name, value): value (float): value to set usage to """ - attr = self.allocationattribute_set.filter( - allocation_attribute_type__name=name).first() + attr = self.allocationattribute_set.filter(allocation_attribute_type__name=name).first() if not attr: return @@ -255,16 +257,14 @@ def set_usage(self, name, value): return if not AllocationAttributeUsage.objects.filter(allocation_attribute=attr).exists(): - usage = AllocationAttributeUsage.objects.create( - allocation_attribute=attr) + usage = AllocationAttributeUsage.objects.create(allocation_attribute=attr) else: usage = attr.allocationattributeusage usage.value = value usage.save() - def get_attribute_list(self, name, expand=True, typed=True, - extra_allocations=[]): + def get_attribute_list(self, name, expand=True, typed=True, extra_allocations=[]): """ Params: name (str): name of the allocation @@ -276,11 +276,9 @@ def get_attribute_list(self, name, expand=True, typed=True, list: the list of values of the attributes found with specified name """ - attr = self.allocationattribute_set.filter( - allocation_attribute_type__name=name).all() + attr = self.allocationattribute_set.filter(allocation_attribute_type__name=name).all() if expand: - return [a.expanded_value(typed=typed, - extra_allocations=extra_allocations) for a in attr] + return [a.expanded_value(typed=typed, extra_allocations=extra_allocations) for a in attr] else: if typed: return [a.typed_value() for a in attr] @@ -297,9 +295,11 @@ def get_attribute_set(self, user): """ if user.is_superuser: - return self.allocationattribute_set.all().order_by('allocation_attribute_type__name') + return self.allocationattribute_set.all().order_by("allocation_attribute_type__name") - return self.allocationattribute_set.filter(allocation_attribute_type__is_private=False).order_by('allocation_attribute_type__name') + return self.allocationattribute_set.filter(allocation_attribute_type__is_private=False).order_by( + "allocation_attribute_type__name" + ) def user_permissions(self, user): """ @@ -321,7 +321,7 @@ def user_permissions(self, user): if ProjectPermission.PI in project_perms or ProjectPermission.MANAGER in project_perms: return [AllocationPermission.USER, AllocationPermission.MANAGER] - if self.allocationuser_set.filter(user=user, status__name__in=['Active', 'New', 'PendingEULA']).exists(): + if self.allocationuser_set.filter(user=user, status__name__in=["Active", "New", "PendingEULA"]).exists(): return [AllocationPermission.USER] return [] @@ -341,18 +341,19 @@ def has_perm(self, user, perm): def __str__(self): return "%s (%s)" % (self.get_parent_resource.name, self.project.pi) - + def get_eula(self): if self.get_resources_as_list: for res in self.get_resources_as_list: - if res.get_attribute(name='eula'): - return res.get_attribute(name='eula') + if res.get_attribute(name="eula"): + return res.get_attribute(name="eula") else: return None + class AllocationAdminNote(TimeStampedModel): - """ An allocation admin note is a note that an admin makes on an allocation. - + """An allocation admin note is a note that an admin makes on an allocation. + Attributes: allocation (Allocation): links the allocation to the note author (User): represents the User class of the admin who authored the note @@ -366,9 +367,10 @@ class AllocationAdminNote(TimeStampedModel): def __str__(self): return self.note + class AllocationUserNote(TimeStampedModel): - """ An allocation user note is a note that an user makes on an allocation. - + """An allocation user note is a note that an user makes on an allocation. + Attributes: allocation (Allocation): links the allocation to the note author (User): represents the User class of the user who authored the note @@ -384,9 +386,10 @@ class AllocationUserNote(TimeStampedModel): def __str__(self): return self.note + class AttributeType(TimeStampedModel): - """ An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. - + """An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. + Attributes: name (str): name of attribute data type """ @@ -397,11 +400,14 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class AllocationAttributeType(TimeStampedModel): - """ An allocation attribute type indicates the type of the attribute. Examples include Cloud Account Name and Core Usage (Hours). - + """An allocation attribute type indicates the type of the attribute. Examples include Cloud Account Name and Core Usage (Hours). + Attributes: attribute_type (AttributeType): indicates the data type of the attribute name (str): name of allocation attribute type @@ -422,61 +428,81 @@ class AllocationAttributeType(TimeStampedModel): history = HistoricalRecords() def __str__(self): - return '%s' % (self.name) + return "%s" % (self.name) class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class AllocationAttribute(TimeStampedModel): - """ An allocation attribute class links an allocation attribute type and an allocation. - + """An allocation attribute class links an allocation attribute type and an allocation. + Attributes: allocation_attribute_type (AllocationAttributeType): attribute type to link allocation (Allocation): allocation to link value (str): value of the allocation attribute """ - allocation_attribute_type = models.ForeignKey( - AllocationAttributeType, on_delete=models.CASCADE) + allocation_attribute_type = models.ForeignKey(AllocationAttributeType, on_delete=models.CASCADE) allocation = models.ForeignKey(Allocation, on_delete=models.CASCADE) value = models.CharField(max_length=128) history = HistoricalRecords() def save(self, *args, **kwargs): - """ Saves the allocation attribute. """ + """Saves the allocation attribute.""" super().save(*args, **kwargs) - if self.allocation_attribute_type.has_usage and not AllocationAttributeUsage.objects.filter(allocation_attribute=self).exists(): - AllocationAttributeUsage.objects.create( - allocation_attribute=self) + if ( + self.allocation_attribute_type.has_usage + and not AllocationAttributeUsage.objects.filter(allocation_attribute=self).exists() + ): + AllocationAttributeUsage.objects.create(allocation_attribute=self) def clean(self): - """ Validates the allocation attribute and raises errors if the allocation attribute is invalid. """ - - if self.allocation_attribute_type.is_unique and self.allocation.allocationattribute_set.filter(allocation_attribute_type=self.allocation_attribute_type).exclude(id=self.pk).exists(): - raise ValidationError("'{}' attribute already exists for this allocation.".format( - self.allocation_attribute_type)) + """Validates the allocation attribute and raises errors if the allocation attribute is invalid.""" + + if ( + self.allocation_attribute_type.is_unique + and self.allocation.allocationattribute_set.filter(allocation_attribute_type=self.allocation_attribute_type) + .exclude(id=self.pk) + .exists() + ): + raise ValidationError( + "'{}' attribute already exists for this allocation.".format(self.allocation_attribute_type) + ) expected_value_type = self.allocation_attribute_type.attribute_type.name.strip() if expected_value_type == "Int" and not isinstance(literal_eval(self.value), int): raise ValidationError( - 'Invalid Value "%s" for "%s". Value must be an integer.' % (self.value, self.allocation_attribute_type.name)) - elif expected_value_type == "Float" and not (isinstance(literal_eval(self.value), float) or isinstance(literal_eval(self.value), int)): + 'Invalid Value "%s" for "%s". Value must be an integer.' + % (self.value, self.allocation_attribute_type.name) + ) + elif expected_value_type == "Float" and not ( + isinstance(literal_eval(self.value), float) or isinstance(literal_eval(self.value), int) + ): raise ValidationError( - 'Invalid Value "%s" for "%s". Value must be a float.' % (self.value, self.allocation_attribute_type.name)) + 'Invalid Value "%s" for "%s". Value must be a float.' + % (self.value, self.allocation_attribute_type.name) + ) elif expected_value_type == "Yes/No" and self.value not in ["Yes", "No"]: raise ValidationError( - 'Invalid Value "%s" for "%s". Allowed inputs are "Yes" or "No".' % (self.value, self.allocation_attribute_type.name)) + 'Invalid Value "%s" for "%s". Allowed inputs are "Yes" or "No".' + % (self.value, self.allocation_attribute_type.name) + ) elif expected_value_type == "Date": try: datetime.datetime.strptime(self.value.strip(), "%Y-%m-%d") except ValueError: raise ValidationError( - 'Invalid Value "%s" for "%s". Date must be in format YYYY-MM-DD' % (self.value, self.allocation_attribute_type.name)) + 'Invalid Value "%s" for "%s". Date must be in format YYYY-MM-DD' + % (self.value, self.allocation_attribute_type.name) + ) def __str__(self): - return '%s' % (self.allocation_attribute_type.name) + return "%s" % (self.allocation_attribute_type.name) def typed_value(self): """ @@ -486,10 +512,8 @@ def typed_value(self): raw_value = self.value atype_name = self.allocation_attribute_type.attribute_type.name - return attribute_expansion.convert_type( - value=raw_value, type_name=atype_name) - - + return attribute_expansion.convert_type(value=raw_value, type_name=atype_name) + def expanded_value(self, extra_allocations=[], typed=True): """ Params: @@ -499,7 +523,7 @@ def expanded_value(self, extra_allocations=[], typed=True): Returns: int, float, str: the value of the attribute after attribute expansion - For attributes with attribute type of 'Attribute Expanded Text' we look for an attribute with same name suffixed with '_attriblist' (this should be ResourceAttribute of the Resource associated with the attribute). If the attriblist attribute is found, we use it to generate a dictionary to use to expand the attribute value, and the expanded value is returned. + For attributes with attribute type of 'Attribute Expanded Text' we look for an attribute with same name suffixed with '_attriblist' (this should be ResourceAttribute of the Resource associated with the attribute). If the attriblist attribute is found, we use it to generate a dictionary to use to expand the attribute value, and the expanded value is returned. If the expansion fails, or if no attriblist attribute is found, or if the attribute type is not 'Attribute Expanded Text', we just return the raw value. """ @@ -509,56 +533,59 @@ def expanded_value(self, extra_allocations=[], typed=True): # Try to convert to python type as per AttributeType raw_value = self.typed_value() - if not attribute_expansion.is_expandable_type( - self.allocation_attribute_type.attribute_type): + if not attribute_expansion.is_expandable_type(self.allocation_attribute_type.attribute_type): # We are not an expandable type, return raw_value return raw_value - allocs = [ self.allocation ] + extra_allocations + allocs = [self.allocation] + extra_allocations resources = list(self.allocation.resources.all()) attrib_name = self.allocation_attribute_type.name attriblist = attribute_expansion.get_attriblist_str( - attribute_name = attrib_name, - resources = resources, - allocations = allocs) + attribute_name=attrib_name, resources=resources, allocations=allocs + ) if not attriblist: # We do not have an attriblist, return raw_value return raw_value expanded = attribute_expansion.expand_attribute( - raw_value = raw_value, - attribute_name = attrib_name, - attriblist_string = attriblist, - resources = resources, - allocations = allocs) + raw_value=raw_value, + attribute_name=attrib_name, + attriblist_string=attriblist, + resources=resources, + allocations=allocs, + ) return expanded - + + class AllocationAttributeUsage(TimeStampedModel): - """ Allocation attribute usage indicates the usage of an allocation attribute. - + """Allocation attribute usage indicates the usage of an allocation attribute. + Attributes: allocation_attribute (AllocationAttribute): links the usage to its allocation attribute value (float): usage value of the allocation attribute """ - allocation_attribute = models.OneToOneField( - AllocationAttribute, on_delete=models.CASCADE, primary_key=True) + allocation_attribute = models.OneToOneField(AllocationAttribute, on_delete=models.CASCADE, primary_key=True) value = models.FloatField(default=0) history = HistoricalRecords() def __str__(self): - return '{}: {}'.format(self.allocation_attribute.allocation_attribute_type.name, self.value) + return "{}: {}".format(self.allocation_attribute.allocation_attribute_type.name, self.value) + class AllocationUserStatusChoice(TimeStampedModel): - """ An allocation user status choice indicates the status of an allocation user. Examples include Active, Error, and Removed. - + """An allocation user status choice indicates the status of an allocation user. Examples include Active, Error, and Removed. + Attributes: name (str): name of the allocation user status choice """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class AllocationUserStatusChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -573,9 +600,10 @@ def __str__(self): def natural_key(self): return (self.name,) + class AllocationUser(TimeStampedModel): - """ An allocation user represents a user on the allocation. - + """An allocation user represents a user on the allocation. + Attributes: allocation (Allocation): links user to its allocation user (User): represents the User object of the allocation user @@ -584,34 +612,36 @@ class AllocationUser(TimeStampedModel): allocation = models.ForeignKey(Allocation, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE) - status = models.ForeignKey(AllocationUserStatusChoice, on_delete=models.CASCADE, - verbose_name='Allocation User Status') + status = models.ForeignKey( + AllocationUserStatusChoice, on_delete=models.CASCADE, verbose_name="Allocation User Status" + ) history = HistoricalRecords() def is_active(self): """Helper function returns True if allocation user status == Active and - allocation status is one of the accepted active states where users - should be considered active and have actions taken on them (i.e. - groups added, accounts created in other systems, etc.)""" + allocation status is one of the accepted active states where users + should be considered active and have actions taken on them (i.e. + groups added, accounts created in other systems, etc.)""" active_allocation_statuses = [ - 'Active', - 'Renewal Requested', + "Active", + "Renewal Requested", ] - return self.status.name == 'Active' and self.allocation.status.name in active_allocation_statuses + return self.status.name == "Active" and self.allocation.status.name in active_allocation_statuses def __str__(self): - return '%s' % (self.user) + return "%s" % (self.user) class Meta: - verbose_name_plural = 'Allocation User Status' - unique_together = ('user', 'allocation') + verbose_name_plural = "Allocation User Status" + unique_together = ("user", "allocation") + class AllocationAccount(TimeStampedModel): - """ An allocation account + """An allocation account #come back to - + Attributes: user (User): represents the User object of the project user name (str): @@ -624,11 +654,14 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class AllocationChangeStatusChoice(TimeStampedModel): - """ An allocation change status choice represents statuses displayed when a user changes their allocation status (for allocations that have their is_changeable attribute set to True). Examples include Expired and Payment Pending. - + """An allocation change status choice represents statuses displayed when a user changes their allocation status (for allocations that have their is_changeable attribute set to True). Examples include Expired and Payment Pending. + Attributes: name (str): status name """ @@ -639,11 +672,14 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class AllocationChangeRequest(TimeStampedModel): - """ An allocation change request represents a request from a PI or manager to change their allocation. - + """An allocation change request represents a request from a PI or manager to change their allocation. + Attributes: allocation (Allocation): represents the allocation to change status (AllocationStatusChoice): represents the allocation status of the changed allocation @@ -652,9 +688,11 @@ class AllocationChangeRequest(TimeStampedModel): notes (str): represents notes for users changing allocations """ - allocation = models.ForeignKey(Allocation, on_delete=models.CASCADE,) - status = models.ForeignKey( - AllocationChangeStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + allocation = models.ForeignKey( + Allocation, + on_delete=models.CASCADE, + ) + status = models.ForeignKey(AllocationChangeStatusChoice, on_delete=models.CASCADE, verbose_name="Status") end_date_extension = models.IntegerField(blank=True, null=True) justification = models.TextField() notes = models.CharField(max_length=512, blank=True, null=True) @@ -675,13 +713,14 @@ def get_parent_resource(self): def __str__(self): return "%s (%s)" % (self.get_parent_resource.name, self.allocation.project.pi) + class AllocationAttributeChangeRequest(TimeStampedModel): - """ An allocation attribute change request represents a request from a PI/ manager to change their allocation attribute. - + """An allocation attribute change request represents a request from a PI/ manager to change their allocation attribute. + Attributes: allocation_change_request (AllocationChangeRequest): links the change request from which this attribute change is derived allocation_attribute (AllocationAttribute): represents the allocation_attribute to change - new_value (str): new value of allocation attribute + new_value (str): new value of allocation attribute """ allocation_change_request = models.ForeignKey(AllocationChangeRequest, on_delete=models.CASCADE) @@ -690,4 +729,4 @@ class AllocationAttributeChangeRequest(TimeStampedModel): history = HistoricalRecords() def __str__(self): - return '%s' % (self.allocation_attribute.allocation_attribute_type.name) + return "%s" % (self.allocation_attribute.allocation_attribute_type.name) diff --git a/coldfront/core/allocation/signals.py b/coldfront/core/allocation/signals.py index 51277b184f..fb70bbc86d 100644 --- a/coldfront/core/allocation/signals.py +++ b/coldfront/core/allocation/signals.py @@ -1,19 +1,23 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import django.dispatch allocation_new = django.dispatch.Signal() - #providing_args=["allocation_pk"] +# providing_args=["allocation_pk"] allocation_activate = django.dispatch.Signal() - #providing_args=["allocation_pk"] +# providing_args=["allocation_pk"] allocation_disable = django.dispatch.Signal() - #providing_args=["allocation_pk"] +# providing_args=["allocation_pk"] allocation_activate_user = django.dispatch.Signal() - #providing_args=["allocation_user_pk"] +# providing_args=["allocation_user_pk"] allocation_remove_user = django.dispatch.Signal() - #providing_args=["allocation_user_pk"] +# providing_args=["allocation_user_pk"] allocation_change_approved = django.dispatch.Signal() - #providing_args=["allocation_pk", "allocation_change_pk"] +# providing_args=["allocation_pk", "allocation_change_pk"] allocation_change_created = django.dispatch.Signal() - #providing_args=["allocation_pk", "allocation_change_pk"] +# providing_args=["allocation_pk", "allocation_change_pk"] diff --git a/coldfront/core/allocation/tasks.py b/coldfront/core/allocation/tasks.py index e6371e442b..883420aee0 100644 --- a/coldfront/core/allocation/tasks.py +++ b/coldfront/core/allocation/tasks.py @@ -1,10 +1,13 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime + # import the logging library import logging -from coldfront.core.allocation.models import (Allocation, - AllocationStatusChoice, AllocationUserStatusChoice) -from coldfront.core.allocation.utils import get_user_resources +from coldfront.core.allocation.models import Allocation, AllocationStatusChoice, AllocationUserStatusChoice from coldfront.core.user.models import User from coldfront.core.utils.common import import_from_settings from coldfront.core.utils.mail import send_email_template @@ -13,34 +16,42 @@ logger = logging.getLogger(__name__) -CENTER_NAME = import_from_settings('CENTER_NAME') -CENTER_BASE_URL = import_from_settings('CENTER_BASE_URL') -CENTER_PROJECT_RENEWAL_HELP_URL = import_from_settings( - 'CENTER_PROJECT_RENEWAL_HELP_URL') -EMAIL_SENDER = import_from_settings('EMAIL_SENDER') -EMAIL_OPT_OUT_INSTRUCTION_URL = import_from_settings( - 'EMAIL_OPT_OUT_INSTRUCTION_URL') -EMAIL_SIGNATURE = import_from_settings('EMAIL_SIGNATURE') +CENTER_NAME = import_from_settings("CENTER_NAME") +CENTER_BASE_URL = import_from_settings("CENTER_BASE_URL") +CENTER_PROJECT_RENEWAL_HELP_URL = import_from_settings("CENTER_PROJECT_RENEWAL_HELP_URL") +EMAIL_SENDER = import_from_settings("EMAIL_SENDER") +EMAIL_OPT_OUT_INSTRUCTION_URL = import_from_settings("EMAIL_OPT_OUT_INSTRUCTION_URL") +EMAIL_SIGNATURE = import_from_settings("EMAIL_SIGNATURE") EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS = import_from_settings( - 'EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS', [7, ]) + "EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS", + [ + 7, + ], +) -EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = import_from_settings('EMAIL_ADMINS_ON_ALLOCATION_EXPIRE') -EMAIL_ADMIN_LIST = import_from_settings('EMAIL_ADMIN_LIST') +EMAIL_ADMINS_ON_ALLOCATION_EXPIRE = import_from_settings("EMAIL_ADMINS_ON_ALLOCATION_EXPIRE") +EMAIL_ADMIN_LIST = import_from_settings("EMAIL_ADMIN_LIST") -EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = import_from_settings('EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT') +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = import_from_settings("EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT") -def update_statuses(): - expired_status_choice = AllocationStatusChoice.objects.get( - name='Expired') +def update_statuses(): + expired_status_choice = AllocationStatusChoice.objects.get(name="Expired") allocations_to_expire = Allocation.objects.filter( - status__name__in=['Active','Payment Pending','Payment Requested', 'Unpaid',], end_date__lt=datetime.datetime.now().date()) + status__name__in=[ + "Active", + "Payment Pending", + "Payment Requested", + "Unpaid", + ], + end_date__lt=datetime.datetime.now().date(), + ) for sub_obj in allocations_to_expire: sub_obj.status = expired_status_choice sub_obj.save() - logger.info('Allocations set to expired: {}'.format( - allocations_to_expire.count())) + logger.info("Allocations set to expired: {}".format(allocations_to_expire.count())) + def send_eula_reminders(): for allocation in Allocation.objects.all(): @@ -48,186 +59,205 @@ def send_eula_reminders(): email_receiver_list = [] for allocation_user in allocation.allocationuser_set.all(): projectuser = allocation.project.projectuser_set.get(user=allocation_user.user) - if allocation_user.status == AllocationUserStatusChoice.objects.get(name='PendingEULA') and projectuser.status.name == 'Active': + if ( + allocation_user.status == AllocationUserStatusChoice.objects.get(name="PendingEULA") + and projectuser.status.name == "Active" + ): should_send = (projectuser.enable_notifications) or (EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT) if should_send and allocation_user.user.email not in email_receiver_list: email_receiver_list.append(allocation_user.user.email) template_context = { - 'center_name': CENTER_NAME, - 'resource': allocation.get_parent_resource, - 'url': f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/review-eula', - 'signature': EMAIL_SIGNATURE + "center_name": CENTER_NAME, + "resource": allocation.get_parent_resource, + "url": f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/review-eula", + "signature": EMAIL_SIGNATURE, } if email_receiver_list: - send_email_template(f'Reminder: Agree to EULA for {allocation}', 'email/allocation_eula_reminder.txt', template_context, EMAIL_SENDER, email_receiver_list) - logger.debug(f'Allocation(s) EULA reminder sent to users {email_receiver_list}.') + send_email_template( + f"Reminder: Agree to EULA for {allocation}", + "email/allocation_eula_reminder.txt", + template_context, + EMAIL_SENDER, + email_receiver_list, + ) + logger.debug(f"Allocation(s) EULA reminder sent to users {email_receiver_list}.") + def send_expiry_emails(): - #Allocations expiring soon + # Allocations expiring soon for user in User.objects.all(): projectdict = {} expirationdict = {} email_receiver_list = [] for days_remaining in sorted(set(EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS)): + expring_in_days = (datetime.datetime.today() + datetime.timedelta(days=days_remaining)).date() - expring_in_days = (datetime.datetime.today( - ) + datetime.timedelta(days=days_remaining)).date() - for allocationuser in user.allocationuser_set.all(): allocation = allocationuser.allocation - if (((allocation.status.name in ['Active', 'Payment Pending', 'Payment Requested', 'Unpaid']) and (allocation.end_date == expring_in_days))): - - project_url = f'{CENTER_BASE_URL.strip("/")}/{"project"}/{allocation.project.pk}/' + if (allocation.status.name in ["Active", "Payment Pending", "Payment Requested", "Unpaid"]) and ( + allocation.end_date == expring_in_days + ): + project_url = f"{CENTER_BASE_URL.strip('/')}/{'project'}/{allocation.project.pk}/" - if (allocation.status.name in ['Payment Pending', 'Payment Requested', 'Unpaid']): - allocation_renew_url = f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/' + if allocation.status.name in ["Payment Pending", "Payment Requested", "Unpaid"]: + allocation_renew_url = f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/" else: - allocation_renew_url = f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/{"renew"}/' + allocation_renew_url = f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/{'renew'}/" resource_name = allocation.get_parent_resource.name template_context = { - 'center_name': CENTER_NAME, - 'expring_in_days': days_remaining, - 'project_dict': projectdict, - 'expiration_dict': expirationdict, - 'expiration_days': sorted(set(EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS)), - 'project_renewal_help_url': CENTER_PROJECT_RENEWAL_HELP_URL, - 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL, - 'signature': EMAIL_SIGNATURE + "center_name": CENTER_NAME, + "expring_in_days": days_remaining, + "project_dict": projectdict, + "expiration_dict": expirationdict, + "expiration_days": sorted(set(EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS)), + "project_renewal_help_url": CENTER_PROJECT_RENEWAL_HELP_URL, + "opt_out_instruction_url": EMAIL_OPT_OUT_INSTRUCTION_URL, + "signature": EMAIL_SIGNATURE, } - + expire_notification = allocation.allocationattribute_set.filter( - allocation_attribute_type__name='EXPIRE NOTIFICATION').first() - if expire_notification and expire_notification.value == 'No': + allocation_attribute_type__name="EXPIRE NOTIFICATION" + ).first() + if expire_notification and expire_notification.value == "No": continue cloud_usage_notification = allocation.allocationattribute_set.filter( - allocation_attribute_type__name='CLOUD_USAGE_NOTIFICATION').first() - if cloud_usage_notification and cloud_usage_notification.value == 'No': + allocation_attribute_type__name="CLOUD_USAGE_NOTIFICATION" + ).first() + if cloud_usage_notification and cloud_usage_notification.value == "No": continue - for projectuser in allocation.project.projectuser_set.filter(user=user, status__name='Active'): - if ((projectuser.enable_notifications) and - (allocationuser.user == user and allocationuser.status.name == 'Active')): - - if (user.email not in email_receiver_list): + for projectuser in allocation.project.projectuser_set.filter(user=user, status__name="Active"): + if (projectuser.enable_notifications) and ( + allocationuser.user == user and allocationuser.status.name == "Active" + ): + if user.email not in email_receiver_list: email_receiver_list.append(user.email) if days_remaining not in expirationdict: expirationdict[days_remaining] = [] - expirationdict[days_remaining].append((project_url, allocation_renew_url, resource_name)) + expirationdict[days_remaining].append( + (project_url, allocation_renew_url, resource_name) + ) else: - expirationdict[days_remaining].append((project_url, allocation_renew_url, resource_name)) + expirationdict[days_remaining].append( + (project_url, allocation_renew_url, resource_name) + ) if allocation.project.title not in projectdict: - projectdict[allocation.project.title] = (project_url, allocation.project.pi.username,) - - if email_receiver_list: + projectdict[allocation.project.title] = ( + project_url, + allocation.project.pi.username, + ) - send_email_template(f'Your access to {CENTER_NAME}\'s resources is expiring soon', - 'email/allocation_expiring.txt', - template_context, - EMAIL_SENDER, - email_receiver_list - ) + if email_receiver_list: + send_email_template( + f"Your access to {CENTER_NAME}'s resources is expiring soon", + "email/allocation_expiring.txt", + template_context, + EMAIL_SENDER, + email_receiver_list, + ) - logger.debug(f'Allocation(s) expiring in soon, email sent to user {user}.') + logger.debug(f"Allocation(s) expiring in soon, email sent to user {user}.") - #Allocations expired + # Allocations expired admin_projectdict = {} admin_allocationdict = {} for user in User.objects.all(): projectdict = {} allocationdict = {} email_receiver_list = [] - + expring_in_days = (datetime.datetime.today() + datetime.timedelta(days=-1)).date() - + for allocationuser in user.allocationuser_set.all(): allocation = allocationuser.allocation - if (allocation.end_date == expring_in_days): - - project_url = f'{CENTER_BASE_URL.strip("/")}/{"project"}/{allocation.project.pk}/' + if allocation.end_date == expring_in_days: + project_url = f"{CENTER_BASE_URL.strip('/')}/{'project'}/{allocation.project.pk}/" - allocation_renew_url = f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/{"renew"}/' + allocation_renew_url = f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/{'renew'}/" - allocation_url = f'{CENTER_BASE_URL.strip("/")}/{"allocation"}/{allocation.pk}/' + allocation_url = f"{CENTER_BASE_URL.strip('/')}/{'allocation'}/{allocation.pk}/" resource_name = allocation.get_parent_resource.name template_context = { - 'center_name': CENTER_NAME, - 'project_dict': projectdict, - 'allocation_dict': allocationdict, - 'project_renewal_help_url': CENTER_PROJECT_RENEWAL_HELP_URL, - 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL, - 'signature': EMAIL_SIGNATURE + "center_name": CENTER_NAME, + "project_dict": projectdict, + "allocation_dict": allocationdict, + "project_renewal_help_url": CENTER_PROJECT_RENEWAL_HELP_URL, + "opt_out_instruction_url": EMAIL_OPT_OUT_INSTRUCTION_URL, + "signature": EMAIL_SIGNATURE, } expire_notification = allocation.allocationattribute_set.filter( - allocation_attribute_type__name='EXPIRE NOTIFICATION').first() - - for projectuser in allocation.project.projectuser_set.filter(user=user, status__name='Active'): - if ((projectuser.enable_notifications) and - (allocationuser.user == user and allocationuser.status.name == 'Active')): - - if expire_notification and expire_notification.value == 'Yes': - - if (user.email not in email_receiver_list): + allocation_attribute_type__name="EXPIRE NOTIFICATION" + ).first() + + for projectuser in allocation.project.projectuser_set.filter(user=user, status__name="Active"): + if (projectuser.enable_notifications) and ( + allocationuser.user == user and allocationuser.status.name == "Active" + ): + if expire_notification and expire_notification.value == "Yes": + if user.email not in email_receiver_list: email_receiver_list.append(user.email) if project_url not in allocationdict: - allocationdict[project_url] = [] - allocationdict[project_url].append({allocation_renew_url : resource_name}) + allocationdict[project_url] = [] + allocationdict[project_url].append({allocation_renew_url: resource_name}) else: - if {allocation_renew_url : resource_name} not in allocationdict[project_url]: - allocationdict[project_url].append({allocation_renew_url : resource_name}) + if {allocation_renew_url: resource_name} not in allocationdict[project_url]: + allocationdict[project_url].append({allocation_renew_url: resource_name}) if allocation.project.title not in projectdict: projectdict[allocation.project.title] = (project_url, allocation.project.pi.username) if EMAIL_ADMINS_ON_ALLOCATION_EXPIRE: - if project_url not in admin_allocationdict: - admin_allocationdict[project_url] = [] - admin_allocationdict[project_url].append({allocation_url : resource_name}) + admin_allocationdict[project_url] = [] + admin_allocationdict[project_url].append({allocation_url: resource_name}) else: - if {allocation_url : resource_name} not in admin_allocationdict[project_url]: - admin_allocationdict[project_url].append({allocation_url : resource_name}) + if {allocation_url: resource_name} not in admin_allocationdict[project_url]: + admin_allocationdict[project_url].append({allocation_url: resource_name}) if allocation.project.title not in admin_projectdict: - admin_projectdict[allocation.project.title] = (project_url, allocation.project.pi.username) + admin_projectdict[allocation.project.title] = ( + project_url, + allocation.project.pi.username, + ) - if email_receiver_list: + send_email_template( + "Your access to resource(s) have expired", + "email/allocation_expired.txt", + template_context, + EMAIL_SENDER, + email_receiver_list, + ) - send_email_template('Your access to resource(s) have expired', - 'email/allocation_expired.txt', - template_context, - EMAIL_SENDER, - email_receiver_list - ) - - logger.debug(f'Allocation(s) expired email sent to user {user}.') + logger.debug(f"Allocation(s) expired email sent to user {user}.") if EMAIL_ADMINS_ON_ALLOCATION_EXPIRE: - if admin_projectdict: - admin_template_context = { - 'project_dict': admin_projectdict, - 'allocation_dict': admin_allocationdict, - 'signature': EMAIL_SIGNATURE - } - - send_email_template('Allocation(s) have expired', - 'email/admin_allocation_expired.txt', - admin_template_context, - EMAIL_SENDER, - [EMAIL_ADMIN_LIST,] - ) \ No newline at end of file + "project_dict": admin_projectdict, + "allocation_dict": admin_allocationdict, + "signature": EMAIL_SIGNATURE, + } + + send_email_template( + "Allocation(s) have expired", + "email/admin_allocation_expired.txt", + admin_template_context, + EMAIL_SENDER, + [ + EMAIL_ADMIN_LIST, + ], + ) diff --git a/coldfront/core/allocation/test_models.py b/coldfront/core/allocation/test_models.py index d41703f491..3adef2d49f 100644 --- a/coldfront/core/allocation/test_models.py +++ b/coldfront/core/allocation/test_models.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + """Unit tests for the allocation models""" from django.test import TestCase @@ -12,12 +16,9 @@ class AllocationModelTests(TestCase): def setUpTestData(cls): """Set up project to test model properties and methods""" cls.allocation = AllocationFactory() - cls.allocation.resources.add(ResourceFactory(name='holylfs07/tier1')) + cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) def test_allocation_str(self): """test that allocation str method returns correct string""" - allocation_str = '%s (%s)' % ( - self.allocation.get_parent_resource.name, - self.allocation.project.pi - ) + allocation_str = "%s (%s)" % (self.allocation.get_parent_resource.name, self.allocation.project.pi) self.assertEqual(str(self.allocation), allocation_str) diff --git a/coldfront/core/allocation/test_views.py b/coldfront/core/allocation/test_views.py index b60aa927b2..e01b870c79 100644 --- a/coldfront/core/allocation/test_views.py +++ b/coldfront/core/allocation/test_views.py @@ -1,42 +1,47 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from django.test import TestCase from django.urls import reverse +from coldfront.core.allocation.models import ( + AllocationChangeRequest, + AllocationChangeStatusChoice, +) from coldfront.core.test_helpers import utils from coldfront.core.test_helpers.factories import ( - UserFactory, - ProjectFactory, - ResourceFactory, + AllocationAttributeFactory, + AllocationAttributeTypeFactory, + AllocationChangeRequestFactory, AllocationFactory, - ProjectUserFactory, + AllocationStatusChoiceFactory, AllocationUserFactory, - AllocationAttributeFactory, + ProjectFactory, ProjectStatusChoiceFactory, + ProjectUserFactory, ProjectUserRoleChoiceFactory, - AllocationStatusChoiceFactory, - AllocationAttributeTypeFactory, - AllocationChangeRequestFactory, -) -from coldfront.core.allocation.models import ( - AllocationChangeRequest, - AllocationChangeStatusChoice, + ResourceFactory, + UserFactory, ) logging.disable(logging.CRITICAL) BACKEND = "django.contrib.auth.backends.ModelBackend" + class AllocationViewBaseTest(TestCase): """Base class for allocation view tests.""" @classmethod def setUpTestData(cls): """Test Data setup for all allocation view tests.""" - AllocationStatusChoiceFactory(name='New') - cls.project = ProjectFactory(status=ProjectStatusChoiceFactory(name='Active')) + AllocationStatusChoiceFactory(name="New") + cls.project = ProjectFactory(status=ProjectStatusChoiceFactory(name="Active")) cls.allocation = AllocationFactory(project=cls.project) - cls.allocation.resources.add(ResourceFactory(name='holylfs07/tier1')) + cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) # create allocation user that belongs to project allocation_user = AllocationUserFactory(allocation=cls.allocation) cls.allocation_user = allocation_user.user @@ -45,14 +50,14 @@ def setUpTestData(cls): proj_nonallocation_user = ProjectUserFactory() cls.proj_nonallocation_user = proj_nonallocation_user.user cls.admin_user = UserFactory(is_staff=True, is_superuser=True) - manager_role = ProjectUserRoleChoiceFactory(name='Manager') + manager_role = ProjectUserRoleChoiceFactory(name="Manager") pi_user = ProjectUserFactory(user=cls.project.pi, project=cls.project, role=manager_role) cls.pi_user = pi_user.user # make a quota TB allocation attribute AllocationAttributeFactory( allocation=cls.allocation, - value = 100, - allocation_attribute_type=AllocationAttributeTypeFactory(name='Storage Quota (TB)'), + value=100, + allocation_attribute_type=AllocationAttributeTypeFactory(name="Storage Quota (TB)"), ) def allocation_access_tstbase(self, url): @@ -73,15 +78,15 @@ def setUpTestData(cls): super(AllocationListViewTest, cls).setUpTestData() cls.additional_allocations = [AllocationFactory() for i in list(range(100))] for allocation in cls.additional_allocations: - allocation.resources.add(ResourceFactory(name='holylfs09/tier1')) + allocation.resources.add(ResourceFactory(name="holylfs09/tier1")) cls.nonproj_nonallocation_user = UserFactory() def test_allocation_list_access_admin(self): """Confirm that AllocationList access control works for admin""" - self.allocation_access_tstbase('/allocation/') + self.allocation_access_tstbase("/allocation/") # confirm that show_all_allocations=on enables admin to view all allocations response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 25) + self.assertEqual(len(response.context["allocation_list"]), 25) def test_allocation_list_access_pi(self): """Confirm that AllocationList access control works for pi @@ -91,7 +96,7 @@ def test_allocation_list_access_pi(self): # confirm that show_all_allocations=on enables admin to view all allocations self.client.force_login(self.pi_user, backend=BACKEND) response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 1) + self.assertEqual(len(response.context["allocation_list"]), 1) def test_allocation_list_access_user(self): """Confirm that AllocationList access control works for non-pi users @@ -102,26 +107,24 @@ def test_allocation_list_access_user(self): # contains only the user's allocations self.client.force_login(self.allocation_user, backend=BACKEND) response = self.client.get("/allocation/") - self.assertEqual(len(response.context['allocation_list']), 1) + self.assertEqual(len(response.context["allocation_list"]), 1) response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 1) + self.assertEqual(len(response.context["allocation_list"]), 1) # nonallocation user belonging to project can't see allocation self.client.force_login(self.nonproj_nonallocation_user, backend=BACKEND) response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 0) + self.assertEqual(len(response.context["allocation_list"]), 0) # nonallocation user belonging to project can't see allocation self.client.force_login(self.proj_nonallocation_user, backend=BACKEND) response = self.client.get("/allocation/?show_all_allocations=on") - self.assertEqual(len(response.context['allocation_list']), 0) + self.assertEqual(len(response.context["allocation_list"]), 0) def test_allocation_list_search_admin(self): """Confirm that AllocationList search works for admin""" self.client.force_login(self.admin_user, backend=BACKEND) - base_url = '/allocation/?show_all_allocations=on' - response = self.client.get( - base_url + f'&resource_name={self.allocation.resources.first().pk}' - ) - self.assertEqual(len(response.context['allocation_list']), 1) + base_url = "/allocation/?show_all_allocations=on" + response = self.client.get(base_url + f"&resource_name={self.allocation.resources.first().pk}") + self.assertEqual(len(response.context["allocation_list"]), 1) class AllocationChangeDetailViewTest(AllocationViewBaseTest): @@ -133,21 +136,17 @@ def setUp(self): AllocationChangeRequestFactory(id=2, allocation=self.allocation) def test_allocationchangedetailview_access(self): - response = self.client.get( - reverse('allocation-change-detail', kwargs={'pk': 2}) - ) + response = self.client.get(reverse("allocation-change-detail", kwargs={"pk": 2})) self.assertEqual(response.status_code, 200) def test_allocationchangedetailview_post_deny(self): """Test that posting to AllocationChangeDetailView with action=deny changes the status of the AllocationChangeRequest to denied.""" - param = {'action': 'deny'} - response = self.client.post( - reverse('allocation-change-detail', kwargs={'pk': 2}), param, follow=True - ) + param = {"action": "deny"} + response = self.client.post(reverse("allocation-change-detail", kwargs={"pk": 2}), param, follow=True) self.assertEqual(response.status_code, 200) alloc_change_req = AllocationChangeRequest.objects.get(pk=2) - denied_status_id = AllocationChangeStatusChoice.objects.get(name='Denied').pk + denied_status_id = AllocationChangeStatusChoice.objects.get(name="Denied").pk self.assertEqual(alloc_change_req.status_id, denied_status_id) @@ -157,15 +156,15 @@ class AllocationChangeViewTest(AllocationViewBaseTest): def setUp(self): self.client.force_login(self.admin_user, backend=BACKEND) self.post_data = { - 'justification': 'just a test', - 'attributeform-0-new_value': '', - 'attributeform-INITIAL_FORMS': '1', - 'attributeform-MAX_NUM_FORMS': '1', - 'attributeform-MIN_NUM_FORMS': '0', - 'attributeform-TOTAL_FORMS': '1', - 'end_date_extension': 0, + "justification": "just a test", + "attributeform-0-new_value": "", + "attributeform-INITIAL_FORMS": "1", + "attributeform-MAX_NUM_FORMS": "1", + "attributeform-MIN_NUM_FORMS": "0", + "attributeform-TOTAL_FORMS": "1", + "end_date_extension": 0, } - self.url = '/allocation/1/change-request' + self.url = "/allocation/1/change-request" def test_allocationchangeview_access(self): """Test get request""" @@ -176,15 +175,11 @@ def test_allocationchangeview_access(self): def test_allocationchangeview_post_extension(self): """Test post request to extend end date""" - self.post_data['end_date_extension'] = 90 + self.post_data["end_date_extension"] = 90 self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) - response = self.client.post( - '/allocation/1/change-request', data=self.post_data, follow=True - ) + response = self.client.post("/allocation/1/change-request", data=self.post_data, follow=True) self.assertEqual(response.status_code, 200) - self.assertContains( - response, "Allocation change request successfully submitted." - ) + self.assertContains(response, "Allocation change request successfully submitted.") self.assertEqual(len(AllocationChangeRequest.objects.all()), 1) def test_allocationchangeview_post_no_change(self): @@ -192,9 +187,7 @@ def test_allocationchangeview_post_no_change(self): self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) - response = self.client.post( - '/allocation/1/change-request', data=self.post_data, follow=True - ) + response = self.client.post("/allocation/1/change-request", data=self.post_data, follow=True) self.assertEqual(response.status_code, 200) self.assertContains(response, "You must request a change") self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) @@ -204,7 +197,7 @@ class AllocationDetailViewTest(AllocationViewBaseTest): """Tests for AllocationDetailView""" def setUp(self): - self.url = f'/allocation/{self.allocation.pk}/' + self.url = f"/allocation/{self.allocation.pk}/" def test_allocation_detail_access(self): self.allocation_access_tstbase(self.url) @@ -214,63 +207,45 @@ def test_allocation_detail_access(self): def test_allocationdetail_requestchange_button(self): """Test visibility of "Request Change" button for different user types""" - utils.page_contains_for_user(self, self.admin_user, self.url, 'Request Change') - utils.page_contains_for_user(self, self.pi_user, self.url, 'Request Change') - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Request Change' - ) + utils.page_contains_for_user(self, self.admin_user, self.url, "Request Change") + utils.page_contains_for_user(self, self.pi_user, self.url, "Request Change") + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Request Change") def test_allocationattribute_button_visibility(self): """Test visibility of "Add Attribute" button for different user types""" # admin - utils.page_contains_for_user( - self, self.admin_user, self.url, 'Add Allocation Attribute' - ) - utils.page_contains_for_user( - self, self.admin_user, self.url, 'Delete Allocation Attribute' - ) + utils.page_contains_for_user(self, self.admin_user, self.url, "Add Allocation Attribute") + utils.page_contains_for_user(self, self.admin_user, self.url, "Delete Allocation Attribute") # pi - utils.page_does_not_contain_for_user( - self, self.pi_user, self.url, 'Add Allocation Attribute' - ) - utils.page_does_not_contain_for_user( - self, self.pi_user, self.url, 'Delete Allocation Attribute' - ) + utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Add Allocation Attribute") + utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Delete Allocation Attribute") # allocation user - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Add Allocation Attribute' - ) - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Delete Allocation Attribute' - ) + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Add Allocation Attribute") + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Delete Allocation Attribute") def test_allocationuser_button_visibility(self): """Test visibility of "Add/Remove Users" buttons for different user types""" # admin - utils.page_contains_for_user(self, self.admin_user, self.url, 'Add Users') - utils.page_contains_for_user(self, self.admin_user, self.url, 'Remove Users') + utils.page_contains_for_user(self, self.admin_user, self.url, "Add Users") + utils.page_contains_for_user(self, self.admin_user, self.url, "Remove Users") # pi - utils.page_contains_for_user(self, self.pi_user, self.url, 'Add Users') - utils.page_contains_for_user(self, self.pi_user, self.url, 'Remove Users') + utils.page_contains_for_user(self, self.pi_user, self.url, "Add Users") + utils.page_contains_for_user(self, self.pi_user, self.url, "Remove Users") # allocation user - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Add Users' - ) - utils.page_does_not_contain_for_user( - self, self.allocation_user, self.url, 'Remove Users' - ) + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Add Users") + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Remove Users") class AllocationCreateViewTest(AllocationViewBaseTest): """Tests for the AllocationCreateView""" def setUp(self): - self.url = f'/allocation/project/{self.project.pk}/create' # url for AllocationCreateView + self.url = f"/allocation/project/{self.project.pk}/create" # url for AllocationCreateView self.client.force_login(self.pi_user) self.post_data = { - 'justification': 'test justification', - 'quantity': '1', - 'resource': f'{self.allocation.resources.first().pk}', + "justification": "test justification", + "quantity": "1", + "resource": f"{self.allocation.resources.first().pk}", } def test_allocationcreateview_access(self): @@ -289,7 +264,7 @@ def test_allocationcreateview_post(self): def test_allocationcreateview_post_zeroquantity(self): """Test POST to the AllocationCreateView""" - self.post_data['quantity'] = '0' + self.post_data["quantity"] = "0" self.assertEqual(len(self.project.allocation_set.all()), 1) response = self.client.post(self.url, data=self.post_data, follow=True) self.assertEqual(response.status_code, 200) @@ -301,12 +276,12 @@ class AllocationAddUsersViewTest(AllocationViewBaseTest): """Tests for the AllocationAddUsersView""" def setUp(self): - self.url = f'/allocation/{self.allocation.pk}/add-users' + self.url = f"/allocation/{self.allocation.pk}/add-users" def test_allocationaddusersview_access(self): """Test access to AllocationAddUsersView""" self.allocation_access_tstbase(self.url) - no_permission = 'You do not have permission to add users to the allocation.' + no_permission = "You do not have permission to add users to the allocation." self.client.force_login(self.admin_user, backend=BACKEND) admin_response = self.client.get(self.url) @@ -325,7 +300,7 @@ class AllocationRemoveUsersViewTest(AllocationViewBaseTest): """Tests for the AllocationRemoveUsersView""" def setUp(self): - self.url = f'/allocation/{self.allocation.pk}/remove-users' + self.url = f"/allocation/{self.allocation.pk}/remove-users" def test_allocationremoveusersview_access(self): self.allocation_access_tstbase(self.url) @@ -335,7 +310,7 @@ class AllocationChangeListViewTest(AllocationViewBaseTest): """Tests for the AllocationChangeListView""" def setUp(self): - self.url = '/allocation/change-list' + self.url = "/allocation/change-list" def test_allocationchangelistview_access(self): self.allocation_access_tstbase(self.url) @@ -345,7 +320,7 @@ class AllocationNoteCreateViewTest(AllocationViewBaseTest): """Tests for the AllocationNoteCreateView""" def setUp(self): - self.url = f'/allocation/{self.allocation.pk}/allocationnote/add' + self.url = f"/allocation/{self.allocation.pk}/allocationnote/add" def test_allocationnotecreateview_access(self): self.allocation_access_tstbase(self.url) diff --git a/coldfront/core/allocation/urls.py b/coldfront/core/allocation/urls.py index da27b22143..e9fd08f740 100644 --- a/coldfront/core/allocation/urls.py +++ b/coldfront/core/allocation/urls.py @@ -1,53 +1,73 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.allocation.views as allocation_views from coldfront.config.core import ALLOCATION_EULA_ENABLE - urlpatterns = [ - path('', allocation_views.AllocationListView.as_view(), name='allocation-list'), - path('project//create', - allocation_views.AllocationCreateView.as_view(), name='allocation-create'), - path('/', allocation_views.AllocationDetailView.as_view(), - name='allocation-detail'), - path('change-request//', allocation_views.AllocationChangeDetailView.as_view(), - name='allocation-change-detail'), - path('/delete-attribute-change', allocation_views.AllocationChangeDeleteAttributeView.as_view(), - name='allocation-attribute-change-delete'), - path('/add-users', allocation_views.AllocationAddUsersView.as_view(), - name='allocation-add-users'), - path('/remove-users', allocation_views.AllocationRemoveUsersView.as_view(), - name='allocation-remove-users'), - path('request-list', allocation_views.AllocationRequestListView.as_view(), - name='allocation-request-list'), - path('change-list', allocation_views.AllocationChangeListView.as_view(), - name='allocation-change-list'), - path('/renew', allocation_views.AllocationRenewView.as_view(), - name='allocation-renew'), - path('/allocationattribute/add', - allocation_views.AllocationAttributeCreateView.as_view(), name='allocation-attribute-add'), - path('/change-request', - allocation_views.AllocationChangeView.as_view(), name='allocation-change'), - path('/allocationattribute/delete', - allocation_views.AllocationAttributeDeleteView.as_view(), name='allocation-attribute-delete'), - path('/allocationnote/add', - allocation_views.AllocationNoteCreateView.as_view(), name='allocation-note-add'), - path('allocation-invoice-list', allocation_views.AllocationInvoiceListView.as_view(), - name='allocation-invoice-list'), - path('/invoice/', allocation_views.AllocationInvoiceDetailView.as_view(), - name='allocation-invoice-detail'), - path('allocation//add-invoice-note', - allocation_views.AllocationAddInvoiceNoteView.as_view(), name='allocation-add-invoice-note'), - path('allocation-invoice-note//update', - allocation_views.AllocationUpdateInvoiceNoteView.as_view(), name='allocation-update-invoice-note'), - path('allocation//invoice/delete/', - allocation_views.AllocationDeleteInvoiceNoteView.as_view(), name='allocation-delete-invoice-note'), - path('add-allocation-account/', allocation_views.AllocationAccountCreateView.as_view(), - name='add-allocation-account'), - path('allocation-account-list/', allocation_views.AllocationAccountListView.as_view(), - name='allocation-account-list'), + path("", allocation_views.AllocationListView.as_view(), name="allocation-list"), + path("project//create", allocation_views.AllocationCreateView.as_view(), name="allocation-create"), + path("/", allocation_views.AllocationDetailView.as_view(), name="allocation-detail"), + path( + "change-request//", + allocation_views.AllocationChangeDetailView.as_view(), + name="allocation-change-detail", + ), + path( + "/delete-attribute-change", + allocation_views.AllocationChangeDeleteAttributeView.as_view(), + name="allocation-attribute-change-delete", + ), + path("/add-users", allocation_views.AllocationAddUsersView.as_view(), name="allocation-add-users"), + path("/remove-users", allocation_views.AllocationRemoveUsersView.as_view(), name="allocation-remove-users"), + path("request-list", allocation_views.AllocationRequestListView.as_view(), name="allocation-request-list"), + path("change-list", allocation_views.AllocationChangeListView.as_view(), name="allocation-change-list"), + path("/renew", allocation_views.AllocationRenewView.as_view(), name="allocation-renew"), + path( + "/allocationattribute/add", + allocation_views.AllocationAttributeCreateView.as_view(), + name="allocation-attribute-add", + ), + path("/change-request", allocation_views.AllocationChangeView.as_view(), name="allocation-change"), + path( + "/allocationattribute/delete", + allocation_views.AllocationAttributeDeleteView.as_view(), + name="allocation-attribute-delete", + ), + path( + "/allocationnote/add", allocation_views.AllocationNoteCreateView.as_view(), name="allocation-note-add" + ), + path( + "allocation-invoice-list", allocation_views.AllocationInvoiceListView.as_view(), name="allocation-invoice-list" + ), + path("/invoice/", allocation_views.AllocationInvoiceDetailView.as_view(), name="allocation-invoice-detail"), + path( + "allocation//add-invoice-note", + allocation_views.AllocationAddInvoiceNoteView.as_view(), + name="allocation-add-invoice-note", + ), + path( + "allocation-invoice-note//update", + allocation_views.AllocationUpdateInvoiceNoteView.as_view(), + name="allocation-update-invoice-note", + ), + path( + "allocation//invoice/delete/", + allocation_views.AllocationDeleteInvoiceNoteView.as_view(), + name="allocation-delete-invoice-note", + ), + path( + "add-allocation-account/", allocation_views.AllocationAccountCreateView.as_view(), name="add-allocation-account" + ), + path( + "allocation-account-list/", allocation_views.AllocationAccountListView.as_view(), name="allocation-account-list" + ), ] if ALLOCATION_EULA_ENABLE: - urlpatterns.append(path('/review-eula', allocation_views.AllocationEULAView.as_view(), - name='allocation-review-eula')) + urlpatterns.append( + path("/review-eula", allocation_views.AllocationEULAView.as_view(), name="allocation-review-eula") + ) diff --git a/coldfront/core/allocation/utils.py b/coldfront/core/allocation/utils.py index b166997b74..d091d0aad4 100644 --- a/coldfront/core/allocation/utils.py +++ b/coldfront/core/allocation/utils.py @@ -1,23 +1,25 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.db.models import Q -from coldfront.core.allocation.models import (AllocationUser, - AllocationUserStatusChoice) +from coldfront.core.allocation.models import AllocationUser, AllocationUserStatusChoice from coldfront.core.resource.models import Resource def set_allocation_user_status_to_error(allocation_user_pk): allocation_user_obj = AllocationUser.objects.get(pk=allocation_user_pk) - error_status = AllocationUserStatusChoice.objects.get(name='Error') + error_status = AllocationUserStatusChoice.objects.get(name="Error") allocation_user_obj.status = error_status allocation_user_obj.save() def generate_guauge_data_from_usage(name, value, usage): - label = "%s: %.2f of %.2f" % (name, usage, value) try: - percent = (usage/value)*100 + percent = (usage / value) * 100 except ZeroDivisionError: percent = 100 except ValueError: @@ -34,28 +36,33 @@ def generate_guauge_data_from_usage(name, value, usage): "columns": [ [label, percent], ], - "type": 'gauge', - "colors": { - label: color - } + "type": "gauge", + "colors": {label: color}, } return usage_data def get_user_resources(user_obj): - if user_obj.is_superuser: resources = Resource.objects.filter(is_allocatable=True) else: resources = Resource.objects.filter( - Q(is_allocatable=True) & - Q(is_available=True) & - (Q(is_public=True) | Q(allowed_groups__in=user_obj.groups.all()) | Q(allowed_users__in=[user_obj,])) + Q(is_allocatable=True) + & Q(is_available=True) + & ( + Q(is_public=True) + | Q(allowed_groups__in=user_obj.groups.all()) + | Q( + allowed_users__in=[ + user_obj, + ] + ) + ) ).distinct() return resources def test_allocation_function(allocation_pk): - print('test_allocation_function', allocation_pk) + print("test_allocation_function", allocation_pk) diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index fc75614bc2..02c129d267 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -1,520 +1,585 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime import logging from datetime import date -import json from dateutil.relativedelta import relativedelta from django import forms from django.contrib import messages from django.contrib.auth import get_user_model -from django.contrib.auth.models import User from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from django.db.models.query import QuerySet from django.forms import formset_factory -from django.http import HttpResponseRedirect, JsonResponse, HttpResponseBadRequest +from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse, reverse_lazy from django.utils.html import format_html, mark_safe from django.views import View from django.views.generic import ListView, TemplateView from django.views.generic.edit import CreateView, FormView, UpdateView -from coldfront.config.core import ALLOCATION_EULA_ENABLE -from coldfront.core.allocation.forms import (AllocationAccountForm, - AllocationAddUserForm, - AllocationAttributeCreateForm, - AllocationAttributeDeleteForm, - AllocationChangeForm, - AllocationChangeNoteForm, - AllocationAttributeChangeForm, - AllocationAttributeUpdateForm, - AllocationForm, - AllocationInvoiceNoteDeleteForm, - AllocationInvoiceUpdateForm, - AllocationRemoveUserForm, - AllocationReviewUserForm, - AllocationSearchForm, - AllocationUpdateForm) -from coldfront.core.allocation.models import (Allocation, - AllocationPermission, - AllocationAccount, - AllocationAttribute, - AllocationAttributeType, - AllocationChangeRequest, - AllocationChangeStatusChoice, - AllocationAttributeChangeRequest, - AllocationStatusChoice, - AllocationUser, - AllocationUserNote, - AllocationUserStatusChoice) -from coldfront.core.allocation.signals import (allocation_new, - allocation_activate, - allocation_activate_user, - allocation_disable, - allocation_remove_user, - allocation_change_created, - allocation_change_approved,) -from coldfront.core.allocation.utils import (generate_guauge_data_from_usage, - get_user_resources) -from coldfront.core.project.models import (Project, ProjectUser, ProjectPermission, ProjectUserRoleChoice, - ProjectUserStatusChoice) +from coldfront.config.core import ALLOCATION_EULA_ENABLE +from coldfront.core.allocation.forms import ( + AllocationAccountForm, + AllocationAddUserForm, + AllocationAttributeChangeForm, + AllocationAttributeCreateForm, + AllocationAttributeDeleteForm, + AllocationAttributeUpdateForm, + AllocationChangeForm, + AllocationChangeNoteForm, + AllocationForm, + AllocationInvoiceNoteDeleteForm, + AllocationInvoiceUpdateForm, + AllocationRemoveUserForm, + AllocationReviewUserForm, + AllocationSearchForm, + AllocationUpdateForm, +) +from coldfront.core.allocation.models import ( + Allocation, + AllocationAccount, + AllocationAttribute, + AllocationAttributeChangeRequest, + AllocationAttributeType, + AllocationChangeRequest, + AllocationChangeStatusChoice, + AllocationPermission, + AllocationStatusChoice, + AllocationUser, + AllocationUserNote, + AllocationUserStatusChoice, +) +from coldfront.core.allocation.signals import ( + allocation_activate, + allocation_activate_user, + allocation_change_approved, + allocation_change_created, + allocation_disable, + allocation_new, + allocation_remove_user, +) +from coldfront.core.allocation.utils import generate_guauge_data_from_usage, get_user_resources +from coldfront.core.project.models import Project, ProjectPermission, ProjectUser, ProjectUserStatusChoice from coldfront.core.resource.models import Resource from coldfront.core.utils.common import get_domain_url, import_from_settings -from coldfront.core.utils.mail import (build_link, - send_allocation_admin_email, - send_allocation_customer_email, - send_allocation_eula_customer_email, - send_email, - send_email_template) - -ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings( - 'ALLOCATION_ENABLE_ALLOCATION_RENEWAL', True) -ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings( - 'ALLOCATION_DEFAULT_ALLOCATION_LENGTH', 365) +from coldfront.core.utils.mail import ( + build_link, + send_allocation_admin_email, + send_allocation_customer_email, + send_allocation_eula_customer_email, + send_email_template, +) + +ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings("ALLOCATION_ENABLE_ALLOCATION_RENEWAL", True) +ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings("ALLOCATION_DEFAULT_ALLOCATION_LENGTH", 365) ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = import_from_settings( - 'ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT', True) + "ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT", True +) -PROJECT_ENABLE_PROJECT_REVIEW = import_from_settings( - 'PROJECT_ENABLE_PROJECT_REVIEW', False) -INVOICE_ENABLED = import_from_settings('INVOICE_ENABLED', False) +PROJECT_ENABLE_PROJECT_REVIEW = import_from_settings("PROJECT_ENABLE_PROJECT_REVIEW", False) +INVOICE_ENABLED = import_from_settings("INVOICE_ENABLED", False) if INVOICE_ENABLED: - INVOICE_DEFAULT_STATUS = import_from_settings( - 'INVOICE_DEFAULT_STATUS', 'Pending Payment') - -ALLOCATION_ACCOUNT_ENABLED = import_from_settings( - 'ALLOCATION_ACCOUNT_ENABLED', False) -ALLOCATION_ACCOUNT_MAPPING = import_from_settings( - 'ALLOCATION_ACCOUNT_MAPPING', {}) - -EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = import_from_settings( - 'EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT', False) -EMAIL_ALLOCATION_EULA_CONFIRMATIONS = import_from_settings( - 'EMAIL_ALLOCATION_EULA_CONFIRMATIONS',False) + INVOICE_DEFAULT_STATUS = import_from_settings("INVOICE_DEFAULT_STATUS", "Pending Payment") + +ALLOCATION_ACCOUNT_ENABLED = import_from_settings("ALLOCATION_ACCOUNT_ENABLED", False) +ALLOCATION_ACCOUNT_MAPPING = import_from_settings("ALLOCATION_ACCOUNT_MAPPING", {}) + +EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT = import_from_settings("EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT", False) +EMAIL_ALLOCATION_EULA_CONFIRMATIONS = import_from_settings("EMAIL_ALLOCATION_EULA_CONFIRMATIONS", False) EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS = import_from_settings( - 'EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS',False) -EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA = import_from_settings( - 'EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA',False) + "EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS", False +) +EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA = import_from_settings("EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA", False) logger = logging.getLogger(__name__) + class AllocationDetailView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Allocation - template_name = 'allocation/allocation_detail.html' - context_object_name = 'allocation' + template_name = "allocation/allocation_detail.html" + context_object_name = "allocation" def test_func(self): - """ UserPassesTestMixin Tests""" - pk = self.kwargs.get('pk') + """UserPassesTestMixin Tests""" + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - if self.request.user.has_perm('allocation.can_view_all_allocations'): + if self.request.user.has_perm("allocation.can_view_all_allocations"): return True return allocation_obj.has_perm(self.request.user, AllocationPermission.USER) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed',]).order_by('user__username') - + status__name__in=[ + "Removed", + ] + ).order_by("user__username") + if ALLOCATION_EULA_ENABLE: user_in_allocation = allocation_users.filter(user=self.request.user).exists() - context['user_in_allocation'] = user_in_allocation + context["user_in_allocation"] = user_in_allocation if user_in_allocation: - allocation_user_status = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user).status - if allocation_obj.status.name == 'Active' and allocation_user_status.name == 'PendingEula': + allocation_user_status = get_object_or_404( + AllocationUser, allocation=allocation_obj, user=self.request.user + ).status + if allocation_obj.status.name == "Active" and allocation_user_status.name == "PendingEula": messages.info(self.request, "This allocation is active, but you must agree to the EULA to use it!") - - context['eulas'] = allocation_obj.get_eula() - context['res'] = allocation_obj.get_parent_resource.pk - context['res_obj'] = allocation_obj.get_parent_resource + + context["eulas"] = allocation_obj.get_eula() + context["res"] = allocation_obj.get_parent_resource.pk + context["res_obj"] = allocation_obj.get_parent_resource # set visible usage attributes alloc_attr_set = allocation_obj.get_attribute_set(self.request.user) - attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, 'allocationattributeusage')] + attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, "allocationattributeusage")] attributes = alloc_attr_set - allocation_changes = allocation_obj.allocationchangerequest_set.all().order_by('-pk') + allocation_changes = allocation_obj.allocationchangerequest_set.all().order_by("-pk") guage_data = [] invalid_attributes = [] for attribute in attributes_with_usage: try: - guage_data.append(generate_guauge_data_from_usage( - attribute.allocation_attribute_type.name, - float(attribute.value), - float(attribute.allocationattributeusage.value) - )) + guage_data.append( + generate_guauge_data_from_usage( + attribute.allocation_attribute_type.name, + float(attribute.value), + float(attribute.allocationattributeusage.value), + ) + ) except ValueError: - logger.error("Allocation attribute '%s' is not an int but has a usage", - attribute.allocation_attribute_type.name) + logger.error( + "Allocation attribute '%s' is not an int but has a usage", attribute.allocation_attribute_type.name + ) invalid_attributes.append(attribute) for a in invalid_attributes: attributes_with_usage.remove(a) - context['allocation_users'] = allocation_users - context['guage_data'] = guage_data - context['attributes_with_usage'] = attributes_with_usage - context['attributes'] = attributes - context['allocation_changes'] = allocation_changes + context["allocation_users"] = allocation_users + context["guage_data"] = guage_data + context["attributes_with_usage"] = attributes_with_usage + context["attributes"] = attributes + context["allocation_changes"] = allocation_changes # Can the user update the project? - context['is_allowed_to_update_project'] = allocation_obj.project.has_perm(self.request.user, ProjectPermission.UPDATE) + context["is_allowed_to_update_project"] = allocation_obj.project.has_perm( + self.request.user, ProjectPermission.UPDATE + ) noteset = allocation_obj.allocationusernote_set notes = noteset.all() if self.request.user.is_superuser else noteset.filter(is_private=False) - context['notes'] = notes - context['ALLOCATION_ENABLE_ALLOCATION_RENEWAL'] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL + context["notes"] = notes + context["ALLOCATION_ENABLE_ALLOCATION_RENEWAL"] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL return context def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) initial_data = { - 'status': allocation_obj.status, - 'end_date': allocation_obj.end_date, - 'start_date': allocation_obj.start_date, - 'description': allocation_obj.description, - 'is_locked': allocation_obj.is_locked, - 'is_changeable': allocation_obj.is_changeable, + "status": allocation_obj.status, + "end_date": allocation_obj.end_date, + "start_date": allocation_obj.start_date, + "description": allocation_obj.description, + "is_locked": allocation_obj.is_locked, + "is_changeable": allocation_obj.is_changeable, } form = AllocationUpdateForm(initial=initial_data) if not self.request.user.is_superuser: - form.fields['is_locked'].disabled = True - form.fields['is_changeable'].disabled = True + form.fields["is_locked"].disabled = True + form.fields["is_changeable"].disabled = True context = self.get_context_data() - context['form'] = form - context['allocation'] = allocation_obj + context["form"] = form + context["allocation"] = allocation_obj return self.render_to_response(context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).order_by('user__username') - + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]).order_by( + "user__username" + ) + if not self.request.user.is_superuser: - messages.success( - request, 'You do not have permission to update the allocation') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) - + messages.success(request, "You do not have permission to update the allocation") + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + initial_data = { - 'status': allocation_obj.status, - 'end_date': allocation_obj.end_date, - 'start_date': allocation_obj.start_date, - 'description': allocation_obj.description, - 'is_locked': allocation_obj.is_locked, - 'is_changeable': allocation_obj.is_changeable, + "status": allocation_obj.status, + "end_date": allocation_obj.end_date, + "start_date": allocation_obj.start_date, + "description": allocation_obj.description, + "is_locked": allocation_obj.is_locked, + "is_changeable": allocation_obj.is_changeable, } form = AllocationUpdateForm(request.POST, initial=initial_data) if not form.is_valid(): context = self.get_context_data() - context['form'] = form - context['allocation'] = allocation_obj + context["form"] = form + context["allocation"] = allocation_obj return render(request, self.template_name, context) - action = request.POST.get('action') - if action not in ['update', 'approve', 'auto-approve', 'deny']: + action = request.POST.get("action") + if action not in ["update", "approve", "auto-approve", "deny"]: return HttpResponseBadRequest("Invalid request") form_data = form.cleaned_data old_status = allocation_obj.status.name - if action in ['update', 'approve', 'deny']: - allocation_obj.end_date = form_data.get('end_date') - allocation_obj.start_date = form_data.get('start_date') - allocation_obj.description = form_data.get('description') - allocation_obj.is_locked = form_data.get('is_locked') - allocation_obj.is_changeable = form_data.get('is_changeable') - allocation_obj.status = form_data.get('status') + if action in ["update", "approve", "deny"]: + allocation_obj.end_date = form_data.get("end_date") + allocation_obj.start_date = form_data.get("start_date") + allocation_obj.description = form_data.get("description") + allocation_obj.is_locked = form_data.get("is_locked") + allocation_obj.is_changeable = form_data.get("is_changeable") + allocation_obj.status = form_data.get("status") - if 'approve' in action: - allocation_obj.status = AllocationStatusChoice.objects.get(name='Active') - elif action == 'deny': - allocation_obj.status = AllocationStatusChoice.objects.get(name='Denied') + if "approve" in action: + allocation_obj.status = AllocationStatusChoice.objects.get(name="Active") + elif action == "deny": + allocation_obj.status = AllocationStatusChoice.objects.get(name="Denied") - if old_status != 'Active' == allocation_obj.status.name: + if old_status != "Active" == allocation_obj.status.name: if not allocation_obj.start_date: allocation_obj.start_date = datetime.datetime.now() - if 'approve' in action or not allocation_obj.end_date: - allocation_obj.end_date = datetime.datetime.now() + relativedelta(days=ALLOCATION_DEFAULT_ALLOCATION_LENGTH) + if "approve" in action or not allocation_obj.end_date: + allocation_obj.end_date = datetime.datetime.now() + relativedelta( + days=ALLOCATION_DEFAULT_ALLOCATION_LENGTH + ) allocation_obj.save() - allocation_activate.send( - sender=self.__class__, allocation_pk=allocation_obj.pk) - allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=['Removed', 'Error', 'DeclinedEULA', 'PendingEULA']) + allocation_activate.send(sender=self.__class__, allocation_pk=allocation_obj.pk) + allocation_users = allocation_obj.allocationuser_set.exclude( + status__name__in=["Removed", "Error", "DeclinedEULA", "PendingEULA"] + ) for allocation_user in allocation_users: - allocation_activate_user.send( - sender=self.__class__, allocation_user_pk=allocation_user.pk) + allocation_activate_user.send(sender=self.__class__, allocation_user_pk=allocation_user.pk) - send_allocation_customer_email(allocation_obj, 'Allocation Activated', 'email/allocation_activated.txt', domain_url=get_domain_url(self.request)) - if action != 'auto-approve': - messages.success(request, 'Allocation Activated!') + send_allocation_customer_email( + allocation_obj, + "Allocation Activated", + "email/allocation_activated.txt", + domain_url=get_domain_url(self.request), + ) + if action != "auto-approve": + messages.success(request, "Allocation Activated!") - elif old_status != allocation_obj.status.name in ['Denied', 'New', 'Revoked']: + elif old_status != allocation_obj.status.name in ["Denied", "New", "Revoked"]: allocation_obj.start_date = None allocation_obj.end_date = None allocation_obj.save() - if allocation_obj.status.name in ['Denied', 'Revoked']: - allocation_disable.send( - sender=self.__class__, allocation_pk=allocation_obj.pk) - allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed', 'Error']) + if allocation_obj.status.name in ["Denied", "Revoked"]: + allocation_disable.send(sender=self.__class__, allocation_pk=allocation_obj.pk) + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed", "Error"]) for allocation_user in allocation_users: - allocation_remove_user.send( - sender=self.__class__, allocation_user_pk=allocation_user.pk) - if allocation_obj.status.name == 'Denied': - send_allocation_customer_email(allocation_obj, 'Allocation Denied', 'email/allocation_denied.txt', domain_url=get_domain_url(self.request)) - messages.success(request, 'Allocation Denied!') - elif allocation_obj.status.name == 'Revoked': - send_allocation_customer_email(allocation_obj, 'Allocation Revoked', 'email/allocation_revoked.txt', domain_url=get_domain_url(self.request)) - messages.success(request, 'Allocation Revoked!') + allocation_remove_user.send(sender=self.__class__, allocation_user_pk=allocation_user.pk) + if allocation_obj.status.name == "Denied": + send_allocation_customer_email( + allocation_obj, + "Allocation Denied", + "email/allocation_denied.txt", + domain_url=get_domain_url(self.request), + ) + messages.success(request, "Allocation Denied!") + elif allocation_obj.status.name == "Revoked": + send_allocation_customer_email( + allocation_obj, + "Allocation Revoked", + "email/allocation_revoked.txt", + domain_url=get_domain_url(self.request), + ) + messages.success(request, "Allocation Revoked!") else: - messages.success(request, 'Allocation updated!') + messages.success(request, "Allocation updated!") else: - messages.success(request, 'Allocation updated!') + messages.success(request, "Allocation updated!") allocation_obj.save() - - if action == 'auto-approve': - messages.success(request, 'Allocation to {} has been ACTIVATED for {} {} ({})'.format( - allocation_obj.get_parent_resource, - allocation_obj.project.pi.first_name, - allocation_obj.project.pi.last_name, - allocation_obj.project.pi.username) + if action == "auto-approve": + messages.success( + request, + "Allocation to {} has been ACTIVATED for {} {} ({})".format( + allocation_obj.get_parent_resource, + allocation_obj.project.pi.first_name, + allocation_obj.project.pi.last_name, + allocation_obj.project.pi.username, + ), ) - return HttpResponseRedirect(reverse('allocation-request-list')) + return HttpResponseRedirect(reverse("allocation-request-list")) + + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) class AllocationEULAView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Allocation - template_name = 'allocation/allocation_review_eula.html' - context_object_name = 'allocation-eula' + template_name = "allocation/allocation_review_eula.html" + context_object_name = "allocation-eula" def test_func(self): - """ UserPassesTestMixin Tests""" - pk = self.kwargs.get('pk') + """UserPassesTestMixin Tests""" + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - if self.request.user.has_perm('allocation.can_view_all_allocations'): + if self.request.user.has_perm("allocation.can_view_all_allocations"): return True return allocation_obj.has_perm(self.request.user, AllocationPermission.USER) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed',]).order_by('user__username') + status__name__in=[ + "Removed", + ] + ).order_by("user__username") user_in_allocation = allocation_users.filter(user=self.request.user).exists() - - context['allocation'] = allocation_obj.pk - context['eulas'] = allocation_obj.get_eula() - context['res'] = allocation_obj.get_parent_resource.pk - context['res_obj'] = allocation_obj.get_parent_resource + + context["allocation"] = allocation_obj.pk + context["eulas"] = allocation_obj.get_eula() + context["res"] = allocation_obj.get_parent_resource.pk + context["res_obj"] = allocation_obj.get_parent_resource if user_in_allocation and ALLOCATION_EULA_ENABLE: - allocation_user_status = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user).status + allocation_user_status = get_object_or_404( + AllocationUser, allocation=allocation_obj, user=self.request.user + ).status context["allocation_user_status"] = allocation_user_status.name - context['last_updated'] = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user).modified + context["last_updated"] = get_object_or_404( + AllocationUser, allocation=allocation_obj, user=self.request.user + ).modified return context def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') - allocation_obj = get_object_or_404(Allocation, pk=pk) + pk = self.kwargs.get("pk") + get_object_or_404(Allocation, pk=pk) context = self.get_context_data() return self.render_to_response(context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed','DeclinedEULA']).order_by('user__username') + status__name__in=["Removed", "DeclinedEULA"] + ).order_by("user__username") user_in_allocation = allocation_users.filter(user=self.request.user).exists() if user_in_allocation: allocation_user_obj = get_object_or_404(AllocationUser, allocation=allocation_obj, user=self.request.user) - action = request.POST.get('action') - if action not in ['accepted_eula', 'declined_eula']: + action = request.POST.get("action") + if action not in ["accepted_eula", "declined_eula"]: return HttpResponseBadRequest("Invalid request") - if 'accepted_eula' in action: - allocation_user_obj.status = AllocationUserStatusChoice.objects.get(name='Active') + if "accepted_eula" in action: + allocation_user_obj.status = AllocationUserStatusChoice.objects.get(name="Active") messages.success(self.request, "EULA Accepted!") if EMAIL_ALLOCATION_EULA_CONFIRMATIONS: - project_user = allocation_user_obj.allocation.project.projectuser_set.get(user=allocation_user_obj.user) + project_user = allocation_user_obj.allocation.project.projectuser_set.get( + user=allocation_user_obj.user + ) if EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT or project_user.enable_notifications: - send_allocation_eula_customer_email(allocation_user_obj, - "EULA accepted", - 'email/allocation_eula_accepted.txt', - cc_managers=EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS, - include_eula=EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA) - if (allocation_obj.status == AllocationStatusChoice.objects.get(name='Active')): - allocation_activate_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) - elif action == 'declined_eula': - allocation_user_obj.status = AllocationUserStatusChoice.objects.get(name='DeclinedEULA') - messages.warning(self.request, "You did not agree to the EULA and were removed from the allocation. To access this allocation, your PI will have to re-add you.") + send_allocation_eula_customer_email( + allocation_user_obj, + "EULA accepted", + "email/allocation_eula_accepted.txt", + cc_managers=EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS, + include_eula=EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA, + ) + if allocation_obj.status == AllocationStatusChoice.objects.get(name="Active"): + allocation_activate_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) + elif action == "declined_eula": + allocation_user_obj.status = AllocationUserStatusChoice.objects.get(name="DeclinedEULA") + messages.warning( + self.request, + "You did not agree to the EULA and were removed from the allocation. To access this allocation, your PI will have to re-add you.", + ) if EMAIL_ALLOCATION_EULA_CONFIRMATIONS: - project_user = allocation_user_obj.allocation.project.projectuser_set.get(user=allocation_user_obj.user) + project_user = allocation_user_obj.allocation.project.projectuser_set.get( + user=allocation_user_obj.user + ) if EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT or project_user.enable_notifications: - send_allocation_eula_customer_email(allocation_user_obj, - "EULA declined", - 'email/allocation_eula_declined.txt', - cc_managers=EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS) + send_allocation_eula_customer_email( + allocation_user_obj, + "EULA declined", + "email/allocation_eula_declined.txt", + cc_managers=EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS, + ) allocation_user_obj.save() - - return HttpResponseRedirect(reverse('allocation-review-eula', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-review-eula", kwargs={"pk": pk})) -class AllocationListView(LoginRequiredMixin, ListView): +class AllocationListView(LoginRequiredMixin, ListView): model = Allocation - template_name = 'allocation/allocation_list.html' - context_object_name = 'allocation_list' + template_name = "allocation/allocation_list.html" + context_object_name = "allocation_list" paginate_by = 25 def get_queryset(self): - - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - dir_dict = {'asc':'', 'des':'-'} + direction = self.request.GET.get("direction") + dir_dict = {"asc": "", "des": "-"} order_by = dir_dict[direction] + order_by else: - order_by = 'id' + order_by = "id" allocation_search_form = AllocationSearchForm(self.request.GET) if allocation_search_form.is_valid(): data = allocation_search_form.cleaned_data - if data.get('show_all_allocations') and (self.request.user.is_superuser or self.request.user.has_perm('allocation.can_view_all_allocations')): - allocations = Allocation.objects.prefetch_related( - 'project', 'project__pi', 'status',).all().order_by(order_by) + if data.get("show_all_allocations") and ( + self.request.user.is_superuser or self.request.user.has_perm("allocation.can_view_all_allocations") + ): + allocations = ( + Allocation.objects.prefetch_related( + "project", + "project__pi", + "status", + ) + .all() + .order_by(order_by) + ) else: - allocations = Allocation.objects.prefetch_related('project', 'project__pi', 'status',).filter( - Q(project__status__name__in=['New', 'Active']) & - Q(project__projectuser__status__name__in=['Active']) & - Q(project__projectuser__user=self.request.user) & - - (Q(project__projectuser__role__name='Manager') | - Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name__in=['Active', 'PendingEULA'] )) - ).distinct().order_by(order_by) + allocations = ( + Allocation.objects.prefetch_related( + "project", + "project__pi", + "status", + ) + .filter( + Q(project__status__name__in=["New", "Active"]) + & Q(project__projectuser__status__name__in=["Active"]) + & Q(project__projectuser__user=self.request.user) + & ( + Q(project__projectuser__role__name="Manager") + | Q(allocationuser__user=self.request.user) + & Q(allocationuser__status__name__in=["Active", "PendingEULA"]) + ) + ) + .distinct() + .order_by(order_by) + ) # Project Title - if data.get('project'): - allocations = allocations.filter( - project__title__icontains=data.get('project')) + if data.get("project"): + allocations = allocations.filter(project__title__icontains=data.get("project")) # username - if data.get('username'): + if data.get("username"): allocations = allocations.filter( - Q(project__pi__username__icontains=data.get('username')) | - Q(allocationuser__user__username__icontains=data.get('username')) & - Q(allocationuser__status__name__in=['PendingEULA', 'Active']) + Q(project__pi__username__icontains=data.get("username")) + | Q(allocationuser__user__username__icontains=data.get("username")) + & Q(allocationuser__status__name__in=["PendingEULA", "Active"]) ) # Resource Type - if data.get('resource_type'): - allocations = allocations.filter( - resources__resource_type=data.get('resource_type')) + if data.get("resource_type"): + allocations = allocations.filter(resources__resource_type=data.get("resource_type")) # Resource Name - if data.get('resource_name'): - allocations = allocations.filter( - resources__in=data.get('resource_name')) + if data.get("resource_name"): + allocations = allocations.filter(resources__in=data.get("resource_name")) # Allocation Attribute Name - if data.get('allocation_attribute_name') and data.get('allocation_attribute_value'): + if data.get("allocation_attribute_name") and data.get("allocation_attribute_value"): allocations = allocations.filter( - Q(allocationattribute__allocation_attribute_type=data.get('allocation_attribute_name')) & - Q(allocationattribute__value=data.get( - 'allocation_attribute_value')) + Q(allocationattribute__allocation_attribute_type=data.get("allocation_attribute_name")) + & Q(allocationattribute__value=data.get("allocation_attribute_value")) ) # End Date - if data.get('end_date'): - allocations = allocations.filter(end_date__lt=data.get( - 'end_date'), status__name='Active').order_by('end_date') + if data.get("end_date"): + allocations = allocations.filter(end_date__lt=data.get("end_date"), status__name="Active").order_by( + "end_date" + ) # Active from now until date - if data.get('active_from_now_until_date'): + if data.get("active_from_now_until_date"): + allocations = allocations.filter(end_date__gte=date.today()) allocations = allocations.filter( - end_date__gte=date.today()) - allocations = allocations.filter(end_date__lt=data.get( - 'active_from_now_until_date'), status__name='Active').order_by('end_date') + end_date__lt=data.get("active_from_now_until_date"), status__name="Active" + ).order_by("end_date") # Status - if data.get('status'): - allocations = allocations.filter( - status__in=data.get('status')) + if data.get("status"): + allocations = allocations.filter(status__in=data.get("status")) else: - allocations = Allocation.objects.prefetch_related('project', 'project__pi', 'status',).filter( - Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name__in=['PendingEULA', 'Active']) - ).order_by(order_by) + allocations = ( + Allocation.objects.prefetch_related( + "project", + "project__pi", + "status", + ) + .filter( + Q(allocationuser__user=self.request.user) + & Q(allocationuser__status__name__in=["PendingEULA", "Active"]) + ) + .order_by(order_by) + ) return allocations.distinct() def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) allocations_count = self.get_queryset().count() - context['allocations_count'] = allocations_count + context["allocations_count"] = allocations_count allocation_search_form = AllocationSearchForm(self.request.GET) if allocation_search_form.is_valid(): data = allocation_search_form.cleaned_data - filter_parameters = '' + filter_parameters = "" for key, value in data.items(): if value: if isinstance(value, QuerySet): - filter_parameters += ''.join([f'{key}={ele.pk}&' for ele in value]) - elif hasattr(value, 'pk'): - filter_parameters += f'{key}={value.pk}&' + filter_parameters += "".join([f"{key}={ele.pk}&" for ele in value]) + elif hasattr(value, "pk"): + filter_parameters += f"{key}={value.pk}&" else: - filter_parameters += f'{key}={value}&' - context['allocation_search_form'] = allocation_search_form + filter_parameters += f"{key}={value}&" + context["allocation_search_form"] = allocation_search_form else: filter_parameters = None - context['allocation_search_form'] = AllocationSearchForm() + context["allocation_search_form"] = AllocationSearchForm() - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % (order_by, direction) + direction = self.request.GET.get("direction") + filter_parameters_with_order_by = filter_parameters + "order_by=%s&direction=%s&" % (order_by, direction) else: filter_parameters_with_order_by = filter_parameters if filter_parameters: - context['expand_accordion'] = 'show' - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = filter_parameters_with_order_by + context["expand_accordion"] = "show" + context["filter_parameters"] = filter_parameters + context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - allocation_list = context.get('allocation_list') + allocation_list = context.get("allocation_list") paginator = Paginator(allocation_list, self.paginate_by) - page = self.request.GET.get('page') + page = self.request.GET.get("page") try: allocation_list = paginator.page(page) @@ -528,58 +593,63 @@ def get_context_data(self, **kwargs): class AllocationCreateView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = AllocationForm - template_name = 'allocation/allocation_create.html' + template_name = "allocation/allocation_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + """UserPassesTestMixin Tests""" + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.has_perm(self.request.user, ProjectPermission.UPDATE): return True - messages.error(self.request, 'You do not have permission to create a new allocation.') + messages.error(self.request, "You do not have permission to create a new allocation.") return False def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.needs_review: - messages.error(request, 'You cannot request a new allocation because you have to review your project first.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + messages.error( + request, "You cannot request a new allocation because you have to review your project first." + ) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error(request, 'You cannot request a new allocation to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot request a new allocation to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - context['project'] = project_obj + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + context["project"] = project_obj user_resources = get_user_resources(self.request.user) resources_form_default_quantities = {} resources_form_label_texts = {} resources_with_eula = {} - attr_names = ('quantity_default_value', 'quantity_label', 'eula') + attr_names = ("quantity_default_value", "quantity_label", "eula") for resource in user_resources: for attr_name in attr_names: query = Q(resource_attribute_type__name=attr_name) if resource.resourceattribute_set.filter(query).exists(): value = resource.resourceattribute_set.get(query).value - if attr_name == 'quantity_default_value': + if attr_name == "quantity_default_value": resources_form_default_quantities[resource.id] = int(value) - if attr_name == 'quantity_label': - resources_form_label_texts[resource.id] = mark_safe(f'{value}*') - if attr_name == 'eula': + if attr_name == "quantity_label": + resources_form_label_texts[resource.id] = mark_safe(f"{value}*") + if attr_name == "eula": resources_with_eula[resource.id] = value - context['resources_form_default_quantities'] = resources_form_default_quantities - context['resources_form_label_texts'] = resources_form_label_texts - context['resources_with_eula'] = resources_with_eula - context['resources_with_accounts'] = list(Resource.objects.filter( - name__in=list(ALLOCATION_ACCOUNT_MAPPING.keys())).values_list('id', flat=True)) + context["resources_form_default_quantities"] = resources_form_default_quantities + context["resources_form_label_texts"] = resources_form_label_texts + context["resources_with_eula"] = resources_with_eula + context["resources_with_accounts"] = list( + Resource.objects.filter(name__in=list(ALLOCATION_ACCOUNT_MAPPING.keys())).values_list("id", flat=True) + ) return context @@ -587,43 +657,44 @@ def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" if form_class is None: form_class = self.get_form_class() - return form_class(self.request.user, self.kwargs.get('project_pk'), **self.get_form_kwargs()) + return form_class(self.request.user, self.kwargs.get("project_pk"), **self.get_form_kwargs()) def form_valid(self, form): form_data = form.cleaned_data - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - resource_obj = form_data.get('resource') - justification = form_data.get('justification') - quantity = form_data.get('quantity', 1) - allocation_account = form_data.get('allocation_account', None) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + resource_obj = form_data.get("resource") + justification = form_data.get("justification") + quantity = form_data.get("quantity", 1) + allocation_account = form_data.get("allocation_account", None) # A resource is selected that requires an account name selection but user has no account names - if ALLOCATION_ACCOUNT_ENABLED and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING and AllocationAttributeType.objects.filter( - name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name]).exists() and not allocation_account: - form.add_error(None, format_html( - 'You need to create an account name. Create it by clicking the link under the "Allocation account" field.')) + if ( + ALLOCATION_ACCOUNT_ENABLED + and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING + and AllocationAttributeType.objects.filter(name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name]).exists() + and not allocation_account + ): + form.add_error( + None, + format_html( + 'You need to create an account name. Create it by clicking the link under the "Allocation account" field.' + ), + ) return self.form_invalid(form) allocation_limit_objs = resource_obj.resourceattribute_set.filter( - resource_attribute_type__name='allocation_limit').first() + resource_attribute_type__name="allocation_limit" + ).first() if allocation_limit_objs: allocation_limit = int(allocation_limit_objs.value) allocation_count = project_obj.allocation_set.filter( resources=resource_obj, - status__name__in=[ - 'Active', 'New', - 'Renewal Requested', - 'Paid', - 'Payment Pending', - 'Payment Requested' - ] + status__name__in=["Active", "New", "Renewal Requested", "Paid", "Payment Pending", "Payment Requested"], ).count() if allocation_count >= allocation_limit: - form.add_error(None, format_html( - 'Your project is at the allocation limit allowed for this resource.')) + form.add_error(None, format_html("Your project is at the allocation limit allowed for this resource.")) return self.form_invalid(form) - usernames = form_data.get('users') + usernames = form_data.get("users") usernames.append(project_obj.pi.username) usernames = list(set(usernames)) @@ -632,17 +703,12 @@ def form_valid(self, form): users.append(project_obj.pi) if INVOICE_ENABLED and resource_obj.requires_payment: - allocation_status_obj = AllocationStatusChoice.objects.get( - name=INVOICE_DEFAULT_STATUS) + allocation_status_obj = AllocationStatusChoice.objects.get(name=INVOICE_DEFAULT_STATUS) else: - allocation_status_obj = AllocationStatusChoice.objects.get( - name='New') + allocation_status_obj = AllocationStatusChoice.objects.get(name="New") allocation_obj = Allocation.objects.create( - project=project_obj, - justification=justification, - quantity=quantity, - status=allocation_status_obj + project=project_obj, justification=justification, quantity=quantity, status=allocation_status_obj ) if ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT: @@ -652,771 +718,824 @@ def form_valid(self, form): allocation_obj.resources.add(resource_obj) if ALLOCATION_ACCOUNT_ENABLED and allocation_account and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING: - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name]) + name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name] + ) AllocationAttribute.objects.create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value=allocation_account + value=allocation_account, ) - for linked_resource in resource_obj.linked_resources.all(): allocation_obj.resources.add(linked_resource) - allocation_user_active_status = AllocationUserStatusChoice.objects.get( - name='Active') + allocation_user_active_status = AllocationUserStatusChoice.objects.get(name="Active") if ALLOCATION_EULA_ENABLE: - allocation_user_pending_status = AllocationUserStatusChoice.objects.get( - name='PendingEULA') + allocation_user_pending_status = AllocationUserStatusChoice.objects.get(name="PendingEULA") for user in users: if ALLOCATION_EULA_ENABLE and not (user == self.request.user): - AllocationUser.objects.create(allocation=allocation_obj, user=user, - status=allocation_user_pending_status) + AllocationUser.objects.create( + allocation=allocation_obj, user=user, status=allocation_user_pending_status + ) else: - AllocationUser.objects.create(allocation=allocation_obj, user=user, - status=allocation_user_active_status) + AllocationUser.objects.create( + allocation=allocation_obj, user=user, status=allocation_user_active_status + ) send_allocation_admin_email( allocation_obj, - 'New Allocation Request', - 'email/new_allocation_request.txt', - domain_url=get_domain_url(self.request) + "New Allocation Request", + "email/new_allocation_request.txt", + domain_url=get_domain_url(self.request), ) - allocation_new.send(sender=self.__class__, - allocation_pk=allocation_obj.pk) + allocation_new.send(sender=self.__class__, allocation_pk=allocation_obj.pk) return super().form_valid(form) def get_success_url(self): - msg = 'Allocation requested. It will be available once it is approved.' + msg = "Allocation requested. It will be available once it is approved." messages.success(self.request, msg) - return reverse('project-detail', kwargs={'pk': self.kwargs.get('project_pk')}) + return reverse("project-detail", kwargs={"pk": self.kwargs.get("project_pk")}) class AllocationAddUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_add_users.html' + template_name = "allocation/allocation_add_users.html" model = Allocation - context_object_name = 'allocation' + context_object_name = "allocation" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER): return True - messages.error(self.request, 'You do not have permission to add users to the allocation.') + messages.error(self.request, "You do not have permission to add users to the allocation.") return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) message = None if allocation_obj.is_locked and not self.request.user.is_superuser: - message = 'You cannot modify this allocation because it is locked! Contact support for details.' - elif allocation_obj.status.name not in ['Active', 'New', 'Renewal Requested', 'Payment Pending', 'Payment Requested', 'Paid']: - message = f'You cannot add users to an allocation with status {allocation_obj.status.name}.' + message = "You cannot modify this allocation because it is locked! Contact support for details." + elif allocation_obj.status.name not in [ + "Active", + "New", + "Renewal Requested", + "Payment Pending", + "Payment Requested", + "Paid", + ]: + message = f"You cannot add users to an allocation with status {allocation_obj.status.name}." if message: messages.error(request, message) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) return super().dispatch(request, *args, **kwargs) def get_users_to_add(self, allocation_obj): - active_users_in_project = list(allocation_obj.project.projectuser_set.filter( - status__name='Active').values_list('user__username', flat=True)) - users_already_in_allocation = list(allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).values_list('user__username', flat=True)) + active_users_in_project = list( + allocation_obj.project.projectuser_set.filter(status__name="Active").values_list( + "user__username", flat=True + ) + ) + users_already_in_allocation = list( + allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]).values_list( + "user__username", flat=True + ) + ) - missing_users = list(set(active_users_in_project) - - set(users_already_in_allocation)) - missing_users = get_user_model().objects.filter(username__in=missing_users).exclude( - pk=allocation_obj.project.pi.pk) + missing_users = list(set(active_users_in_project) - set(users_already_in_allocation)) + missing_users = ( + get_user_model().objects.filter(username__in=missing_users).exclude(pk=allocation_obj.project.pi.pk) + ) users_to_add = [ - - {'username': user.username, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email, } - + { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + } for user in missing_users ] return users_to_add def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_to_add = self.get_users_to_add(allocation_obj) context = {} if users_to_add: - formset = formset_factory( - AllocationAddUserForm, max_num=len(users_to_add)) - formset = formset(initial=users_to_add, prefix='userform') - context['formset'] = formset + formset = formset_factory(AllocationAddUserForm, max_num=len(users_to_add)) + formset = formset(initial=users_to_add, prefix="userform") + context["formset"] = formset - context['allocation'] = allocation_obj + context["allocation"] = allocation_obj user_resources = get_user_resources(self.request.user) resources_with_eula = {} for res in user_resources: if res in allocation_obj.get_resources_as_list: - if res.get_attribute_list(name='eula'): - for attr_value in res.get_attribute_list(name='eula'): + if res.get_attribute_list(name="eula"): + for attr_value in res.get_attribute_list(name="eula"): resources_with_eula[res] = attr_value - context['resources_with_eula'] = resources_with_eula + context["resources_with_eula"] = resources_with_eula string_accumulator = "" for res, value in resources_with_eula.items(): string_accumulator += f"{res}: {value}\n" - context['compiled_eula'] = str(string_accumulator) + context["compiled_eula"] = str(string_accumulator) return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_to_add = self.get_users_to_add(allocation_obj) - formset = formset_factory( - AllocationAddUserForm, max_num=len(users_to_add)) - formset = formset(request.POST, initial=users_to_add, - prefix='userform') + formset = formset_factory(AllocationAddUserForm, max_num=len(users_to_add)) + formset = formset(request.POST, initial=users_to_add, prefix="userform") users_added_count = 0 if formset.is_valid(): - - allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get( - name='Active') + allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get(name="Active") if ALLOCATION_EULA_ENABLE: - allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get( - name='PendingEULA') + allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get(name="PendingEULA") for form in formset: user_form_data = form.cleaned_data - if user_form_data['selected']: - + if user_form_data["selected"]: users_added_count += 1 - user_obj = get_user_model().objects.get( - username=user_form_data.get('username')) + user_obj = get_user_model().objects.get(username=user_form_data.get("username")) if allocation_obj.allocationuser_set.filter(user=user_obj).exists(): - allocation_user_obj = allocation_obj.allocationuser_set.get( - user=user_obj) + allocation_user_obj = allocation_obj.allocationuser_set.get(user=user_obj) if ALLOCATION_EULA_ENABLE and not user_obj.userprofile.is_pi and allocation_obj.get_eula(): allocation_user_obj.status = allocation_user_pending_status_choice - send_email_template(f'Agree to EULA for {allocation_obj.get_parent_resource.__str__()}', 'email/allocation_agree_to_eula.txt', {"resource": allocation_obj.get_parent_resource, "url": build_link(reverse('allocation-review-eula', kwargs={'pk': allocation_obj.pk}), domain_url=get_domain_url(self.request))}, self.request.user.email, [user_obj]) + send_email_template( + f"Agree to EULA for {allocation_obj.get_parent_resource.__str__()}", + "email/allocation_agree_to_eula.txt", + { + "resource": allocation_obj.get_parent_resource, + "url": build_link( + reverse("allocation-review-eula", kwargs={"pk": allocation_obj.pk}), + domain_url=get_domain_url(self.request), + ), + }, + self.request.user.email, + [user_obj], + ) else: allocation_user_obj.status = allocation_user_active_status_choice allocation_user_obj.save() else: if ALLOCATION_EULA_ENABLE and not user_obj.userprofile.is_pi and allocation_obj.get_eula(): allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, user=user_obj, status=allocation_user_pending_status_choice) - send_email_template(f'Agree to EULA for {allocation_obj.get_parent_resource.__str__()}', 'email/allocation_agree_to_eula.txt', {"resource": allocation_obj.get_parent_resource, "url": build_link(reverse('allocation-review-eula', kwargs={'pk': allocation_obj.pk}), domain_url=get_domain_url(self.request))}, self.request.user.email, [user_obj]) + allocation=allocation_obj, user=user_obj, status=allocation_user_pending_status_choice + ) + send_email_template( + f"Agree to EULA for {allocation_obj.get_parent_resource.__str__()}", + "email/allocation_agree_to_eula.txt", + { + "resource": allocation_obj.get_parent_resource, + "url": build_link( + reverse("allocation-review-eula", kwargs={"pk": allocation_obj.pk}), + domain_url=get_domain_url(self.request), + ), + }, + self.request.user.email, + [user_obj], + ) else: allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, user=user_obj, status=allocation_user_active_status_choice) + allocation=allocation_obj, user=user_obj, status=allocation_user_active_status_choice + ) - allocation_activate_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) + allocation_activate_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) user_plural = "user" if users_added_count == 1 else "users" - messages.success(request, f'Added {users_added_count} {user_plural} to allocation.') + messages.success(request, f"Added {users_added_count} {user_plural} to allocation.") else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) class AllocationRemoveUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_remove_users.html' + template_name = "allocation/allocation_remove_users.html" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER): return True - messages.error(self.request, 'You do not have permission to remove users from allocation.') + messages.error(self.request, "You do not have permission to remove users from allocation.") return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) message = None if allocation_obj.is_locked and not self.request.user.is_superuser: - message = 'You cannot modify this allocation because it is locked! Contact support for details.' - elif allocation_obj.status.name not in ['Active', 'New', 'Renewal Requested', ]: - message = f'You cannot remove users from a allocation with status {allocation_obj.status.name}.' + message = "You cannot modify this allocation because it is locked! Contact support for details." + elif allocation_obj.status.name not in [ + "Active", + "New", + "Renewal Requested", + ]: + message = f"You cannot remove users from a allocation with status {allocation_obj.status.name}." if message: messages.error(request, message) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) return super().dispatch(request, *args, **kwargs) def get_users_to_remove(self, allocation_obj): - users_to_remove = list(allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed', 'Error', ]).values_list('user__username', flat=True)) + users_to_remove = list( + allocation_obj.allocationuser_set.exclude( + status__name__in=[ + "Removed", + "Error", + ] + ).values_list("user__username", flat=True) + ) - users_to_remove = get_user_model().objects.filter(username__in=users_to_remove).exclude( - pk__in=[allocation_obj.project.pi.pk, self.request.user.pk]) + users_to_remove = ( + get_user_model() + .objects.filter(username__in=users_to_remove) + .exclude(pk__in=[allocation_obj.project.pi.pk, self.request.user.pk]) + ) users_to_remove = [ - - {'username': user.username, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'email': user.email, } - + { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + } for user in users_to_remove ] return users_to_remove def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_to_remove = self.get_users_to_remove(allocation_obj) context = {} if users_to_remove: - formset = formset_factory( - AllocationRemoveUserForm, max_num=len(users_to_remove)) - formset = formset(initial=users_to_remove, prefix='userform') - context['formset'] = formset + formset = formset_factory(AllocationRemoveUserForm, max_num=len(users_to_remove)) + formset = formset(initial=users_to_remove, prefix="userform") + context["formset"] = formset - context['allocation'] = allocation_obj + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_to_remove = self.get_users_to_remove(allocation_obj) - formset = formset_factory( - AllocationRemoveUserForm, max_num=len(users_to_remove)) - formset = formset( - request.POST, initial=users_to_remove, prefix='userform') + formset = formset_factory(AllocationRemoveUserForm, max_num=len(users_to_remove)) + formset = formset(request.POST, initial=users_to_remove, prefix="userform") remove_users_count = 0 if formset.is_valid(): - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get( - name='Removed') + allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get(name="Removed") for form in formset: user_form_data = form.cleaned_data - if user_form_data['selected']: - + if user_form_data["selected"]: remove_users_count += 1 - user_obj = get_user_model().objects.get( - username=user_form_data.get('username')) + user_obj = get_user_model().objects.get(username=user_form_data.get("username")) if allocation_obj.project.pi == user_obj: continue - allocation_user_obj = allocation_obj.allocationuser_set.get( - user=user_obj) + allocation_user_obj = allocation_obj.allocationuser_set.get(user=user_obj) allocation_user_obj.status = allocation_user_removed_status_choice allocation_user_obj.save() - allocation_remove_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) + allocation_remove_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) user_plural = "user" if remove_users_count == 1 else "users" - messages.success(request, f'Removed {remove_users_count} {user_plural} from allocation.') + messages.success(request, f"Removed {remove_users_count} {user_plural} from allocation.") else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) class AllocationAttributeCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = AllocationAttribute form_class = AllocationAttributeCreateForm - template_name = 'allocation/allocation_allocationattribute_create.html' + template_name = "allocation/allocation_allocationattribute_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - messages.error(self.request, 'You do not have permission to add allocation attributes.') + messages.error(self.request, "You do not have permission to add allocation attributes.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - context['allocation'] = allocation_obj + context["allocation"] = allocation_obj return context def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - initial['allocation'] = allocation_obj + initial["allocation"] = allocation_obj return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['allocation'].widget = forms.HiddenInput() + form.fields["allocation"].widget = forms.HiddenInput() return form def get_success_url(self): - return reverse('allocation-detail', kwargs={'pk': self.kwargs.get('pk')}) + return reverse("allocation-detail", kwargs={"pk": self.kwargs.get("pk")}) class AllocationAttributeDeleteView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_allocationattribute_delete.html' + template_name = "allocation/allocation_allocationattribute_delete.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - messages.error(self.request, 'You do not have permission to delete allocation attributes.') + messages.error(self.request, "You do not have permission to delete allocation attributes.") return False def get_allocation_attributes_to_delete(self, allocation_obj): - - allocation_attributes_to_delete = AllocationAttribute.objects.filter( - allocation=allocation_obj) + allocation_attributes_to_delete = AllocationAttribute.objects.filter(allocation=allocation_obj) allocation_attributes_to_delete = [ - { - 'pk': attribute.pk, - 'name': attribute.allocation_attribute_type.name, - 'value': attribute.value, - } - + "pk": attribute.pk, + "name": attribute.allocation_attribute_type.name, + "value": attribute.value, + } for attribute in allocation_attributes_to_delete ] return allocation_attributes_to_delete def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_attributes_to_delete = self.get_allocation_attributes_to_delete( - allocation_obj) + allocation_attributes_to_delete = self.get_allocation_attributes_to_delete(allocation_obj) context = {} if allocation_attributes_to_delete: - formset = formset_factory(AllocationAttributeDeleteForm, max_num=len( - allocation_attributes_to_delete)) - formset = formset( - initial=allocation_attributes_to_delete, prefix='attributeform') - context['formset'] = formset - context['allocation'] = allocation_obj + formset = formset_factory(AllocationAttributeDeleteForm, max_num=len(allocation_attributes_to_delete)) + formset = formset(initial=allocation_attributes_to_delete, prefix="attributeform") + context["formset"] = formset + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_attributes_to_delete = self.get_allocation_attributes_to_delete( - allocation_obj) + allocation_attributes_to_delete = self.get_allocation_attributes_to_delete(allocation_obj) - formset = formset_factory(AllocationAttributeDeleteForm, max_num=len( - allocation_attributes_to_delete)) - formset = formset( - request.POST, initial=allocation_attributes_to_delete, prefix='attributeform') + formset = formset_factory(AllocationAttributeDeleteForm, max_num=len(allocation_attributes_to_delete)) + formset = formset(request.POST, initial=allocation_attributes_to_delete, prefix="attributeform") attributes_deleted_count = 0 if formset.is_valid(): for form in formset: form_data = form.cleaned_data - if form_data['selected']: - + if form_data["selected"]: attributes_deleted_count += 1 - allocation_attribute = AllocationAttribute.objects.get( - pk=form_data['pk']) + allocation_attribute = AllocationAttribute.objects.get(pk=form_data["pk"]) allocation_attribute.delete() - messages.success(request, f'Deleted {attributes_deleted_count} attributes from allocation.') + messages.success(request, f"Deleted {attributes_deleted_count} attributes from allocation.") else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) class AllocationNoteCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = AllocationUserNote - fields = '__all__' - template_name = 'allocation/allocation_note_create.html' + fields = "__all__" + template_name = "allocation/allocation_note_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - messages.error( self.request, 'You do not have permission to add allocation notes.') + messages.error(self.request, "You do not have permission to add allocation notes.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - context['allocation'] = allocation_obj + context["allocation"] = allocation_obj return context def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) author = self.request.user - initial['allocation'] = allocation_obj - initial['author'] = author + initial["allocation"] = allocation_obj + initial["author"] = author return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['allocation'].widget = forms.HiddenInput() - form.fields['author'].widget = forms.HiddenInput() - form.order_fields([ 'allocation', 'author', 'note', 'is_private' ]) + form.fields["allocation"].widget = forms.HiddenInput() + form.fields["author"].widget = forms.HiddenInput() + form.order_fields(["allocation", "author", "note", "is_private"]) return form def get_success_url(self): - return reverse('allocation-detail', kwargs={'pk': self.kwargs.get('pk')}) + return reverse("allocation-detail", kwargs={"pk": self.kwargs.get("pk")}) class AllocationRequestListView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_request_list.html' - login_url = '/' + template_name = "allocation/allocation_request_list.html" + login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_review_allocation_requests'): + if self.request.user.has_perm("allocation.can_review_allocation_requests"): return True - messages.error(self.request, 'You do not have permission to review allocation requests.') + messages.error(self.request, "You do not have permission to review allocation requests.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) allocation_list = Allocation.objects.filter( - status__name__in=['New', 'Renewal Requested', 'Paid', 'Approved',]) + status__name__in=[ + "New", + "Renewal Requested", + "Paid", + "Approved", + ] + ) allocation_renewal_dates = {} - for allocation in allocation_list.filter(status__name='Renewal Requested'): - allocation_history = allocation.history.all().order_by('-history_date') + for allocation in allocation_list.filter(status__name="Renewal Requested"): + allocation_history = allocation.history.all().order_by("-history_date") for history in allocation_history: - if history.status.name != 'Renewal Requested': + if history.status.name != "Renewal Requested": break allocation_renewal_dates[allocation.pk] = history.history_date - context['allocation_renewal_dates'] = allocation_renewal_dates - context['allocation_status_active'] = AllocationStatusChoice.objects.get(name='Active') - context['allocation_list'] = allocation_list - context['PROJECT_ENABLE_PROJECT_REVIEW'] = PROJECT_ENABLE_PROJECT_REVIEW - context['ALLOCATION_DEFAULT_ALLOCATION_LENGTH'] = ALLOCATION_DEFAULT_ALLOCATION_LENGTH + context["allocation_renewal_dates"] = allocation_renewal_dates + context["allocation_status_active"] = AllocationStatusChoice.objects.get(name="Active") + context["allocation_list"] = allocation_list + context["PROJECT_ENABLE_PROJECT_REVIEW"] = PROJECT_ENABLE_PROJECT_REVIEW + context["ALLOCATION_DEFAULT_ALLOCATION_LENGTH"] = ALLOCATION_DEFAULT_ALLOCATION_LENGTH return context class AllocationRenewView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_renew.html' + template_name = "allocation/allocation_renew.html" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER): return True - messages.error(self.request, 'You do not have permission to renew allocation.') + messages.error(self.request, "You do not have permission to renew allocation.") return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404( - Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if not ALLOCATION_ENABLE_ALLOCATION_RENEWAL: messages.error( - request, 'Allocation renewal is disabled. Request a new allocation to this resource if you want to continue using it after the active until date.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + request, + "Allocation renewal is disabled. Request a new allocation to this resource if you want to continue using it after the active until date.", + ) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) - if allocation_obj.status.name not in ['Active', ]: - messages.error(request, f'You cannot renew a allocation with status {allocation_obj.status.name}.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + if allocation_obj.status.name not in [ + "Active", + ]: + messages.error(request, f"You cannot renew a allocation with status {allocation_obj.status.name}.") + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) if allocation_obj.project.needs_review: - messages.error( - request, 'You cannot renew your allocation because you have to review your project first.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': allocation_obj.project.pk})) + messages.error(request, "You cannot renew your allocation because you have to review your project first.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": allocation_obj.project.pk})) if allocation_obj.expires_in > 60: - messages.error( - request, 'It is too soon to review your allocation.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + messages.error(request, "It is too soon to review your allocation.") + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) return super().dispatch(request, *args, **kwargs) def get_users_in_allocation(self, allocation_obj): - users_in_allocation = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).exclude(user__pk__in=[allocation_obj.project.pi.pk, self.request.user.pk]).order_by('user__username') + users_in_allocation = ( + allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]) + .exclude(user__pk__in=[allocation_obj.project.pi.pk, self.request.user.pk]) + .order_by("user__username") + ) users = [ - - {'username': allocation_user.user.username, - 'first_name': allocation_user.user.first_name, - 'last_name': allocation_user.user.last_name, - 'email': allocation_user.user.email, } - + { + "username": allocation_user.user.username, + "first_name": allocation_user.user.first_name, + "last_name": allocation_user.user.last_name, + "email": allocation_user.user.email, + } for allocation_user in users_in_allocation ] return users def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_in_allocation = self.get_users_in_allocation(allocation_obj) context = {} if users_in_allocation: - formset = formset_factory( - AllocationReviewUserForm, max_num=len(users_in_allocation)) - formset = formset(initial=users_in_allocation, prefix='userform') - context['formset'] = formset - - context['resource_eula'] = {} - if allocation_obj.get_parent_resource.resourceattribute_set.filter(resource_attribute_type__name='eula').exists(): - value = allocation_obj.get_parent_resource.resourceattribute_set.get(resource_attribute_type__name='eula').value - context['resource_eula'].update({'eula': value}) - - context['allocation'] = allocation_obj + formset = formset_factory(AllocationReviewUserForm, max_num=len(users_in_allocation)) + formset = formset(initial=users_in_allocation, prefix="userform") + context["formset"] = formset + + context["resource_eula"] = {} + if allocation_obj.get_parent_resource.resourceattribute_set.filter( + resource_attribute_type__name="eula" + ).exists(): + value = allocation_obj.get_parent_resource.resourceattribute_set.get( + resource_attribute_type__name="eula" + ).value + context["resource_eula"].update({"eula": value}) + + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) users_in_allocation = self.get_users_in_allocation(allocation_obj) - formset = formset_factory( - AllocationReviewUserForm, max_num=len(users_in_allocation)) - formset = formset( - request.POST, initial=users_in_allocation, prefix='userform') + formset = formset_factory(AllocationReviewUserForm, max_num=len(users_in_allocation)) + formset = formset(request.POST, initial=users_in_allocation, prefix="userform") - allocation_renewal_requested_status_choice = AllocationStatusChoice.objects.get( - name='Renewal Requested') - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get( - name='Removed') - project_user_remove_status_choice = ProjectUserStatusChoice.objects.get( - name='Removed') + allocation_renewal_requested_status_choice = AllocationStatusChoice.objects.get(name="Renewal Requested") + allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get(name="Removed") + project_user_remove_status_choice = ProjectUserStatusChoice.objects.get(name="Removed") allocation_obj.status = allocation_renewal_requested_status_choice allocation_obj.save() if not users_in_allocation or formset.is_valid(): - if users_in_allocation: for form in formset: user_form_data = form.cleaned_data - user_obj = get_user_model().objects.get( - username=user_form_data.get('username')) - user_status = user_form_data.get('user_status') + user_obj = get_user_model().objects.get(username=user_form_data.get("username")) + user_status = user_form_data.get("user_status") - if user_status == 'keep_in_project_only': - allocation_user_obj = allocation_obj.allocationuser_set.get( - user=user_obj) + if user_status == "keep_in_project_only": + allocation_user_obj = allocation_obj.allocationuser_set.get(user=user_obj) allocation_user_obj.status = allocation_user_removed_status_choice allocation_user_obj.save() - allocation_remove_user.send( - sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) - - elif user_status == 'remove_from_project': - for active_allocation in allocation_obj.project.allocation_set.filter(status__name__in=( - 'Active', 'Denied', 'New', 'Paid', 'Payment Pending', - 'Payment Requested', 'Payment Declined', 'Renewal Requested', 'Unpaid',)): - - allocation_user_obj = active_allocation.allocationuser_set.get( - user=user_obj) + allocation_remove_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) + + elif user_status == "remove_from_project": + for active_allocation in allocation_obj.project.allocation_set.filter( + status__name__in=( + "Active", + "Denied", + "New", + "Paid", + "Payment Pending", + "Payment Requested", + "Payment Declined", + "Renewal Requested", + "Unpaid", + ) + ): + allocation_user_obj = active_allocation.allocationuser_set.get(user=user_obj) allocation_user_obj.status = allocation_user_removed_status_choice allocation_user_obj.save() allocation_remove_user.send( - sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) + sender=self.__class__, allocation_user_pk=allocation_user_obj.pk + ) - project_user_obj = ProjectUser.objects.get( - project=allocation_obj.project, - user=user_obj) + project_user_obj = ProjectUser.objects.get(project=allocation_obj.project, user=user_obj) project_user_obj.status = project_user_remove_status_choice project_user_obj.save() - send_allocation_admin_email(allocation_obj, 'Allocation Renewed', 'email/allocation_renewed.txt', domain_url=get_domain_url(self.request)) - messages.success(request, 'Allocation renewed successfully') + send_allocation_admin_email( + allocation_obj, + "Allocation Renewed", + "email/allocation_renewed.txt", + domain_url=get_domain_url(self.request), + ) + messages.success(request, "Allocation renewed successfully") else: if not formset.is_valid(): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': allocation_obj.project.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": allocation_obj.project.pk})) class AllocationInvoiceListView(LoginRequiredMixin, UserPassesTestMixin, ListView): model = Allocation - template_name = 'allocation/allocation_invoice_list.html' - context_object_name = 'allocation_list' + template_name = "allocation/allocation_invoice_list.html" + context_object_name = "allocation_list" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_queryset(self): - allocations = Allocation.objects.filter( - status__name__in=['Paid', 'Payment Pending', 'Payment Requested', 'Payment Declined', ]) + status__name__in=[ + "Paid", + "Payment Pending", + "Payment Requested", + "Payment Declined", + ] + ) return allocations + # this is the view class thats rendering allocation_invoice_detail. # each view class has a view template that renders class AllocationInvoiceDetailView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Allocation - template_name = 'allocation/allocation_invoice_detail.html' - context_object_name = 'allocation' + template_name = "allocation/allocation_invoice_detail.html" + context_object_name = "allocation" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to view invoices.') + messages.error(self.request, "You do not have permission to view invoices.") return False def get_context_data(self, **kwargs): - """Create all the variables for allocation_invoice_detail.html - - """ + """Create all the variables for allocation_invoice_detail.html""" context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=['Removed']).order_by('user__username') + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]).order_by( + "user__username" + ) alloc_attr_set = allocation_obj.get_attribute_set(self.request.user) - attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, 'allocationattributeusage')] + attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, "allocationattributeusage")] attributes = [a for a in alloc_attr_set] guage_data = [] invalid_attributes = [] for attribute in attributes_with_usage: try: - guage_data.append(generate_guauge_data_from_usage(attribute.allocation_attribute_type.name, - float(attribute.value), float(attribute.allocationattributeusage.value))) + guage_data.append( + generate_guauge_data_from_usage( + attribute.allocation_attribute_type.name, + float(attribute.value), + float(attribute.allocationattributeusage.value), + ) + ) except ValueError: - logger.error("Allocation attribute '%s' is not an int but has a usage", - attribute.allocation_attribute_type.name) + logger.error( + "Allocation attribute '%s' is not an int but has a usage", attribute.allocation_attribute_type.name + ) invalid_attributes.append(attribute) for a in invalid_attributes: attributes_with_usage.remove(a) - context['guage_data'] = guage_data - context['attributes_with_usage'] = attributes_with_usage - context['attributes'] = attributes + context["guage_data"] = guage_data + context["attributes_with_usage"] = attributes_with_usage + context["attributes"] = attributes # Can the user update the project? - context['is_allowed_to_update_project'] = allocation_obj.project.has_perm(self.request.user, ProjectPermission.UPDATE) - context['allocation_users'] = allocation_users + context["is_allowed_to_update_project"] = allocation_obj.project.has_perm( + self.request.user, ProjectPermission.UPDATE + ) + context["allocation_users"] = allocation_users if self.request.user.is_superuser: notes = allocation_obj.allocationusernote_set.all() else: - notes = allocation_obj.allocationusernote_set.filter( - is_private=False) + notes = allocation_obj.allocationusernote_set.filter(is_private=False) - context['notes'] = notes - context['ALLOCATION_ENABLE_ALLOCATION_RENEWAL'] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL + context["notes"] = notes + context["ALLOCATION_ENABLE_ALLOCATION_RENEWAL"] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL return context - def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) initial_data = { - 'status': allocation_obj.status, + "status": allocation_obj.status, } form = AllocationInvoiceUpdateForm(initial=initial_data) context = self.get_context_data() - context['form'] = form - context['allocation'] = allocation_obj + context["form"] = form + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - initial_data = {'status': allocation_obj.status,} + initial_data = { + "status": allocation_obj.status, + } form = AllocationInvoiceUpdateForm(request.POST, initial=initial_data) if form.is_valid(): form_data = form.cleaned_data - allocation_obj.status = form_data.get('status') + allocation_obj.status = form_data.get("status") allocation_obj.save() - messages.success(request, 'Allocation updated!') + messages.success(request, "Allocation updated!") else: for error in form.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-invoice-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-invoice-detail", kwargs={"pk": pk})) + class AllocationAddInvoiceNoteView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = AllocationUserNote - template_name = 'allocation/allocation_add_invoice_note.html' - fields = ('is_private', 'note',) + template_name = "allocation/allocation_add_invoice_note.html" + fields = ( + "is_private", + "note", + ) def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) - context['allocation'] = allocation_obj + context["allocation"] = allocation_obj return context def form_valid(self, form): # This method is called when valid form data has been POSTed. # It should return an HttpResponse. - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) obj = form.save(commit=False) obj.author = self.request.user @@ -1426,50 +1545,52 @@ def form_valid(self, form): return super().form_valid(form) def get_success_url(self): - return reverse_lazy('allocation-invoice-detail', kwargs={'pk': self.object.allocation.pk}) + return reverse_lazy("allocation-invoice-detail", kwargs={"pk": self.object.allocation.pk}) class AllocationUpdateInvoiceNoteView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): model = AllocationUserNote - template_name = 'allocation/allocation_update_invoice_note.html' - fields = ('is_private', 'note',) + template_name = "allocation/allocation_update_invoice_note.html" + fields = ( + "is_private", + "note", + ) def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_success_url(self): - return reverse_lazy('allocation-invoice-detail', kwargs={'pk': self.object.allocation.pk}) + return reverse_lazy("allocation-invoice-detail", kwargs={"pk": self.object.allocation.pk}) class AllocationDeleteInvoiceNoteView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_delete_invoice_note.html' + template_name = "allocation/allocation_delete_invoice_note.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_manage_invoice'): + if self.request.user.has_perm("allocation.can_manage_invoice"): return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_notes_to_delete(self, allocation_obj): - notes_to_delete = [ { - 'pk': note.pk, - 'note': note.note, - 'author': note.author.username, + "pk": note.pk, + "note": note.note, + "author": note.author.username, } for note in allocation_obj.allocationusernote_set.all() ] @@ -1477,50 +1598,45 @@ def get_notes_to_delete(self, allocation_obj): return notes_to_delete def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) notes_to_delete = self.get_notes_to_delete(allocation_obj) context = {} if notes_to_delete: - formset = formset_factory( - AllocationInvoiceNoteDeleteForm, max_num=len(notes_to_delete)) - formset = formset(initial=notes_to_delete, prefix='noteform') - context['formset'] = formset - context['allocation'] = allocation_obj + formset = formset_factory(AllocationInvoiceNoteDeleteForm, max_num=len(notes_to_delete)) + formset = formset(initial=notes_to_delete, prefix="noteform") + context["formset"] = formset + context["allocation"] = allocation_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) notes_to_delete = self.get_notes_to_delete(allocation_obj) - formset = formset_factory( - AllocationInvoiceNoteDeleteForm, max_num=len(notes_to_delete)) - formset = formset( - request.POST, initial=notes_to_delete, prefix='noteform') + formset = formset_factory(AllocationInvoiceNoteDeleteForm, max_num=len(notes_to_delete)) + formset = formset(request.POST, initial=notes_to_delete, prefix="noteform") if formset.is_valid(): for form in formset: note_form_data = form.cleaned_data - if note_form_data['selected']: - note_obj = AllocationUserNote.objects.get( - pk=note_form_data.get('pk')) + if note_form_data["selected"]: + note_obj = AllocationUserNote.objects.get(pk=note_form_data.get("pk")) note_obj.delete() else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse_lazy('allocation-invoice-detail', kwargs={'pk': allocation_obj.pk})) + return HttpResponseRedirect(reverse_lazy("allocation-invoice-detail", kwargs={"pk": allocation_obj.pk})) class AllocationAccountCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = AllocationAccount - template_name = 'allocation/allocation_allocationaccount_create.html' + template_name = "allocation/allocation_allocationaccount_create.html" form_class = AllocationAccountForm def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if not ALLOCATION_ACCOUNT_ENABLED: return False @@ -1529,7 +1645,7 @@ def test_func(self): if self.request.user.userprofile.is_pi: return True - messages.error(self.request, 'You do not have permission to add allocation attributes.') + messages.error(self.request, "You do not have permission to add allocation attributes.") return False def form_invalid(self, form): @@ -1543,22 +1659,22 @@ def form_valid(self, form): response = super().form_valid(form) if self.request.is_ajax(): data = { - 'pk': self.object.pk, + "pk": self.object.pk, } return JsonResponse(data) return response def get_success_url(self): - return reverse_lazy('allocation-account-list') + return reverse_lazy("allocation-account-list") class AllocationAccountListView(LoginRequiredMixin, UserPassesTestMixin, ListView): model = AllocationAccount - template_name = 'allocation/allocation_account_list.html' - context_object_name = 'allocationaccount_list' + template_name = "allocation/allocation_account_list.html" + context_object_name = "allocationaccount_list" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if not ALLOCATION_ACCOUNT_ENABLED: return False @@ -1567,7 +1683,7 @@ def test_func(self): if self.request.user.userprofile.is_pi: return True - messages.error(self.request, 'You do not have permission to manage invoices.') + messages.error(self.request, "You do not have permission to manage invoices.") return False def get_queryset(self): @@ -1576,13 +1692,13 @@ def get_queryset(self): class AllocationChangeDetailView(LoginRequiredMixin, UserPassesTestMixin, FormView): formset_class = AllocationAttributeUpdateForm - template_name = 'allocation/allocation_change_detail.html' + template_name = "allocation/allocation_change_detail.html" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=self.kwargs.get("pk")) - if self.request.user.has_perm('allocation.can_view_all_allocations'): + if self.request.user.has_perm("allocation.can_view_all_allocations"): return True if allocation_change_obj.allocation.has_perm(self.request.user, AllocationPermission.MANAGER): @@ -1590,19 +1706,17 @@ def test_func(self): return False - def get_allocation_attributes_to_change(self, allocation_change_obj): attributes_to_change = allocation_change_obj.allocationattributechangerequest_set.all() attributes_to_change = [ - - {'change_pk': attribute_change.pk, - 'attribute_pk': attribute_change.allocation_attribute.pk, - 'name': attribute_change.allocation_attribute.allocation_attribute_type.name, - 'value': attribute_change.allocation_attribute.value, - 'new_value': attribute_change.new_value, - } - + { + "change_pk": attribute_change.pk, + "attribute_pk": attribute_change.allocation_attribute.pk, + "name": attribute_change.allocation_attribute.allocation_attribute_type.name, + "value": attribute_change.allocation_attribute.value, + "new_value": attribute_change.new_value, + } for attribute_change in attributes_to_change ] @@ -1611,114 +1725,111 @@ def get_allocation_attributes_to_change(self, allocation_change_obj): def get_context_data(self, **kwargs): context = {} - allocation_change_obj = get_object_or_404( - AllocationChangeRequest, pk=self.kwargs.get('pk')) - + allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=self.kwargs.get("pk")) - allocation_attributes_to_change = self.get_allocation_attributes_to_change( - allocation_change_obj) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_change_obj) if allocation_attributes_to_change: - formset = formset_factory(self.formset_class, max_num=len( - allocation_attributes_to_change)) - formset = formset( - initial=allocation_attributes_to_change, prefix='attributeform') - context['formset'] = formset + formset = formset_factory(self.formset_class, max_num=len(allocation_attributes_to_change)) + formset = formset(initial=allocation_attributes_to_change, prefix="attributeform") + context["formset"] = formset - context['allocation_change'] = allocation_change_obj - context['attribute_changes'] = allocation_attributes_to_change + context["allocation_change"] = allocation_change_obj + context["attribute_changes"] = allocation_attributes_to_change return context def get(self, request, *args, **kwargs): - - allocation_change_obj = get_object_or_404( - AllocationChangeRequest, pk=self.kwargs.get('pk')) + allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=self.kwargs.get("pk")) allocation_change_form = AllocationChangeForm( - initial={'justification': allocation_change_obj.justification, - 'end_date_extension': allocation_change_obj.end_date_extension}) - allocation_change_form.fields['justification'].disabled = True - if allocation_change_obj.status.name != 'Pending': - allocation_change_form.fields['end_date_extension'].disabled = True + initial={ + "justification": allocation_change_obj.justification, + "end_date_extension": allocation_change_obj.end_date_extension, + } + ) + allocation_change_form.fields["justification"].disabled = True + if allocation_change_obj.status.name != "Pending": + allocation_change_form.fields["end_date_extension"].disabled = True if not self.request.user.is_staff and not self.request.user.is_superuser: - allocation_change_form.fields['end_date_extension'].disabled = True + allocation_change_form.fields["end_date_extension"].disabled = True - note_form = AllocationChangeNoteForm( - initial={'notes': allocation_change_obj.notes}) + note_form = AllocationChangeNoteForm(initial={"notes": allocation_change_obj.notes}) context = self.get_context_data() - context['allocation_change_form'] = allocation_change_form - context['note_form'] = note_form + context["allocation_change_form"] = allocation_change_form + context["note_form"] = note_form return render(request, self.template_name, context) - def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") if not self.request.user.is_superuser: - messages.error( - request, 'You do not have permission to update an allocation change request') - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + messages.error(request, "You do not have permission to update an allocation change request") + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) allocation_change_obj = get_object_or_404(AllocationChangeRequest, pk=pk) - allocation_change_form = AllocationChangeForm(request.POST, - initial={'justification': allocation_change_obj.justification, - 'end_date_extension': allocation_change_obj.end_date_extension}) - allocation_change_form.fields['justification'].required = False + allocation_change_form = AllocationChangeForm( + request.POST, + initial={ + "justification": allocation_change_obj.justification, + "end_date_extension": allocation_change_obj.end_date_extension, + }, + ) + allocation_change_form.fields["justification"].required = False - allocation_attributes_to_change = self.get_allocation_attributes_to_change( - allocation_change_obj) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_change_obj) if allocation_attributes_to_change: - formset = formset_factory(self.formset_class, max_num=len( - allocation_attributes_to_change)) - formset = formset( - request.POST, initial=allocation_attributes_to_change, prefix='attributeform') + formset = formset_factory(self.formset_class, max_num=len(allocation_attributes_to_change)) + formset = formset(request.POST, initial=allocation_attributes_to_change, prefix="attributeform") - note_form = AllocationChangeNoteForm( - request.POST, initial={'notes': allocation_change_obj.notes}) + note_form = AllocationChangeNoteForm(request.POST, initial={"notes": allocation_change_obj.notes}) if not note_form.is_valid(): allocation_change_form = AllocationChangeForm( - initial={'justification': allocation_change_obj.justification}) - allocation_change_form.fields['justification'].disabled = True + initial={"justification": allocation_change_obj.justification} + ) + allocation_change_form.fields["justification"].disabled = True context = self.get_context_data() - context['note_form'] = note_form - context['allocation_change_form'] = allocation_change_form + context["note_form"] = note_form + context["allocation_change_form"] = allocation_change_form return render(request, self.template_name, context) - notes = note_form.cleaned_data.get('notes') + notes = note_form.cleaned_data.get("notes") - action = request.POST.get('action') - if action not in ['update', 'approve', 'deny']: + action = request.POST.get("action") + if action not in ["update", "approve", "deny"]: return HttpResponseBadRequest("Invalid request") - if action == 'deny': + if action == "deny": allocation_change_obj.notes = notes - allocation_change_status_denied_obj = AllocationChangeStatusChoice.objects.get( - name='Denied') + allocation_change_status_denied_obj = AllocationChangeStatusChoice.objects.get(name="Denied") allocation_change_obj.status = allocation_change_status_denied_obj allocation_change_obj.save() - messages.success(request, 'Allocation change request to {} has been DENIED for {} {} ({})'.format( - allocation_change_obj.allocation.resources.first(), - allocation_change_obj.allocation.project.pi.first_name, - allocation_change_obj.allocation.project.pi.last_name, - allocation_change_obj.allocation.project.pi.username) + messages.success( + request, + "Allocation change request to {} has been DENIED for {} {} ({})".format( + allocation_change_obj.allocation.resources.first(), + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username, + ), ) - send_allocation_customer_email(allocation_change_obj.allocation, - 'Allocation Change Denied', - 'email/allocation_change_denied.txt', - domain_url=get_domain_url(self.request)) - - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + send_allocation_customer_email( + allocation_change_obj.allocation, + "Allocation Change Denied", + "email/allocation_change_denied.txt", + domain_url=get_domain_url(self.request), + ) + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) if not allocation_change_form.is_valid() or (allocation_attributes_to_change and not formset.is_valid()): for error in allocation_change_form.errors: @@ -1727,25 +1838,23 @@ def post(self, request, *args, **kwargs): attribute_errors = "" for error in formset.errors: if error: - attribute_errors += error.get('__all__') + attribute_errors += error.get("__all__") messages.error(request, attribute_errors) - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) - + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) allocation_change_obj.notes = notes - if action == 'update' and allocation_change_obj.status.name != 'Pending': + if action == "update" and allocation_change_obj.status.name != "Pending": allocation_change_obj.save() - messages.success(request, 'Allocation change request updated!') - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) - + messages.success(request, "Allocation change request updated!") + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) form_data = allocation_change_form.cleaned_data - end_date_extension = form_data.get('end_date_extension') + end_date_extension = form_data.get("end_date_extension") if not allocation_attributes_to_change and end_date_extension == 0: - messages.error(request, 'You must make a change to the allocation.') - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + messages.error(request, "You must make a change to the allocation.") + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) if end_date_extension != allocation_change_obj.end_date_extension: allocation_change_obj.end_date_extension = end_date_extension @@ -1753,29 +1862,25 @@ def post(self, request, *args, **kwargs): if allocation_attributes_to_change: for entry in formset: formset_data = entry.cleaned_data - new_value = formset_data.get('new_value') - attribute_change = AllocationAttributeChangeRequest.objects.get( - pk=formset_data.get('change_pk')) + new_value = formset_data.get("new_value") + attribute_change = AllocationAttributeChangeRequest.objects.get(pk=formset_data.get("change_pk")) if new_value != attribute_change.new_value: attribute_change.new_value = new_value attribute_change.save() - - if action == 'update': - + if action == "update": allocation_change_obj.save() - messages.success(request, 'Allocation change request updated!') + messages.success(request, "Allocation change request updated!") - - elif action == 'approve': - allocation_change_status_active_obj = AllocationChangeStatusChoice.objects.get( - name='Approved') + elif action == "approve": + allocation_change_status_active_obj = AllocationChangeStatusChoice.objects.get(name="Approved") allocation_change_obj.status = allocation_change_status_active_obj if allocation_change_obj.end_date_extension > 0: new_end_date = allocation_change_obj.allocation.end_date + relativedelta( - days=allocation_change_obj.end_date_extension) + days=allocation_change_obj.end_date_extension + ) allocation_change_obj.allocation.end_date = new_end_date allocation_change_obj.allocation.save() @@ -1787,102 +1892,119 @@ def post(self, request, *args, **kwargs): attribute_change.allocation_attribute.value = attribute_change.new_value attribute_change.allocation_attribute.save() - messages.success(request, 'Allocation change request to {} has been APPROVED for {} {} ({})'.format( - allocation_change_obj.allocation.get_parent_resource, - allocation_change_obj.allocation.project.pi.first_name, - allocation_change_obj.allocation.project.pi.last_name, - allocation_change_obj.allocation.project.pi.username) + messages.success( + request, + "Allocation change request to {} has been APPROVED for {} {} ({})".format( + allocation_change_obj.allocation.get_parent_resource, + allocation_change_obj.allocation.project.pi.first_name, + allocation_change_obj.allocation.project.pi.last_name, + allocation_change_obj.allocation.project.pi.username, + ), ) allocation_change_approved.send( sender=self.__class__, allocation_pk=allocation_change_obj.allocation.pk, - allocation_change_pk=allocation_change_obj.pk,) - - send_allocation_customer_email(allocation_change_obj.allocation, - 'Allocation Change Approved', - 'email/allocation_change_approved.txt', - domain_url=get_domain_url(self.request)) - - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': pk})) + allocation_change_pk=allocation_change_obj.pk, + ) + send_allocation_customer_email( + allocation_change_obj.allocation, + "Allocation Change Approved", + "email/allocation_change_approved.txt", + domain_url=get_domain_url(self.request), + ) + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": pk})) class AllocationChangeListView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'allocation/allocation_change_list.html' + template_name = "allocation/allocation_change_list.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_review_allocation_requests'): + if self.request.user.has_perm("allocation.can_review_allocation_requests"): return True - messages.error(self.request, 'You do not have permission to review allocation requests.') + messages.error(self.request, "You do not have permission to review allocation requests.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) allocation_change_list = AllocationChangeRequest.objects.filter( - status__name__in=['Pending', ]) - context['allocation_change_list'] = allocation_change_list - context['PROJECT_ENABLE_PROJECT_REVIEW'] = PROJECT_ENABLE_PROJECT_REVIEW + status__name__in=[ + "Pending", + ] + ) + context["allocation_change_list"] = allocation_change_list + context["PROJECT_ENABLE_PROJECT_REVIEW"] = PROJECT_ENABLE_PROJECT_REVIEW return context class AllocationChangeView(LoginRequiredMixin, UserPassesTestMixin, FormView): formset_class = AllocationAttributeChangeForm - template_name = 'allocation/allocation_change.html' + template_name = "allocation/allocation_change.html" def test_func(self): - """ UserPassesTestMixin Tests""" - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER): return True - messages.error(self.request, 'You do not have permission to request changes to this allocation.') + messages.error(self.request, "You do not have permission to request changes to this allocation.") return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404( - Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) if allocation_obj.project.needs_review: messages.error( - request, 'You cannot request a change to this allocation because you have to review your project first.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + request, "You cannot request a change to this allocation because you have to review your project first." + ) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) - if allocation_obj.project.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot request a change to an allocation in an archived project.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + if allocation_obj.project.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot request a change to an allocation in an archived project.") + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) if allocation_obj.is_locked: + messages.error(request, "You cannot request a change to a locked allocation.") + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + + if allocation_obj.status.name not in [ + "Active", + "Renewal Requested", + "Payment Pending", + "Payment Requested", + "Paid", + ]: messages.error( - request, 'You cannot request a change to a locked allocation.') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) - - if allocation_obj.status.name not in ['Active', 'Renewal Requested', 'Payment Pending', 'Payment Requested', 'Paid']: - messages.error(request, f'You cannot request a change to an allocation with status "{allocation_obj.status.name}".') - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': allocation_obj.pk})) + request, f'You cannot request a change to an allocation with status "{allocation_obj.status.name}".' + ) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) return super().dispatch(request, *args, **kwargs) def get_allocation_attributes_to_change(self, allocation_obj): attributes_to_change = allocation_obj.allocationattribute_set.filter( - allocation_attribute_type__is_changeable=True) + allocation_attribute_type__is_changeable=True + ) attributes_to_change = [ { - 'pk': attribute.pk, - 'name': attribute.allocation_attribute_type.name, - 'value': attribute.value, - } + "pk": attribute.pk, + "name": attribute.allocation_attribute_type.name, + "value": attribute.value, + } for attribute in attributes_to_change ] @@ -1891,129 +2013,124 @@ def get_allocation_attributes_to_change(self, allocation_obj): def get(self, request, *args, **kwargs): context = {} - allocation_obj = get_object_or_404( - Allocation, pk=self.kwargs.get('pk')) + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) form = AllocationChangeForm(**self.get_form_kwargs()) - context['form'] = form + context["form"] = form allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) if allocation_attributes_to_change: - formset = formset_factory(self.formset_class, max_num=len( - allocation_attributes_to_change)) - formset = formset( - initial=allocation_attributes_to_change, prefix='attributeform') - context['formset'] = formset - context['allocation'] = allocation_obj - context['attributes'] = allocation_attributes_to_change + formset = formset_factory(self.formset_class, max_num=len(allocation_attributes_to_change)) + formset = formset(initial=allocation_attributes_to_change, prefix="attributeform") + context["formset"] = formset + context["allocation"] = allocation_obj + context["attributes"] = allocation_attributes_to_change return render(request, self.template_name, context) def post(self, request, *args, **kwargs): change_requested = False attribute_changes_to_make = set({}) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) form = AllocationChangeForm(**self.get_form_kwargs()) - allocation_attributes_to_change = self.get_allocation_attributes_to_change( - allocation_obj) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) if allocation_attributes_to_change: - formset = formset_factory(self.formset_class, max_num=len( - allocation_attributes_to_change)) - formset = formset( - request.POST, initial=allocation_attributes_to_change, prefix='attributeform') + formset = formset_factory(self.formset_class, max_num=len(allocation_attributes_to_change)) + formset = formset(request.POST, initial=allocation_attributes_to_change, prefix="attributeform") if not form.is_valid() or not formset.is_valid(): attribute_errors = "" for error in form.errors: messages.error(request, error) for error in formset.errors: - if error: attribute_errors += error.get('__all__') + if error: + attribute_errors += error.get("__all__") messages.error(request, attribute_errors) - return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-change", kwargs={"pk": pk})) form_data = form.cleaned_data - if form_data.get('end_date_extension') != 0: + if form_data.get("end_date_extension") != 0: change_requested = True for entry in formset: formset_data = entry.cleaned_data - new_value = formset_data.get('new_value') + new_value = formset_data.get("new_value") if new_value != "": change_requested = True - allocation_attribute = AllocationAttribute.objects.get(pk=formset_data.get('pk')) + allocation_attribute = AllocationAttribute.objects.get(pk=formset_data.get("pk")) attribute_changes_to_make.add((allocation_attribute, new_value)) if not change_requested: - messages.error(request, 'You must request a change.') - return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) - + messages.error(request, "You must request a change.") + return HttpResponseRedirect(reverse("allocation-change", kwargs={"pk": pk})) if not form.is_valid(): for error in form.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("allocation-change", kwargs={"pk": pk})) form_data = form.cleaned_data - if not allocation_attributes_to_change and form_data.get('end_date_extension') == 0: - messages.error(request, 'You must request a change.') - return HttpResponseRedirect(reverse('allocation-change', kwargs={'pk': pk})) + if not allocation_attributes_to_change and form_data.get("end_date_extension") == 0: + messages.error(request, "You must request a change.") + return HttpResponseRedirect(reverse("allocation-change", kwargs={"pk": pk})) - end_date_extension = form_data.get('end_date_extension') - justification = form_data.get('justification') - change_request_status_obj = AllocationChangeStatusChoice.objects.get(name='Pending') + end_date_extension = form_data.get("end_date_extension") + justification = form_data.get("justification") + change_request_status_obj = AllocationChangeStatusChoice.objects.get(name="Pending") allocation_change_request_obj = AllocationChangeRequest.objects.create( allocation=allocation_obj, end_date_extension=end_date_extension, justification=justification, - status=change_request_status_obj - ) - + status=change_request_status_obj, + ) for attribute in attribute_changes_to_make: - attribute_change_request_obj = AllocationAttributeChangeRequest.objects.create( + AllocationAttributeChangeRequest.objects.create( allocation_change_request=allocation_change_request_obj, allocation_attribute=attribute[0], - new_value=attribute[1] - ) + new_value=attribute[1], + ) - messages.success(request, 'Allocation change request successfully submitted.') + messages.success(request, "Allocation change request successfully submitted.") allocation_change_created.send( sender=self.__class__, allocation_pk=allocation_obj.pk, - allocation_change_pk=allocation_change_request_obj.pk,) - + allocation_change_pk=allocation_change_request_obj.pk, + ) - send_allocation_admin_email(allocation_obj, - 'New Allocation Change Request', - 'email/new_allocation_change_request.txt', - url_path=reverse('allocation-change-list'), - domain_url=get_domain_url(self.request)) - return HttpResponseRedirect(reverse('allocation-detail', kwargs={'pk': pk})) + send_allocation_admin_email( + allocation_obj, + "New Allocation Change Request", + "email/new_allocation_change_request.txt", + url_path=reverse("allocation-change-list"), + domain_url=get_domain_url(self.request), + ) + return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) class AllocationChangeDeleteAttributeView(LoginRequiredMixin, UserPassesTestMixin, View): - login_url = '/' + login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('allocation.can_review_allocation_requests'): + if self.request.user.has_perm("allocation.can_review_allocation_requests"): return True - messages.error(self.request, 'You do not have permission to update an allocation change request.') + messages.error(self.request, "You do not have permission to update an allocation change request.") return False def get(self, request, pk): @@ -2022,6 +2139,5 @@ def get(self, request, pk): allocation_attribute_change_obj.delete() - messages.success( - request, 'Allocation attribute change request successfully deleted.') - return HttpResponseRedirect(reverse('allocation-change-detail', kwargs={'pk': allocation_change_pk})) + messages.success(request, "Allocation attribute change request successfully deleted.") + return HttpResponseRedirect(reverse("allocation-change-detail", kwargs={"pk": allocation_change_pk})) diff --git a/coldfront/core/attribute_expansion.py b/coldfront/core/attribute_expansion.py index ff985b2d89..553a227269 100644 --- a/coldfront/core/attribute_expansion.py +++ b/coldfront/core/attribute_expansion.py @@ -1,21 +1,24 @@ -#Collection of functions related to attribute expansion. +# SPDX-FileCopyrightText: (C) ColdFront Authors # -#This is a collection common functions related to the expansion -#of parameters (typically related to other attributes) inside of -#attributes. Used in the expanded_value() method of AllocationAttribute -#and ResourceAttribute. +# SPDX-License-Identifier: AGPL-3.0-or-later + +# Collection of functions related to attribute expansion. +# +# This is a collection common functions related to the expansion +# of parameters (typically related to other attributes) inside of +# attributes. Used in the expanded_value() method of AllocationAttribute +# and ResourceAttribute. import logging import math - logger = logging.getLogger(__name__) -#ALLOCATION_ATTRIBUTE_VIEW_LIST = import_from_settings( +# ALLOCATION_ATTRIBUTE_VIEW_LIST = import_from_settings( # 'ALLOCATION_ATTRIBUTE_VIEW_LIST', []) -ATTRIBUTE_EXPANSION_TYPE_PREFIX = 'Attribute Expanded' -ATTRIBUTE_EXPANSION_ATTRIBLIST_SUFFIX = '_attriblist' +ATTRIBUTE_EXPANSION_TYPE_PREFIX = "Attribute Expanded" +ATTRIBUTE_EXPANSION_ATTRIBLIST_SUFFIX = "_attriblist" def is_expandable_type(attribute_type): @@ -23,13 +26,14 @@ def is_expandable_type(attribute_type): Takes an AttributeType (from either Resource or Allocation, but wants AttributeType, not ResourceAttributeType or - AllocationAttributeType) and checks if type name matches + AllocationAttributeType) and checks if type name matches ATTRIBUTE_EXPANSION_TYPE_PREFIX """ - atype_name = attribute_type.name; + atype_name = attribute_type.name return atype_name.startswith(ATTRIBUTE_EXPANSION_TYPE_PREFIX) + def get_attriblist_str(attribute_name, resources=[], allocations=[]): """This finds the attriblist string for the named expandable attribute. @@ -40,8 +44,7 @@ def get_attriblist_str(attribute_name, resources=[], allocations=[]): attriblist attributes are found, we return None. """ - attriblist_name = "{aname}{suffix}".format( - aname=attribute_name, suffix=ATTRIBUTE_EXPANSION_ATTRIBLIST_SUFFIX) + attriblist_name = "{aname}{suffix}".format(aname=attribute_name, suffix=ATTRIBUTE_EXPANSION_ATTRIBLIST_SUFFIX) attriblist = None # Check resources first @@ -49,7 +52,7 @@ def get_attriblist_str(attribute_name, resources=[], allocations=[]): alist_list = res.get_attribute_list(attriblist_name) for alist in alist_list: if attriblist: - attriblist = attriblist + '\n' + alist + attriblist = attriblist + "\n" + alist else: attriblist = alist # Then check allocations @@ -57,15 +60,14 @@ def get_attriblist_str(attribute_name, resources=[], allocations=[]): alist_list = alloc.get_attribute_list(attriblist_name) for alist in alist_list: if attriblist: - attriblist = attriblist + '\n' + alist + attriblist = attriblist + "\n" + alist else: attriblist = alist return attriblist -def get_attribute_parameter_value( - argument, attribute_parameter_dict, error_text, - resources=[], allocations=[]): + +def get_attribute_parameter_value(argument, attribute_parameter_dict, error_text, resources=[], allocations=[]): """Evaluates the argument for a attribute parameter statement. This is called by process_attribute_parameter_string and handles @@ -75,7 +77,7 @@ def get_attribute_parameter_value( APDICT:pname - expands to the value of a parameter named pname already in the attribute_parameter_dict (or None if not present) RESOURCE:aname - expands to the value of the first attribute of type - named aname found in the resources list of resources. + named aname found in the resources list of resources. NOTE: 'RESOURCE:' is a literal. Or None if not found. ALLOCATION:aname - expands to the value of the first attribute of type named aname found in the allocations list of allocations. @@ -88,17 +90,17 @@ def get_attribute_parameter_value( 'single line of text' - expands to a string literal contained between the two single quotes. Very simplistic, nothing is allowed after the last single quote, and we just remove the leading and trailing - single quote --- everything in between is treated literally + single quote --- everything in between is treated literally (including any contained single quotes). digits (optionally with decimal): expands to a numeric literal error_text is used to give context in diagnostic messages. - This method returns the expanded value, or None if unable to + This method returns the expanded value, or None if unable to evaluate """ value = None - + # Check for string constant if argument.startswith("'"): # Looks like a string literal @@ -107,20 +109,22 @@ def get_attribute_parameter_value( # Verify the last character is a single quote tmp = tmpstr[-1:] if tmp == "'": - #Good string literal + # Good string literal tmpstr = tmpstr[:-1] return tmpstr else: - #Bad string literal - logger.warn("Bad string literal '{}' found while processing " - "{}; missing final single quote".format( - argument, error_text)) + # Bad string literal + logger.warn( + "Bad string literal '{}' found while processing {}; missing final single quote".format( + argument, error_text + ) + ) return None # If argument if prefixed with any of the strings in attrib_sources, # strip the prefix and set attrib_source accordingly attrib_source = None - attrib_sources = [ ':APDICT', 'RESOURCE:', 'ALLOCATION:', ':' ] + attrib_sources = [":APDICT", "RESOURCE:", "ALLOCATION:", ":"] for asrc in attrib_sources: if argument.startswith(asrc): # Got a match @@ -132,18 +136,17 @@ def get_attribute_parameter_value( # Try expanding as a parameter/attribute # We do attribute_parameter_dict first, then allocations, then # resources to try to get value most specific to use case - if ( attribute_parameter_dict is not None and - (attrib_source == ':' or attrib_source == 'APDICT:')): + if attribute_parameter_dict is not None and (attrib_source == ":" or attrib_source == "APDICT:"): if argument in attribute_parameter_dict: return attribute_parameter_dict[argument] - if attrib_source == ':' or attrib_source == 'ALLOCATION:': + if attrib_source == ":" or attrib_source == "ALLOCATION:": for alloc in allocations: tmp = alloc.get_attribute(argument) if tmp is not None: return tmp - if attrib_source == ':' or attrib_source == 'RESOURCE:': + if attrib_source == ":" or attrib_source == "RESOURCE:": for res in resources: tmp = res.get_attribute(argument) if tmp is not None: @@ -154,7 +157,7 @@ def get_attribute_parameter_value( # find it. Just return None return None - # If reach here, argument is not a string literal, or a + # If reach here, argument is not a string literal, or a # parameter or attribute name, so try numeric constant try: value = int(argument) @@ -164,16 +167,18 @@ def get_attribute_parameter_value( value = float(argument) return value except ValueError: - logger.warn("Unable to evaluate argument '{arg}' while " - "processing {etxt}, returning None".format( - arg=argument, etxt=error_text)) + logger.warn( + "Unable to evaluate argument '{arg}' while processing {etxt}, returning None".format( + arg=argument, etxt=error_text + ) + ) return None # Should not reach here return None - -def process_attribute_parameter_operation( - opcode, oldvalue, argument, error_text): + + +def process_attribute_parameter_operation(opcode, oldvalue, argument, error_text): """Process the specified operation for attribute_parameter_dict. This is called by process_attribute_parameter_string and handles @@ -209,27 +214,25 @@ def process_attribute_parameter_operation( """ # Argument should never be None if argument is None: - logger.warn("Operator {}= acting on None argument in {}, " - "returning None".format(opcode, error_text)) + logger.warn("Operator {}= acting on None argument in {}, returning None".format(opcode, error_text)) return None # Assignment and default operations allow oldvalue to be None if oldvalue is None: - if opcode != ':' and opcode != '|': - logger.warn("Operator {}= acting on oldvalue=None in {}, " - "returning None".format(opcode, error_text)) + if opcode != ":" and opcode != "|": + logger.warn("Operator {}= acting on oldvalue=None in {}, returning None".format(opcode, error_text)) return None try: - if opcode == ':': + if opcode == ":": # Assignment operation return argument - if opcode == '|': + if opcode == "|": # Defaulting operation if oldvalue is None: return argument else: return oldvalue - if opcode == '+': + if opcode == "+": # Addition/concatenation operation if isinstance(oldvalue, int) or isinstance(oldvalue, float): newval = oldvalue + argument @@ -238,40 +241,43 @@ def process_attribute_parameter_operation( newval = oldvalue + argument return newval else: - logger.warn('Operator {}= acting on parameter of type ' - '{} in {}, returning None'.format( - opcode, type(oldvalue), error_text)) + logger.warn( + "Operator {}= acting on parameter of type {} in {}, returning None".format( + opcode, type(oldvalue), error_text + ) + ) return None - if opcode == '-': + if opcode == "-": newval = oldvalue - argument return newval - if opcode == '*': + if opcode == "*": newval = oldvalue * argument return newval - if opcode == '/': + if opcode == "/": newval = oldvalue / argument return newval - if opcode == '(': - if argument == 'floor': + if opcode == "(": + if argument == "floor": newval = math.floor(oldvalue) else: - logger.error('Unrecognized function named {} in {}= for ' - '{}, returning None'.format( - argument, opcode, error_text)) + logger.error( + "Unrecognized function named {} in {}= for {}, returning None".format(argument, opcode, error_text) + ) return None # If reached here, we do not recognize opcode - logger.error('Unrecognized operation {}= in {}, ' - 'returning None'.format( opcode, error_text)) - except Exception as xcept: - logger.warn("Error performing operator {op}= on oldvalue='{old}' " - "and argument={arg} in {errtext}".format( - op=opcode, old=oldvalue, arg=argument, errtext=error_text)) + logger.error("Unrecognized operation {}= in {}, returning None".format(opcode, error_text)) + except Exception: + logger.warn( + "Error performing operator {op}= on oldvalue='{old}' and argument={arg} in {errtext}".format( + op=opcode, old=oldvalue, arg=argument, errtext=error_text + ) + ) return None def process_attribute_parameter_string( - parameter_string, attribute_name, attribute_parameter_dict = {}, - resources = [], allocations = []): + parameter_string, attribute_name, attribute_parameter_dict={}, resources=[], allocations=[] +): """Processes a single attribute parameter definition/statement. This is called by make_attribute_parameter_dictionary, and handles @@ -295,7 +301,7 @@ def process_attribute_parameter_string( AllocationAttribute or ResourceAttribute (which is then replaced by its (expanded if expandable) value). - See the methods get_attribute_parameter_value() and + See the methods get_attribute_parameter_value() and process_attribute_parameter_operation() for more information about the operations and argument values. """ @@ -305,18 +311,19 @@ def process_attribute_parameter_string( # Ignore comment lines/blank lines (return attribute_parameter_dict) if not parmstr: return attribute_parameter_dict - if parmstr.startswith('#'): + if parmstr.startswith("#"): return attribute_parameter_dict # Parse the parameter string to get pname, op, and argument - tmp = parmstr.split('=', 1) + tmp = parmstr.split("=", 1) if len(tmp) != 2: # No '=' found, so invalid format of parmstr # Log error and return unmodified attribute_parameter_dict - logger.error("Invalid parameter string '{pstr}', no '=', while " + logger.error( + "Invalid parameter string '{pstr}', no '=', while " "creating attribute parameter dictionary for expanding " - "attribute {aname}".format( - aname=attribute_name, pstr=parameter_string)) + "attribute {aname}".format(aname=attribute_name, pstr=parameter_string) + ) return attribute_parameter_dict pname = tmp[0] argument = tmp[1].strip() @@ -327,19 +334,20 @@ def process_attribute_parameter_string( # Argument is a parameter/attribute/constant unless opcode is '(' # So get its value if parameter/attribute/constant value = None - if opcode == '(': + if opcode == "(": value = argument else: # Extra text to display in diagnostics if error occurs - error_text = 'processing attribute_parameter_string={pstr} ' \ - 'for expansion of attribute {aname}'.format( - pstr = parameter_string, aname=attribute_name) + error_text = "processing attribute_parameter_string={pstr} for expansion of attribute {aname}".format( + pstr=parameter_string, aname=attribute_name + ) value = get_attribute_parameter_value( - argument = argument, - attribute_parameter_dict = attribute_parameter_dict, - resources = resources, - allocations = allocations, - error_text = error_text) + argument=argument, + attribute_parameter_dict=attribute_parameter_dict, + resources=resources, + allocations=allocations, + error_text=error_text, + ) # Get the old value of the parameter if pname in attribute_parameter_dict: @@ -349,27 +357,26 @@ def process_attribute_parameter_string( # Perform the requested operation newval = process_attribute_parameter_operation( - opcode=opcode, oldvalue=oldval, argument=value, - error_text=error_text) + opcode=opcode, oldvalue=oldval, argument=value, error_text=error_text + ) # Set value in dictionary and return attribute_parameter_dict[pname] = newval return attribute_parameter_dict -def make_attribute_parameter_dictionary(attribute_name, - attribute_parameter_string, resources=[], allocations=[]): +def make_attribute_parameter_dictionary(attribute_name, attribute_parameter_string, resources=[], allocations=[]): """Create the attribute parameter dictionary. Used by expand_attribute. This processes the given attribute parameter string to generate a - dictionary that will (in expand_attribute()) be passed as the argument - to the standard python format() method acting on the raw value of the + dictionary that will (in expand_attribute()) be passed as the argument + to the standard python format() method acting on the raw value of the attribute to expand it. The attribute parameter string is a string consisting of one or more attribute parameter definitions, one per line, with the following general format: ' = ' - + This routine processes the attribute_parameter_string line by line, in order top to bottom, to generate the dictionary that is returned. @@ -381,28 +388,27 @@ def make_attribute_parameter_dictionary(attribute_name, apdict = dict() # Covert attribute_parameter_string to a real list - attrib_parm_list = list(map(str.strip, - attribute_parameter_string.splitlines() )) + attrib_parm_list = list(map(str.strip, attribute_parameter_string.splitlines())) # Process each element in the list for parmstr in attrib_parm_list: apdict = process_attribute_parameter_string( - parameter_string = parmstr, - attribute_parameter_dict = apdict, - attribute_name = attribute_name, - resources = resources, - allocations = allocations) + parameter_string=parmstr, + attribute_parameter_dict=apdict, + attribute_name=attribute_name, + resources=resources, + allocations=allocations, + ) return apdict -def expand_attribute(raw_value, attribute_name, attriblist_string, - resources = [], allocations = []): +def expand_attribute(raw_value, attribute_name, attriblist_string, resources=[], allocations=[]): """Main method to expand parameters in an attribute. - This takes the (raw) value raw_value of either an AllocationAttribute - or ResourceAttribute, which should be in a python formatted string - (f-string) format; i.e. a string with places where parameter - replacement is desired to have the name of the desired replacement - parameter enclosed in curly braces ('{' and '}'). The parameter name + This takes the (raw) value raw_value of either an AllocationAttribute + or ResourceAttribute, which should be in a python formatted string + (f-string) format; i.e. a string with places where parameter + replacement is desired to have the name of the desired replacement + parameter enclosed in curly braces ('{' and '}'). The parameter name can be followed by standard format() format specifiers, as per standard format() rules. The argument attribute_name should have the name of this attribute, for use in diagnostic messages. @@ -443,10 +449,11 @@ def expand_attribute(raw_value, attribute_name, attriblist_string, try: # Create the attribute parameter dictionary apdict = make_attribute_parameter_dictionary( - attribute_parameter_string = attriblist_string, - attribute_name = attribute_name, - resources = resources, - allocations = allocations) + attribute_parameter_string=attriblist_string, + attribute_name=attribute_name, + resources=resources, + allocations=allocations, + ) # Expand the attribute expanded = raw_value.format(**apdict) @@ -456,17 +463,16 @@ def expand_attribute(raw_value, attribute_name, attriblist_string, # referencing a parameter not defined in apdict to divide by # zero errors in processing apdict. We just log it and then # return raw_value - logger.error("Error expanding {aname}: {error}".format( - aname=attribute_name, error=xcept)) + logger.error("Error expanding {aname}: {error}".format(aname=attribute_name, error=xcept)) return raw_value -def convert_type(value, type_name, error_text='unknown'): +def convert_type(value, type_name, error_text="unknown"): """This returns value with a python type corresponding to type_name. Value is the value to operate on. Type_name is the name of the underlying attribute type (AttributeType), - e.g. Text, Float, Int, Date, etc. + e.g. Text, Float, Int, Date, etc. If type_name ends in Int, we try to return value as a python int. If type_name ends in Float, we try to return value as a python float. @@ -479,38 +485,32 @@ def convert_type(value, type_name, error_text='unknown'): future "Attribute Expanded ..." types. """ if type_name is None: - logger.error('No AttributeType found for {}'.format(error_text)) + logger.error("No AttributeType found for {}".format(error_text)) return value - if type_name.endswith('Text'): + if type_name.endswith("Text"): try: newval = str(value) return newval except ValueError: - logger.error('Error converting "{}" to {} in {}'.format( - value, 'Text', error_text)) + logger.error('Error converting "{}" to {} in {}'.format(value, "Text", error_text)) return value - if type_name.endswith('Int'): + if type_name.endswith("Int"): try: newval = int(value) return newval except ValueError: - logger.error('Error converting "{}" to {} in {}'.format( - value, 'Int', error_text)) + logger.error('Error converting "{}" to {} in {}'.format(value, "Int", error_text)) return value - if type_name.endswith('Float'): + if type_name.endswith("Float"): try: newval = float(value) return newval except ValueError: - logger.error('Error converting "{}" to {} in {}'.format( - value, 'Float', error_text)) + logger.error('Error converting "{}" to {} in {}'.format(value, "Float", error_text)) return value - #If not any of the above, just return the value (probably a string) + # If not any of the above, just return the value (probably a string) return value - - - diff --git a/coldfront/core/field_of_science/__init__.py b/coldfront/core/field_of_science/__init__.py index 7ef20f9442..7d532ca4ca 100644 --- a/coldfront/core/field_of_science/__init__.py +++ b/coldfront/core/field_of_science/__init__.py @@ -1 +1,5 @@ -default_app_config = 'coldfront.core.field_of_science.apps.FieldOfScienceConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +default_app_config = "coldfront.core.field_of_science.apps.FieldOfScienceConfig" diff --git a/coldfront/core/field_of_science/admin.py b/coldfront/core/field_of_science/admin.py index be099919c4..44d527b946 100644 --- a/coldfront/core/field_of_science/admin.py +++ b/coldfront/core/field_of_science/admin.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from coldfront.core.field_of_science.models import FieldOfScience @@ -6,12 +10,12 @@ @admin.register(FieldOfScience) class FieldOfScienceAdmin(admin.ModelAdmin): list_display = ( - 'description', - 'is_selectable', - 'parent_id', - 'fos_nsf_id', - 'fos_nsf_abbrev', - 'directorate_fos_id', + "description", + "is_selectable", + "parent_id", + "fos_nsf_id", + "fos_nsf_abbrev", + "directorate_fos_id", ) - list_filter = ('is_selectable', ) - search_fields = ['description'] + list_filter = ("is_selectable",) + search_fields = ["description"] diff --git a/coldfront/core/field_of_science/apps.py b/coldfront/core/field_of_science/apps.py index 55da13d858..b7cbc7fb0c 100644 --- a/coldfront/core/field_of_science/apps.py +++ b/coldfront/core/field_of_science/apps.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class FieldOfScienceConfig(AppConfig): - name = 'coldfront.core.field_of_science' - verbose_name = 'Field of Science' + name = "coldfront.core.field_of_science" + verbose_name = "Field of Science" diff --git a/coldfront/core/field_of_science/management/__init__.py b/coldfront/core/field_of_science/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/field_of_science/management/__init__.py +++ b/coldfront/core/field_of_science/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/field_of_science/management/commands/__init__.py b/coldfront/core/field_of_science/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/field_of_science/management/commands/__init__.py +++ b/coldfront/core/field_of_science/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py b/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py index 4d47eef6bc..67a9a33b1e 100644 --- a/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py +++ b/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from coldfront.core.field_of_science.models import FieldOfScience @@ -8,15 +12,17 @@ class Command(BaseCommand): - help = 'Import field of science data' + help = "Import field of science data" def handle(self, *args, **options): - print('Adding field of science ...') - file_path = os.path.join(app_commands_dir, 'data', 'field_of_science_data.csv') + print("Adding field of science ...") + file_path = os.path.join(app_commands_dir, "data", "field_of_science_data.csv") FieldOfScience.objects.all().delete() - with open(file_path, 'r') as fp: + with open(file_path, "r") as fp: for line in fp: - pk, parent_id, is_selectable, description, fos_nsf_id, fos_nsf_abbrev, directorate_fos_id = line.strip().split('\t') + pk, parent_id, is_selectable, description, fos_nsf_id, fos_nsf_abbrev, directorate_fos_id = ( + line.strip().split("\t") + ) fos = FieldOfScience( pk=pk, @@ -24,12 +30,12 @@ def handle(self, *args, **options): description=description, fos_nsf_id=fos_nsf_id, fos_nsf_abbrev=fos_nsf_abbrev, - directorate_fos_id=directorate_fos_id + directorate_fos_id=directorate_fos_id, ) fos.save() - if parent_id != 'self': + if parent_id != "self": parent_fos = FieldOfScience.objects.get(id=parent_id) - fos.parent_id=parent_fos + fos.parent_id = parent_fos fos.save() - print('Finished adding field of science') + print("Finished adding field of science") diff --git a/coldfront/core/field_of_science/migrations/0001_initial.py b/coldfront/core/field_of_science/migrations/0001_initial.py index a17e77b1ec..cdaa0ff0d1 100644 --- a/coldfront/core/field_of_science/migrations/0001_initial.py +++ b/coldfront/core/field_of_science/migrations/0001_initial.py @@ -1,34 +1,51 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields +from django.db import migrations, models class Migration(migrations.Migration): - initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='FieldOfScience', + name="FieldOfScience", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('is_selectable', models.BooleanField(default=True)), - ('description', models.CharField(max_length=255)), - ('fos_nsf_id', models.IntegerField(blank=True, null=True)), - ('fos_nsf_abbrev', models.CharField(blank=True, max_length=10, null=True)), - ('directorate_fos_id', models.IntegerField(blank=True, null=True)), - ('parent_id', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='field_of_science.FieldOfScience')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("is_selectable", models.BooleanField(default=True)), + ("description", models.CharField(max_length=255)), + ("fos_nsf_id", models.IntegerField(blank=True, null=True)), + ("fos_nsf_abbrev", models.CharField(blank=True, max_length=10, null=True)), + ("directorate_fos_id", models.IntegerField(blank=True, null=True)), + ( + "parent_id", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to="field_of_science.FieldOfScience" + ), + ), ], options={ - 'ordering': ['description'], + "ordering": ["description"], }, ), ] diff --git a/coldfront/core/field_of_science/migrations/0002_alter_fieldofscience_description.py b/coldfront/core/field_of_science/migrations/0002_alter_fieldofscience_description.py index 40813f6d90..36b65f12c6 100644 --- a/coldfront/core/field_of_science/migrations/0002_alter_fieldofscience_description.py +++ b/coldfront/core/field_of_science/migrations/0002_alter_fieldofscience_description.py @@ -1,18 +1,21 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.17 on 2023-04-06 15:33 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('field_of_science', '0001_initial'), + ("field_of_science", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='fieldofscience', - name='description', + model_name="fieldofscience", + name="description", field=models.CharField(max_length=255, unique=True), ), ] diff --git a/coldfront/core/field_of_science/migrations/__init__.py b/coldfront/core/field_of_science/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/field_of_science/migrations/__init__.py +++ b/coldfront/core/field_of_science/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/field_of_science/models.py b/coldfront/core/field_of_science/models.py index 89108a40c7..60e5de5265 100644 --- a/coldfront/core/field_of_science/models.py +++ b/coldfront/core/field_of_science/models.py @@ -1,9 +1,14 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.db import models from model_utils.models import TimeStampedModel + class FieldOfScience(TimeStampedModel): - """ A field of science is a division under which a project falls. The list is prepopulated in ColdFront using the National Science Foundation FOS list, but can be changed by a center admin if needed. Examples include Chemistry and Physics. - + """A field of science is a division under which a project falls. The list is prepopulated in ColdFront using the National Science Foundation FOS list, but can be changed by a center admin if needed. Examples include Chemistry and Physics. + Attributes: parent_id (FieldOfScience): represents parent field of science if it exists is_selectable (bool): indicates whether or not a field of science is selectable for a project @@ -12,15 +17,16 @@ class FieldOfScience(TimeStampedModel): fos_nsf_abbrev (str): represents the field of science's abbreviation under the National Science Foundation directorate_fos_id (int): represents the National Science Foundation's ID for the department the field of science falls under """ + class Meta: - ordering = ['description'] + ordering = ["description"] class FieldOfScienceManager(models.Manager): def get_by_natural_key(self, description): return self.get(description=description) DEFAULT_PK = 149 - parent_id = models.ForeignKey('self', on_delete=models.CASCADE, null=True) + parent_id = models.ForeignKey("self", on_delete=models.CASCADE, null=True) is_selectable = models.BooleanField(default=True) description = models.CharField(max_length=255, unique=True) fos_nsf_id = models.IntegerField(null=True, blank=True) diff --git a/coldfront/core/field_of_science/tests.py b/coldfront/core/field_of_science/tests.py index 9c2e460dd8..bcde4c4a4c 100644 --- a/coldfront/core/field_of_science/tests.py +++ b/coldfront/core/field_of_science/tests.py @@ -1,27 +1,31 @@ -from coldfront.core.test_helpers.factories import FieldOfScienceFactory +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.exceptions import ValidationError from django.test import TestCase from coldfront.core.field_of_science.models import FieldOfScience +from coldfront.core.test_helpers.factories import FieldOfScienceFactory + class TestFieldOfScience(TestCase): class Data: """Collection of test data, separated for readability""" def __init__(self): - self.initial_fields = { - 'pk': 11, - 'parent_id': FieldOfScienceFactory(), - 'is_selectable': False, - 'description': 'Astronomical Sciences', - 'fos_nsf_id': 120, - 'fos_nsf_abbrev': 'AST', - 'directorate_fos_id': 1 + "pk": 11, + "parent_id": FieldOfScienceFactory(), + "is_selectable": False, + "description": "Astronomical Sciences", + "fos_nsf_id": 120, + "fos_nsf_abbrev": "AST", + "directorate_fos_id": 1, } - + self.unsaved_object = FieldOfScience(**self.initial_fields) - + def setUp(self): self.data = self.Data() @@ -58,13 +62,13 @@ def test_nsf_abbrev_optional(self): self.assertEqual(1, len(FieldOfScience.objects.all())) fos_obj = self.data.unsaved_object - fos_obj.fos_nsf_abbrev = '' + fos_obj.fos_nsf_abbrev = "" fos_obj.save() self.assertEqual(2, len(FieldOfScience.objects.all())) retrieved_obj = FieldOfScience.objects.get(pk=fos_obj.pk) - self.assertEqual('', retrieved_obj.fos_nsf_abbrev) + self.assertEqual("", retrieved_obj.fos_nsf_abbrev) def test_directorate_fos_id_optional(self): self.assertEqual(1, len(FieldOfScience.objects.all())) @@ -80,11 +84,11 @@ def test_directorate_fos_id_optional(self): def test_description_maxlength(self): expected_maximum_length = 255 - maximum_description = 'x' * expected_maximum_length + maximum_description = "x" * expected_maximum_length fos_obj = self.data.unsaved_object - fos_obj.description = maximum_description + 'x' + fos_obj.description = maximum_description + "x" with self.assertRaises(ValidationError): fos_obj.clean_fields() @@ -97,11 +101,11 @@ def test_description_maxlength(self): def test_nsf_abbrev_maxlength(self): expected_maximum_length = 10 - maximum_nsf_abbrev = 'x' * expected_maximum_length + maximum_nsf_abbrev = "x" * expected_maximum_length fos_obj = self.data.unsaved_object - fos_obj.fos_nsf_abbrev = maximum_nsf_abbrev + 'x' + fos_obj.fos_nsf_abbrev = maximum_nsf_abbrev + "x" with self.assertRaises(ValidationError): fos_obj.clean_fields() diff --git a/coldfront/core/field_of_science/views.py b/coldfront/core/field_of_science/views.py index 91ea44a218..2fa8704650 100644 --- a/coldfront/core/field_of_science/views.py +++ b/coldfront/core/field_of_science/views.py @@ -1,3 +1,5 @@ -from django.shortcuts import render +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your views here. diff --git a/coldfront/core/grant/__init__.py b/coldfront/core/grant/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/grant/__init__.py +++ b/coldfront/core/grant/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/grant/admin.py b/coldfront/core/grant/admin.py index ef8add720e..b77b71f36c 100644 --- a/coldfront/core/grant/admin.py +++ b/coldfront/core/grant/admin.py @@ -1,33 +1,67 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin from coldfront.core.grant.models import Grant, GrantFundingAgency -from simple_history.admin import SimpleHistoryAdmin @admin.register(GrantFundingAgency) class GrantFundingAgencyChoiceAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) @admin.register(Grant) class GrantAdmin(SimpleHistoryAdmin): - readonly_fields = ('project', 'created', 'modified',) - fields = ('project', 'title', 'grant_number', 'role', 'grant_pi_full_name', 'funding_agency', 'other_funding_agency', 'other_award_number', 'grant_start', - 'grant_end', 'percent_credit', 'direct_funding', 'total_amount_awarded', 'status', 'created', 'modified') - list_display = ['title', 'Project_PI', 'role', - 'grant_pi_full_name', 'Funding_Agency', 'status', 'grant_end', ] - list_filter = ('funding_agency', 'role', 'status', 'grant_end') - search_fields = ['project__title', - 'project__pi__username', - 'project__pi__first_name', - 'project__pi__last_name', - 'funding_agency__name', 'grant_pi_full_name'] + readonly_fields = ( + "project", + "created", + "modified", + ) + fields = ( + "project", + "title", + "grant_number", + "role", + "grant_pi_full_name", + "funding_agency", + "other_funding_agency", + "other_award_number", + "grant_start", + "grant_end", + "percent_credit", + "direct_funding", + "total_amount_awarded", + "status", + "created", + "modified", + ) + list_display = [ + "title", + "Project_PI", + "role", + "grant_pi_full_name", + "Funding_Agency", + "status", + "grant_end", + ] + list_filter = ("funding_agency", "role", "status", "grant_end") + search_fields = [ + "project__title", + "project__pi__username", + "project__pi__first_name", + "project__pi__last_name", + "funding_agency__name", + "grant_pi_full_name", + ] def Project_PI(self, obj): - return '{} {} ({})'.format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) + return "{} {} ({})".format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) def Funding_Agency(self, obj): - if obj.funding_agency.name == 'Other': + if obj.funding_agency.name == "Other": return obj.other_funding_agency else: return obj.funding_agency.name diff --git a/coldfront/core/grant/apps.py b/coldfront/core/grant/apps.py index 6e10b638a0..63cd903726 100644 --- a/coldfront/core/grant/apps.py +++ b/coldfront/core/grant/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class GrantConfig(AppConfig): - name = 'coldfront.core.grant' + name = "coldfront.core.grant" diff --git a/coldfront/core/grant/forms.py b/coldfront/core/grant/forms.py index 0a6f5c8909..f40024b357 100644 --- a/coldfront/core/grant/forms.py +++ b/coldfront/core/grant/forms.py @@ -1,38 +1,46 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms from django.forms import ModelForm -from django.shortcuts import get_object_or_404 -from coldfront.core.grant.models import Grant, MoneyField +from coldfront.core.grant.models import Grant from coldfront.core.utils.common import import_from_settings -CENTER_NAME = import_from_settings('CENTER_NAME') +CENTER_NAME = import_from_settings("CENTER_NAME") class GrantForm(ModelForm): class Meta: model = Grant - exclude = ['project', ] + exclude = [ + "project", + ] labels = { - 'percent_credit': 'Percent credit to {}'.format(CENTER_NAME), - 'direct_funding': 'Direct funding to {}'.format(CENTER_NAME) + "percent_credit": "Percent credit to {}".format(CENTER_NAME), + "direct_funding": "Direct funding to {}".format(CENTER_NAME), } help_texts = { - 'percent_credit': 'Percent credit as entered in the sponsored projects form for grant submission as financial credit to the department/unit in the credit distribution section. Enter only digits, decimals, percent symbols, or spaces.', - 'direct_funding': 'Funds budgeted specifically for {} services, hardware, software, and/or personnel. Enter only digits, decimals, commas, dollar signs, or spaces.'.format(CENTER_NAME), - 'total_amount_awarded': 'Enter only digits, decimals, commas, dollar signs, or spaces.' + "percent_credit": "Percent credit as entered in the sponsored projects form for grant submission as financial credit to the department/unit in the credit distribution section. Enter only digits, decimals, percent symbols, or spaces.", + "direct_funding": "Funds budgeted specifically for {} services, hardware, software, and/or personnel. Enter only digits, decimals, commas, dollar signs, or spaces.".format( + CENTER_NAME + ), + "total_amount_awarded": "Enter only digits, decimals, commas, dollar signs, or spaces.", } def __init__(self, *args, **kwargs): - super(GrantForm, self).__init__(*args, **kwargs) - self.fields['funding_agency'].queryset = self.fields['funding_agency'].queryset.order_by('name') + super(GrantForm, self).__init__(*args, **kwargs) + self.fields["funding_agency"].queryset = self.fields["funding_agency"].queryset.order_by("name") + class GrantDeleteForm(forms.Form): title = forms.CharField(max_length=255, disabled=True) - grant_number = forms.CharField( - max_length=30, required=False, disabled=True) + grant_number = forms.CharField(max_length=30, required=False, disabled=True) grant_end = forms.CharField(max_length=150, required=False, disabled=True) selected = forms.BooleanField(initial=False, required=False) + class GrantDownloadForm(forms.Form): pk = forms.IntegerField(required=False, disabled=True) title = forms.CharField(required=False, disabled=True) @@ -52,4 +60,4 @@ class GrantDownloadForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() diff --git a/coldfront/core/grant/management/__init__.py b/coldfront/core/grant/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/grant/management/__init__.py +++ b/coldfront/core/grant/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/grant/management/commands/__init__.py b/coldfront/core/grant/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/grant/management/commands/__init__.py +++ b/coldfront/core/grant/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/grant/management/commands/add_default_grant_options.py b/coldfront/core/grant/management/commands/add_default_grant_options.py index 4c7424abfc..e5d93ee71b 100644 --- a/coldfront/core/grant/management/commands/add_default_grant_options.py +++ b/coldfront/core/grant/management/commands/add_default_grant_options.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os from django.core.management.base import BaseCommand @@ -9,23 +13,26 @@ class Command(BaseCommand): def handle(self, *args, **options): - GrantFundingAgency.objects.all().delete() for choice in [ - 'Department of Defense (DoD)', - 'Department of Energy (DOE)', - 'Environmental Protection Agency (EPA)', - 'National Aeronautics and Space Administration (NASA)', - 'National Institutes of Health (NIH)', - 'National Science Foundation (NSF)', - 'New York State Department of Health (DOH)', - 'New York State (NYS)', - 'Empire State Development (ESD)', - "Empire State Development's Division of Science, Technology and Innovation (NYSTAR)", - 'Other' - ]: + "Department of Defense (DoD)", + "Department of Energy (DOE)", + "Environmental Protection Agency (EPA)", + "National Aeronautics and Space Administration (NASA)", + "National Institutes of Health (NIH)", + "National Science Foundation (NSF)", + "New York State Department of Health (DOH)", + "New York State (NYS)", + "Empire State Development (ESD)", + "Empire State Development's Division of Science, Technology and Innovation (NYSTAR)", + "Other", + ]: GrantFundingAgency.objects.get_or_create(name=choice) GrantStatusChoice.objects.all().delete() - for choice in ['Active', 'Archived', 'Pending', ]: + for choice in [ + "Active", + "Archived", + "Pending", + ]: GrantStatusChoice.objects.get_or_create(name=choice) diff --git a/coldfront/core/grant/migrations/0001_initial.py b/coldfront/core/grant/migrations/0001_initial.py index c3ce0f3658..c8c5068cb2 100644 --- a/coldfront/core/grant/migrations/0001_initial.py +++ b/coldfront/core/grant/migrations/0001_initial.py @@ -1,105 +1,253 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0001_initial'), + ("project", "0001_initial"), ] operations = [ migrations.CreateModel( - name='GrantFundingAgency', + name="GrantFundingAgency", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=255)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=255)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='GrantStatusChoice', + name="GrantStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ('name',), + "ordering": ("name",), }, ), migrations.CreateModel( - name='HistoricalGrant', + name="HistoricalGrant", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(3), django.core.validators.MaxLengthValidator(255)])), - ('grant_number', models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(3), django.core.validators.MaxLengthValidator(255)], verbose_name='Grant Number from funding agency')), - ('role', models.CharField(choices=[('PI', 'Principal Investigator (PI)'), ('CoPI', 'Co-Principal Investigator (CoPI)'), ('SP', 'Senior Personnel (SP)')], max_length=10)), - ('grant_pi_full_name', models.CharField(blank=True, max_length=255, verbose_name='Grant PI Full Name')), - ('other_funding_agency', models.CharField(blank=True, max_length=255)), - ('other_award_number', models.CharField(blank=True, max_length=255)), - ('grant_start', models.DateField(verbose_name='Grant Start Date')), - ('grant_end', models.DateField(verbose_name='Grant End Date')), - ('percent_credit', models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), - ('direct_funding', models.FloatField()), - ('total_amount_awarded', models.FloatField()), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('funding_agency', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='grant.GrantFundingAgency')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='grant.GrantStatusChoice')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "title", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(3), + django.core.validators.MaxLengthValidator(255), + ], + ), + ), + ( + "grant_number", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(3), + django.core.validators.MaxLengthValidator(255), + ], + verbose_name="Grant Number from funding agency", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("PI", "Principal Investigator (PI)"), + ("CoPI", "Co-Principal Investigator (CoPI)"), + ("SP", "Senior Personnel (SP)"), + ], + max_length=10, + ), + ), + ("grant_pi_full_name", models.CharField(blank=True, max_length=255, verbose_name="Grant PI Full Name")), + ("other_funding_agency", models.CharField(blank=True, max_length=255)), + ("other_award_number", models.CharField(blank=True, max_length=255)), + ("grant_start", models.DateField(verbose_name="Grant Start Date")), + ("grant_end", models.DateField(verbose_name="Grant End Date")), + ("percent_credit", models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), + ("direct_funding", models.FloatField()), + ("total_amount_awarded", models.FloatField()), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "funding_agency", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="grant.GrantFundingAgency", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="grant.GrantStatusChoice", + ), + ), ], options={ - 'verbose_name': 'historical grant', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical grant", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='Grant', + name="Grant", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(3), django.core.validators.MaxLengthValidator(255)])), - ('grant_number', models.CharField(max_length=255, validators=[django.core.validators.MinLengthValidator(3), django.core.validators.MaxLengthValidator(255)], verbose_name='Grant Number from funding agency')), - ('role', models.CharField(choices=[('PI', 'Principal Investigator (PI)'), ('CoPI', 'Co-Principal Investigator (CoPI)'), ('SP', 'Senior Personnel (SP)')], max_length=10)), - ('grant_pi_full_name', models.CharField(blank=True, max_length=255, verbose_name='Grant PI Full Name')), - ('other_funding_agency', models.CharField(blank=True, max_length=255)), - ('other_award_number', models.CharField(blank=True, max_length=255)), - ('grant_start', models.DateField(verbose_name='Grant Start Date')), - ('grant_end', models.DateField(verbose_name='Grant End Date')), - ('percent_credit', models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), - ('direct_funding', models.FloatField()), - ('total_amount_awarded', models.FloatField()), - ('funding_agency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='grant.GrantFundingAgency')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), - ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='grant.GrantStatusChoice')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "title", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(3), + django.core.validators.MaxLengthValidator(255), + ], + ), + ), + ( + "grant_number", + models.CharField( + max_length=255, + validators=[ + django.core.validators.MinLengthValidator(3), + django.core.validators.MaxLengthValidator(255), + ], + verbose_name="Grant Number from funding agency", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("PI", "Principal Investigator (PI)"), + ("CoPI", "Co-Principal Investigator (CoPI)"), + ("SP", "Senior Personnel (SP)"), + ], + max_length=10, + ), + ), + ("grant_pi_full_name", models.CharField(blank=True, max_length=255, verbose_name="Grant PI Full Name")), + ("other_funding_agency", models.CharField(blank=True, max_length=255)), + ("other_award_number", models.CharField(blank=True, max_length=255)), + ("grant_start", models.DateField(verbose_name="Grant Start Date")), + ("grant_end", models.DateField(verbose_name="Grant End Date")), + ("percent_credit", models.FloatField(validators=[django.core.validators.MaxValueValidator(100)])), + ("direct_funding", models.FloatField()), + ("total_amount_awarded", models.FloatField()), + ( + "funding_agency", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="grant.GrantFundingAgency"), + ), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), + ( + "status", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="grant.GrantStatusChoice"), + ), ], options={ - 'verbose_name_plural': 'Grants', - 'permissions': (('can_view_all_grants', 'Can view all grants'),), + "verbose_name_plural": "Grants", + "permissions": (("can_view_all_grants", "Can view all grants"),), }, ), ] diff --git a/coldfront/core/grant/migrations/0002_auto_20230406_1310.py b/coldfront/core/grant/migrations/0002_auto_20230406_1310.py index 921e6b9814..edcab61213 100644 --- a/coldfront/core/grant/migrations/0002_auto_20230406_1310.py +++ b/coldfront/core/grant/migrations/0002_auto_20230406_1310.py @@ -1,23 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.17 on 2023-04-06 17:10 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('grant', '0001_initial'), + ("grant", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='grantfundingagency', - name='name', + model_name="grantfundingagency", + name="name", field=models.CharField(max_length=255, unique=True), ), migrations.AlterField( - model_name='grantstatuschoice', - name='name', + model_name="grantstatuschoice", + name="name", field=models.CharField(max_length=64, unique=True), ), ] diff --git a/coldfront/core/grant/migrations/__init__.py b/coldfront/core/grant/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/grant/migrations/__init__.py +++ b/coldfront/core/grant/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/grant/models.py b/coldfront/core/grant/models.py index 9251574dc6..d1fa1b28f4 100644 --- a/coldfront/core/grant/models.py +++ b/coldfront/core/grant/models.py @@ -1,16 +1,19 @@ -from django.core.validators import (MaxLengthValidator, MaxValueValidator, - MinLengthValidator) -from django.db import models +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.exceptions import ValidationError +from django.core.validators import MaxLengthValidator, MaxValueValidator, MinLengthValidator, RegexValidator +from django.db import models from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords -from django.core.validators import RegexValidator from coldfront.core.project.models import Project + class GrantFundingAgency(TimeStampedModel): - """ A grant funding agency is an agency that funds projects. Examples include Department of Defense (DoD) and National Aeronautics and Space Administration (NASA). - + """A grant funding agency is an agency that funds projects. Examples include Department of Defense (DoD) and National Aeronautics and Space Administration (NASA). + Attributes: name (str): agency name """ @@ -28,14 +31,16 @@ def __str__(self): def natural_key(self): return [self.name] + class GrantStatusChoice(TimeStampedModel): - """ A grant status choice is an option a user has when setting the status of a grant. Examples include Active, Archived, and Pending. - + """A grant status choice is an option a user has when setting the status of a grant. Examples include Active, Archived, and Pending. + Attributes: name (str): status name """ + class Meta: - ordering = ('name',) + ordering = ("name",) class GrantStatusManager(models.Manager): def get_by_natural_key(self, name): @@ -49,13 +54,15 @@ def __str__(self): def natural_key(self): return [self.name] - + + class MoneyField(models.CharField): validators = [ - RegexValidator(r'\$*[\d,.]{1,}$', - 'Enter only digits, decimals, commas, dollar signs, or spaces.', - 'Invalid input.') + RegexValidator( + r"\$*[\d,.]{1,}$", "Enter only digits, decimals, commas, dollar signs, or spaces.", "Invalid input." + ) ] + def to_python(self, value): value = super().to_python(value) if value: @@ -63,13 +70,15 @@ def to_python(self, value): value = value.replace(",", "") value = value.replace("$", "") return value - + + class PercentField(models.CharField): validators = [ - RegexValidator(r'^[\d,.]{1,6}\%*$', - 'Enter only digits, decimals, percent symbols, or spaces.', - 'Invalid input.') + RegexValidator( + r"^[\d,.]{1,6}\%*$", "Enter only digits, decimals, percent symbols, or spaces.", "Invalid input." + ) ] + def to_python(self, value): value = super().to_python(value) if value: @@ -82,9 +91,10 @@ def to_python(self, value): pass return value + class Grant(TimeStampedModel): - """ A grant is funding that a PI receives for their project. - + """A grant is funding that a PI receives for their project. + Attributes: project (Project): links the project to the grant title (str): grant title @@ -101,33 +111,33 @@ class Grant(TimeStampedModel): total_amount_awarded (float): indicates the total amount awarded status (GrantStatusChoice): represents the status of the grant """ - + project = models.ForeignKey(Project, on_delete=models.CASCADE) title = models.CharField( validators=[MinLengthValidator(3), MaxLengthValidator(255)], max_length=255, ) grant_number = models.CharField( - 'Grant Number from funding agency', + "Grant Number from funding agency", validators=[MinLengthValidator(3), MaxLengthValidator(255)], max_length=255, ) ROLE_CHOICES = ( - ('PI', 'Principal Investigator (PI)'), - ('CoPI', 'Co-Principal Investigator (CoPI)'), - ('SP', 'Senior Personnel (SP)'), + ("PI", "Principal Investigator (PI)"), + ("CoPI", "Co-Principal Investigator (CoPI)"), + ("SP", "Senior Personnel (SP)"), ) role = models.CharField( max_length=10, choices=ROLE_CHOICES, ) - grant_pi_full_name = models.CharField('Grant PI Full Name', max_length=255, blank=True) + grant_pi_full_name = models.CharField("Grant PI Full Name", max_length=255, blank=True) funding_agency = models.ForeignKey(GrantFundingAgency, on_delete=models.CASCADE) other_funding_agency = models.CharField(max_length=255, blank=True) other_award_number = models.CharField(max_length=255, blank=True) - grant_start = models.DateField('Grant Start Date') - grant_end = models.DateField('Grant End Date') + grant_start = models.DateField("Grant Start Date") + grant_end = models.DateField("Grant End Date") percent_credit = PercentField(max_length=100, validators=[MaxValueValidator(100)]) direct_funding = MoneyField(max_length=100) total_amount_awarded = MoneyField(max_length=100) @@ -136,13 +146,13 @@ class Grant(TimeStampedModel): @property def grant_pi(self): - """ + """ Returns: str: the grant's PI's full name """ - if self.role == 'PI': - return '{} {}'.format(self.project.pi.first_name, self.project.pi.last_name) + if self.role == "PI": + return "{} {}".format(self.project.pi.first_name, self.project.pi.last_name) else: return self.grant_pi_full_name @@ -152,6 +162,4 @@ def __str__(self): class Meta: verbose_name_plural = "Grants" - permissions = ( - ("can_view_all_grants", "Can view all grants"), - ) + permissions = (("can_view_all_grants", "Can view all grants"),) diff --git a/coldfront/core/grant/tests.py b/coldfront/core/grant/tests.py index 2b7cf7aada..038ddd063e 100644 --- a/coldfront/core/grant/tests.py +++ b/coldfront/core/grant/tests.py @@ -1,17 +1,20 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime from dateutil.relativedelta import relativedelta - from django.core.exceptions import ValidationError from django.test import TestCase +from coldfront.core.grant.models import Grant from coldfront.core.test_helpers.factories import ( GrantFundingAgencyFactory, GrantStatusChoiceFactory, ProjectFactory, ) -from coldfront.core.grant.models import Grant class TestGrant(TestCase): class Data: @@ -19,30 +22,29 @@ class Data: def __init__(self): project = ProjectFactory() - grantFundingAgency = GrantFundingAgencyFactory(name='Department of Defense (DoD)') - grantStatusChoice = GrantStatusChoiceFactory(name='Active') + grantFundingAgency = GrantFundingAgencyFactory(name="Department of Defense (DoD)") + grantStatusChoice = GrantStatusChoiceFactory(name="Active") start_date = datetime.date.today() end_date = start_date + relativedelta(days=900) - self.initial_fields = { - 'project': project, - 'title':'Quantum Halls', - 'grant_number':'12345', - 'role':'PI', - 'grant_pi_full_name':'Stephanie Foster', - 'funding_agency': grantFundingAgency, - 'grant_start':start_date, - 'grant_end':end_date, - 'percent_credit':20.0, - 'direct_funding':200000.0, - 'total_amount_awarded':1000000.0, - 'status': grantStatusChoice + "project": project, + "title": "Quantum Halls", + "grant_number": "12345", + "role": "PI", + "grant_pi_full_name": "Stephanie Foster", + "funding_agency": grantFundingAgency, + "grant_start": start_date, + "grant_end": end_date, + "percent_credit": 20.0, + "direct_funding": 200000.0, + "total_amount_awarded": 1000000.0, + "status": grantStatusChoice, } - + self.unsaved_object = Grant(**self.initial_fields) - + def setUp(self): self.data = self.Data() @@ -65,7 +67,7 @@ def test_fields_generic(self): def test_title_minlength(self): expected_minimum_length = 3 - minimum_title = 'x' * expected_minimum_length + minimum_title = "x" * expected_minimum_length grant_obj = self.data.unsaved_object @@ -82,11 +84,11 @@ def test_title_minlength(self): def test_title_maxlength(self): expected_maximum_length = 255 - maximum_title = 'x' * expected_maximum_length + maximum_title = "x" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.title = maximum_title + 'x' + grant_obj.title = maximum_title + "x" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -99,7 +101,7 @@ def test_title_maxlength(self): def test_grant_number_minlength(self): expected_minimum_length = 3 - minimum_grant_number = '1' * expected_minimum_length + minimum_grant_number = "1" * expected_minimum_length grant_obj = self.data.unsaved_object @@ -116,11 +118,11 @@ def test_grant_number_minlength(self): def test_grant_number_maxlength(self): expected_maximum_length = 255 - maximum_grant_number = '1' * expected_maximum_length + maximum_grant_number = "1" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.grant_number = maximum_grant_number + '1' + grant_obj.grant_number = maximum_grant_number + "1" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -133,11 +135,11 @@ def test_grant_number_maxlength(self): def test_grant_pi_maxlength(self): expected_maximum_length = 255 - maximum_grant_pi_full_name = 'x' * expected_maximum_length + maximum_grant_pi_full_name = "x" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.grant_pi_full_name = maximum_grant_pi_full_name + 'x' + grant_obj.grant_pi_full_name = maximum_grant_pi_full_name + "x" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -152,21 +154,21 @@ def test_grant_pi_optional(self): self.assertEqual(0, len(Grant.objects.all())) grant_obj = self.data.unsaved_object - grant_obj.grant_pi_full_name = '' + grant_obj.grant_pi_full_name = "" grant_obj.save() self.assertEqual(1, len(Grant.objects.all())) retrieved_obj = Grant.objects.get(pk=grant_obj.pk) - self.assertEqual('', retrieved_obj.grant_pi_full_name) + self.assertEqual("", retrieved_obj.grant_pi_full_name) def test_other_funding_agency_maxlength(self): expected_maximum_length = 255 - maximum_other_funding_agency = 'x' * expected_maximum_length + maximum_other_funding_agency = "x" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.other_funding_agency = maximum_other_funding_agency + 'x' + grant_obj.other_funding_agency = maximum_other_funding_agency + "x" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -181,21 +183,21 @@ def test_other_funding_agency_optional(self): self.assertEqual(0, len(Grant.objects.all())) grant_obj = self.data.unsaved_object - grant_obj.other_funding_agency = '' + grant_obj.other_funding_agency = "" grant_obj.save() self.assertEqual(1, len(Grant.objects.all())) retrieved_obj = Grant.objects.get(pk=grant_obj.pk) - self.assertEqual('', retrieved_obj.other_funding_agency) + self.assertEqual("", retrieved_obj.other_funding_agency) def test_other_award_number_maxlength(self): expected_maximum_length = 255 - maxiumum_other_award_number = '1' * expected_maximum_length + maxiumum_other_award_number = "1" * expected_maximum_length grant_obj = self.data.unsaved_object - grant_obj.other_award_number = maxiumum_other_award_number + '1' + grant_obj.other_award_number = maxiumum_other_award_number + "1" with self.assertRaises(ValidationError): grant_obj.clean_fields() @@ -210,13 +212,13 @@ def test_other_award_number_optional(self): self.assertEqual(0, len(Grant.objects.all())) grant_obj = self.data.unsaved_object - grant_obj.other_award_number = '' + grant_obj.other_award_number = "" grant_obj.save() self.assertEqual(1, len(Grant.objects.all())) retrieved_obj = Grant.objects.get(pk=grant_obj.pk) - self.assertEqual('', retrieved_obj.other_award_number) + self.assertEqual("", retrieved_obj.other_award_number) def test_percent_credit_maxvalue(self): expected_maximum_value = 100 @@ -235,17 +237,17 @@ def test_percent_credit_maxvalue(self): self.assertEqual(expected_maximum_value, retrieved_obj.percent_credit) def test_project_foreignkey_on_delete(self): - grant_obj = self.data.unsaved_object - grant_obj.save() + grant_obj = self.data.unsaved_object + grant_obj.save() - self.assertEqual(1, len(Grant.objects.all())) + self.assertEqual(1, len(Grant.objects.all())) - grant_obj.project.delete() + grant_obj.project.delete() - # expecting CASCADE - with self.assertRaises(Grant.DoesNotExist): - Grant.objects.get(pk=grant_obj.pk) - self.assertEqual(0, len(Grant.objects.all())) + # expecting CASCADE + with self.assertRaises(Grant.DoesNotExist): + Grant.objects.get(pk=grant_obj.pk) + self.assertEqual(0, len(Grant.objects.all())) def test_funding_agency_foreignkey_on_delete(self): grant_obj = self.data.unsaved_object @@ -272,4 +274,3 @@ def test_status_foreignkey_on_delete(self): with self.assertRaises(Grant.DoesNotExist): Grant.objects.get(pk=grant_obj.pk) self.assertEqual(0, len(Grant.objects.all())) - diff --git a/coldfront/core/grant/urls.py b/coldfront/core/grant/urls.py index 3a083ae5ef..3efbbeaa0d 100644 --- a/coldfront/core/grant/urls.py +++ b/coldfront/core/grant/urls.py @@ -1,11 +1,19 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.grant.views as grant_views urlpatterns = [ - path('project//create', grant_views.GrantCreateView.as_view(), name='grant-create'), - path('/update/', grant_views.GrantUpdateView.as_view(), name='grant-update'), - path('project//delete-grants/', grant_views.GrantDeleteGrantsView.as_view(), name='grant-delete-grants'), - path('grant-report/', grant_views.GrantReportView.as_view(), name='grant-report'), - path('grant-download/', grant_views.GrantDownloadView.as_view(), name='grant-download'), + path("project//create", grant_views.GrantCreateView.as_view(), name="grant-create"), + path("/update/", grant_views.GrantUpdateView.as_view(), name="grant-update"), + path( + "project//delete-grants/", + grant_views.GrantDeleteGrantsView.as_view(), + name="grant-delete-grants", + ), + path("grant-report/", grant_views.GrantReportView.as_view(), name="grant-report"), + path("grant-download/", grant_views.GrantDownloadView.as_view(), name="grant-download"), ] diff --git a/coldfront/core/grant/views.py b/coldfront/core/grant/views.py index 72ac345eec..ed6f075d53 100644 --- a/coldfront/core/grant/views.py +++ b/coldfront/core/grant/views.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import csv from django.contrib import messages @@ -7,249 +11,259 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.views import View -from django.views.generic import DetailView, FormView, ListView, TemplateView -from django.views.generic.edit import CreateView, UpdateView +from django.views.generic import FormView, ListView, TemplateView +from django.views.generic.edit import UpdateView -from coldfront.core.utils.common import Echo from coldfront.core.grant.forms import GrantDeleteForm, GrantDownloadForm, GrantForm -from coldfront.core.grant.models import (Grant, GrantFundingAgency, - GrantStatusChoice) +from coldfront.core.grant.models import Grant from coldfront.core.project.models import Project +from coldfront.core.utils.common import Echo class GrantCreateView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = GrantForm - template_name = 'grant/grant_create.html' + template_name = "grant/grant_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error(self.request, 'You do not have permission to add a new grant to this project.') + messages.error(self.request, "You do not have permission to add a new grant to this project.") def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error(request, 'You cannot add grants to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add grants to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def form_valid(self, form): form_data = form.cleaned_data - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) - grant_obj = Grant.objects.create( + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + Grant.objects.create( project=project_obj, - title=form_data.get('title'), - grant_number=form_data.get('grant_number'), - role=form_data.get('role'), - grant_pi_full_name=form_data.get('grant_pi_full_name'), - funding_agency=form_data.get('funding_agency'), - other_funding_agency=form_data.get('other_funding_agency'), - other_award_number=form_data.get('other_award_number'), - grant_start=form_data.get('grant_start'), - grant_end=form_data.get('grant_end'), - percent_credit=form_data.get('percent_credit'), - direct_funding=form_data.get('direct_funding'), - total_amount_awarded=form_data.get('total_amount_awarded'), - status=form_data.get('status'), + title=form_data.get("title"), + grant_number=form_data.get("grant_number"), + role=form_data.get("role"), + grant_pi_full_name=form_data.get("grant_pi_full_name"), + funding_agency=form_data.get("funding_agency"), + other_funding_agency=form_data.get("other_funding_agency"), + other_award_number=form_data.get("other_award_number"), + grant_start=form_data.get("grant_start"), + grant_end=form_data.get("grant_end"), + percent_credit=form_data.get("percent_credit"), + direct_funding=form_data.get("direct_funding"), + total_amount_awarded=form_data.get("total_amount_awarded"), + status=form_data.get("status"), ) return super().form_valid(form) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['project'] = Project.objects.get(pk=self.kwargs.get('project_pk')) + context["project"] = Project.objects.get(pk=self.kwargs.get("project_pk")) return context def get_success_url(self): - messages.success(self.request, 'Added a grant.') - return reverse('project-detail', kwargs={'pk': self.kwargs.get('project_pk')}) + messages.success(self.request, "Added a grant.") + return reverse("project-detail", kwargs={"pk": self.kwargs.get("project_pk")}) class GrantUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - grant_obj = get_object_or_404(Grant, pk=self.kwargs.get('pk')) + grant_obj = get_object_or_404(Grant, pk=self.kwargs.get("pk")) if grant_obj.project.pi == self.request.user: return True - if grant_obj.project.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if grant_obj.project.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error(self.request, 'You do not have permission to update grant from this project.') + messages.error(self.request, "You do not have permission to update grant from this project.") model = Grant - template_name_suffix = '_update_form' - fields = ['title', 'grant_number', 'role', 'grant_pi_full_name', 'funding_agency', 'other_funding_agency', - 'other_award_number', 'grant_start', 'grant_end', 'percent_credit', 'direct_funding', 'total_amount_awarded', 'status', ] + template_name_suffix = "_update_form" + fields = [ + "title", + "grant_number", + "role", + "grant_pi_full_name", + "funding_agency", + "other_funding_agency", + "other_award_number", + "grant_start", + "grant_end", + "percent_credit", + "direct_funding", + "total_amount_awarded", + "status", + ] def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project.id}) + return reverse("project-detail", kwargs={"pk": self.object.project.id}) class GrantDeleteGrantsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'grant/grant_delete_grants.html' + template_name = "grant/grant_delete_grants.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error(self.request, 'You do not have permission to delete grants from this project.') + messages.error(self.request, "You do not have permission to delete grants from this project.") def get_grants_to_delete(self, project_obj): - grants_to_delete = [ - - {'title': grant.title, - 'grant_number': grant.grant_number, - 'grant_end': grant.grant_end} - + {"title": grant.title, "grant_number": grant.grant_number, "grant_end": grant.grant_end} for grant in project_obj.grant_set.all() ] return grants_to_delete def get(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) grants_to_delete = self.get_grants_to_delete(project_obj) context = {} if grants_to_delete: formset = formset_factory(GrantDeleteForm, max_num=len(grants_to_delete)) - formset = formset(initial=grants_to_delete, prefix='grantform') - context['formset'] = formset + formset = formset(initial=grants_to_delete, prefix="grantform") + context["formset"] = formset - context['project'] = project_obj + context["project"] = project_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) grants_to_delete = self.get_grants_to_delete(project_obj) - context = {} formset = formset_factory(GrantDeleteForm, max_num=len(grants_to_delete)) - formset = formset(request.POST, initial=grants_to_delete, prefix='grantform') + formset = formset(request.POST, initial=grants_to_delete, prefix="grantform") grants_deleted_count = 0 if formset.is_valid(): for form in formset: grant_form_data = form.cleaned_data - if grant_form_data['selected']: - + if grant_form_data["selected"]: grant_obj = Grant.objects.get( project=project_obj, - title=grant_form_data.get('title'), - grant_number=grant_form_data.get('grant_number') + title=grant_form_data.get("title"), + grant_number=grant_form_data.get("grant_number"), ) grant_obj.delete() grants_deleted_count += 1 - messages.success(request, 'Deleted {} grants from project.'.format(grants_deleted_count)) + messages.success(request, "Deleted {} grants from project.".format(grants_deleted_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project.id}) + return reverse("project-detail", kwargs={"pk": self.object.project.id}) class GrantReportView(LoginRequiredMixin, UserPassesTestMixin, ListView): - template_name = 'grant/grant_report_list.html' + template_name = "grant/grant_report_list.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('grant.can_view_all_grants'): + if self.request.user.has_perm("grant.can_view_all_grants"): return True - messages.error(self.request, 'You do not have permission to view all grants.') - + messages.error(self.request, "You do not have permission to view all grants.") def get_grants(self): - grants = Grant.objects.prefetch_related( - 'project', 'project__pi').all().order_by('-total_amount_awarded') - grants= [ - - {'pk': grant.pk, - 'title': grant.title, - 'project_pk': grant.project.pk, - 'pi_first_name': grant.project.pi.first_name, - 'pi_last_name':grant.project.pi.last_name, - 'role': grant.role, - 'grant_pi': grant.grant_pi, - 'total_amount_awarded': grant.total_amount_awarded, - 'funding_agency': grant.funding_agency, - 'grant_number': grant.grant_number, - 'grant_start': grant.grant_start, - 'grant_end': grant.grant_end, - 'percent_credit': grant.percent_credit, - 'direct_funding': grant.direct_funding, + grants = Grant.objects.prefetch_related("project", "project__pi").all().order_by("-total_amount_awarded") + grants = [ + { + "pk": grant.pk, + "title": grant.title, + "project_pk": grant.project.pk, + "pi_first_name": grant.project.pi.first_name, + "pi_last_name": grant.project.pi.last_name, + "role": grant.role, + "grant_pi": grant.grant_pi, + "total_amount_awarded": grant.total_amount_awarded, + "funding_agency": grant.funding_agency, + "grant_number": grant.grant_number, + "grant_start": grant.grant_start, + "grant_end": grant.grant_end, + "percent_credit": grant.percent_credit, + "direct_funding": grant.direct_funding, } for grant in grants ] return grants - def get(self, request, *args, **kwargs): context = {} grants = self.get_grants() if grants: formset = formset_factory(GrantDownloadForm, max_num=len(grants)) - formset = formset(initial=grants, prefix='grantdownloadform') - context['formset'] = formset + formset = formset(initial=grants, prefix="grantdownloadform") + context["formset"] = formset return render(request, self.template_name, context) - def post(self, request, *args, **kwargs): grants = self.get_grants() formset = formset_factory(GrantDownloadForm, max_num=len(grants)) - formset = formset(request.POST, initial=grants, prefix='grantdownloadform') + formset = formset(request.POST, initial=grants, prefix="grantdownloadform") header = [ - 'Grant Title', - 'Project PI', - 'Faculty Role', - 'Grant PI', - 'Total Amount Awarded', - 'Funding Agency', - 'Grant Number', - 'Start Date', - 'End Date', - 'Percent Credit', - 'Direct Funding', + "Grant Title", + "Project PI", + "Faculty Role", + "Grant PI", + "Total Amount Awarded", + "Funding Agency", + "Grant Number", + "Start Date", + "End Date", + "Percent Credit", + "Direct Funding", ] rows = [] grants_selected_count = 0 @@ -257,12 +271,12 @@ def post(self, request, *args, **kwargs): if formset.is_valid(): for form in formset: form_data = form.cleaned_data - if form_data['selected']: - grant = get_object_or_404(Grant, pk=form_data['pk']) + if form_data["selected"]: + grant = get_object_or_404(Grant, pk=form_data["pk"]) row = [ grant.title, - ' '.join((grant.project.pi.first_name, grant.project.pi.last_name)), + " ".join((grant.project.pi.first_name, grant.project.pi.last_name)), grant.role, grant.grant_pi_full_name, grant.total_amount_awarded, @@ -278,11 +292,13 @@ def post(self, request, *args, **kwargs): grants_selected_count += 1 if grants_selected_count == 0: - grants = Grant.objects.prefetch_related('project', 'project__pi').all().order_by('-total_amount_awarded') + grants = ( + Grant.objects.prefetch_related("project", "project__pi").all().order_by("-total_amount_awarded") + ) for grant in grants: row = [ grant.title, - ' '.join((grant.project.pi.first_name, grant.project.pi.last_name)), + " ".join((grant.project.pi.first_name, grant.project.pi.last_name)), grant.role, grant.grant_pi_full_name, grant.total_amount_awarded, @@ -298,51 +314,49 @@ def post(self, request, *args, **kwargs): rows.insert(0, header) pseudo_buffer = Echo() writer = csv.writer(pseudo_buffer) - response = StreamingHttpResponse((writer.writerow(row) for row in rows), - content_type="text/csv") - response['Content-Disposition'] = 'attachment; filename="grants.csv"' + response = StreamingHttpResponse((writer.writerow(row) for row in rows), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="grants.csv"' return response else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('grant-report')) + return HttpResponseRedirect(reverse("grant-report")) class GrantDownloadView(LoginRequiredMixin, UserPassesTestMixin, View): login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('grant.can_view_all_grants'): + if self.request.user.has_perm("grant.can_view_all_grants"): return True - messages.error(self.request, 'You do not have permission to download all grants.') + messages.error(self.request, "You do not have permission to download all grants.") def get(self, request): - header = [ - 'Grant Title', - 'Project PI', - 'Faculty Role', - 'Grant PI', - 'Total Amount Awarded', - 'Funding Agency', - 'Grant Number', - 'Start Date', - 'End Date', - 'Percent Credit', - 'Direct Funding', + "Grant Title", + "Project PI", + "Faculty Role", + "Grant PI", + "Total Amount Awarded", + "Funding Agency", + "Grant Number", + "Start Date", + "End Date", + "Percent Credit", + "Direct Funding", ] rows = [] - grants = Grant.objects.prefetch_related('project', 'project__pi').all().order_by('-total_amount_awarded') + grants = Grant.objects.prefetch_related("project", "project__pi").all().order_by("-total_amount_awarded") for grant in grants: row = [ grant.title, - ' '.join((grant.project.pi.first_name, grant.project.pi.last_name)), + " ".join((grant.project.pi.first_name, grant.project.pi.last_name)), grant.role, grant.grant_pi_full_name, grant.total_amount_awarded, @@ -358,7 +372,6 @@ def get(self, request): rows.insert(0, header) pseudo_buffer = Echo() writer = csv.writer(pseudo_buffer) - response = StreamingHttpResponse((writer.writerow(row) for row in rows), - content_type="text/csv") - response['Content-Disposition'] = 'attachment; filename="grants.csv"' + response = StreamingHttpResponse((writer.writerow(row) for row in rows), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="grants.csv"' return response diff --git a/coldfront/core/portal/__init__.py b/coldfront/core/portal/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/portal/__init__.py +++ b/coldfront/core/portal/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/portal/admin.py b/coldfront/core/portal/admin.py index 2b05ba5487..a49c53902b 100644 --- a/coldfront/core/portal/admin.py +++ b/coldfront/core/portal/admin.py @@ -1,15 +1,21 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from django.contrib.admin.models import LogEntry @admin.register(LogEntry) class LogEntryAdmin(admin.ModelAdmin): - list_display = ('content_type', - 'user', - 'action_time', - 'object_id', - 'object_repr', - 'action_flag', - 'change_message',) - - search_fields = ['user__username', 'user__first_name', 'user__last_name'] + list_display = ( + "content_type", + "user", + "action_time", + "object_id", + "object_repr", + "action_flag", + "change_message", + ) + + search_fields = ["user__username", "user__first_name", "user__last_name"] diff --git a/coldfront/core/portal/apps.py b/coldfront/core/portal/apps.py index 988452e2af..e1c682b52f 100644 --- a/coldfront/core/portal/apps.py +++ b/coldfront/core/portal/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class PortalConfig(AppConfig): - name = 'coldfront.core.portal' + name = "coldfront.core.portal" diff --git a/coldfront/core/portal/migrations/__init__.py b/coldfront/core/portal/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/portal/migrations/__init__.py +++ b/coldfront/core/portal/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/portal/models.py b/coldfront/core/portal/models.py index 71a8362390..73294d7dba 100644 --- a/coldfront/core/portal/models.py +++ b/coldfront/core/portal/models.py @@ -1,3 +1,5 @@ -from django.db import models +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your models here. diff --git a/coldfront/core/portal/templatetags/__init__.py b/coldfront/core/portal/templatetags/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/portal/templatetags/__init__.py +++ b/coldfront/core/portal/templatetags/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/portal/templatetags/portal_tags.py b/coldfront/core/portal/templatetags/portal_tags.py index 692cf9a2e0..cc430f1a26 100644 --- a/coldfront/core/portal/templatetags/portal_tags.py +++ b/coldfront/core/portal/templatetags/portal_tags.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import template from django.conf import settings diff --git a/coldfront/core/portal/tests.py b/coldfront/core/portal/tests.py index 7ce503c2dd..576ead011d 100644 --- a/coldfront/core/portal/tests.py +++ b/coldfront/core/portal/tests.py @@ -1,3 +1,5 @@ -from django.test import TestCase +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your tests here. diff --git a/coldfront/core/portal/utils.py b/coldfront/core/portal/utils.py index fa5ae8dd11..5ddacaff98 100644 --- a/coldfront/core/portal/utils.py +++ b/coldfront/core/portal/utils.py @@ -1,10 +1,13 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime from coldfront.core.allocation.models import Allocation def generate_publication_by_year_chart_data(publications_by_year): - if publications_by_year: years, publications = zip(*publications_by_year) years = list(years) @@ -12,81 +15,55 @@ def generate_publication_by_year_chart_data(publications_by_year): years.insert(0, "Year") publications.insert(0, "Publications") - data = { - "x": "Year", - "columns": [ - years, - publications - ], - "type": "bar", - "colors": { - "Publications": '#17a2b8' - } - } + data = {"x": "Year", "columns": [years, publications], "type": "bar", "colors": {"Publications": "#17a2b8"}} else: - data = { - "columns": [], - "type": 'bar' - } + data = {"columns": [], "type": "bar"} return data def generate_total_grants_by_agency_chart_data(total_grants_by_agency): - - grants_agency_chart_data = { - "columns": total_grants_by_agency, - "type": 'donut' - } + grants_agency_chart_data = {"columns": total_grants_by_agency, "type": "donut"} return grants_agency_chart_data def generate_resources_chart_data(allocations_count_by_resource_type): - - if allocations_count_by_resource_type: - cluster_label = "Cluster: %d" % (allocations_count_by_resource_type.get('Cluster', 0)) - cloud_label = "Cloud: %d" % (allocations_count_by_resource_type.get('Cloud', 0)) - server_label = "Server: %d" % (allocations_count_by_resource_type.get('Server', 0)) - storage_label = "Storage: %d" % (allocations_count_by_resource_type.get('Storage', 0)) + cluster_label = "Cluster: %d" % (allocations_count_by_resource_type.get("Cluster", 0)) + cloud_label = "Cloud: %d" % (allocations_count_by_resource_type.get("Cloud", 0)) + server_label = "Server: %d" % (allocations_count_by_resource_type.get("Server", 0)) + storage_label = "Storage: %d" % (allocations_count_by_resource_type.get("Storage", 0)) resource_plot_data = { "columns": [ - [cluster_label, allocations_count_by_resource_type.get('Cluster', 0)], - [storage_label, allocations_count_by_resource_type.get('Storage', 0)], - [cloud_label, allocations_count_by_resource_type.get('Cloud', 0)], - [server_label, allocations_count_by_resource_type.get('Server', 0)] - + [cluster_label, allocations_count_by_resource_type.get("Cluster", 0)], + [storage_label, allocations_count_by_resource_type.get("Storage", 0)], + [cloud_label, allocations_count_by_resource_type.get("Cloud", 0)], + [server_label, allocations_count_by_resource_type.get("Server", 0)], ], - "type": 'donut', + "type": "donut", "colors": { - cluster_label: '#6da04b', - storage_label: '#ffc72c', - cloud_label: '#2f9fd0', - server_label: '#e56a54', - - } + cluster_label: "#6da04b", + storage_label: "#ffc72c", + cloud_label: "#2f9fd0", + server_label: "#e56a54", + }, } else: - resource_plot_data = { - "type": 'donut', - "columns": [] - } + resource_plot_data = {"type": "donut", "columns": []} return resource_plot_data def generate_allocations_chart_data(): - - active_count = Allocation.objects.filter(status__name='Active').count() - new_count = Allocation.objects.filter(status__name='New').count() - renewal_requested_count = Allocation.objects.filter(status__name='Renewal Requested').count() + active_count = Allocation.objects.filter(status__name="Active").count() + new_count = Allocation.objects.filter(status__name="New").count() + renewal_requested_count = Allocation.objects.filter(status__name="Renewal Requested").count() now = datetime.datetime.now() start_time = datetime.date(now.year - 1, 1, 1) - expired_count = Allocation.objects.filter( - status__name='Expired', end_date__gte=start_time).count() + expired_count = Allocation.objects.filter(status__name="Expired", end_date__gte=start_time).count() active_label = "Active: %d" % (active_count) new_label = "New: %d" % (new_count) @@ -100,13 +77,13 @@ def generate_allocations_chart_data(): [renewal_requested_label, renewal_requested_count], [expired_label, expired_count], ], - "type": 'donut', + "type": "donut", "colors": { - active_label: '#6da04b', - new_label: '#2f9fd0', - renewal_requested_label: '#ffc72c', - expired_label: '#e56a54', - } + active_label: "#6da04b", + new_label: "#2f9fd0", + renewal_requested_label: "#ffc72c", + expired_label: "#e56a54", + }, } return allocation_chart_data diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py index ed94fc9eaa..7effdf07ea 100644 --- a/coldfront/core/portal/views.py +++ b/coldfront/core/portal/views.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import operator from collections import Counter @@ -9,61 +13,99 @@ from coldfront.core.allocation.models import Allocation, AllocationUser from coldfront.core.grant.models import Grant -from coldfront.core.portal.utils import (generate_allocations_chart_data, - generate_publication_by_year_chart_data, - generate_resources_chart_data, - generate_total_grants_by_agency_chart_data) +from coldfront.core.portal.utils import ( + generate_allocations_chart_data, + generate_publication_by_year_chart_data, + generate_resources_chart_data, + generate_total_grants_by_agency_chart_data, +) from coldfront.core.project.models import Project from coldfront.core.publication.models import Publication from coldfront.core.research_output.models import ResearchOutput from coldfront.core.utils.common import import_from_settings -ALLOCATION_EULA_ENABLE = import_from_settings('ALLOCATION_EULA_ENABLE', False) +ALLOCATION_EULA_ENABLE = import_from_settings("ALLOCATION_EULA_ENABLE", False) def home(request): - context = {} if request.user.is_authenticated: - template_name = 'portal/authorized_home.html' - project_list = Project.objects.filter( - (Q(pi=request.user) & Q(status__name__in=['New', 'Active', ])) | - (Q(status__name__in=['New', 'Active', ]) & - Q(projectuser__user=request.user) & - Q(projectuser__status__name__in=['Active', ])) - ).distinct().order_by('-created')[:5] - - allocation_list = Allocation.objects.filter( - Q(status__name__in=['Active', 'New', 'Renewal Requested', ]) & - Q(project__status__name__in=['Active', 'New']) & - Q(project__projectuser__user=request.user) & - Q(project__projectuser__status__name__in=['Active', ]) & - Q(allocationuser__user=request.user) & - Q(allocationuser__status__name__in=['Active', 'PendingEULA']) - ).distinct().order_by('-created')[:5] - - + template_name = "portal/authorized_home.html" + project_list = ( + Project.objects.filter( + ( + Q(pi=request.user) + & Q( + status__name__in=[ + "New", + "Active", + ] + ) + ) + | ( + Q( + status__name__in=[ + "New", + "Active", + ] + ) + & Q(projectuser__user=request.user) + & Q( + projectuser__status__name__in=[ + "Active", + ] + ) + ) + ) + .distinct() + .order_by("-created")[:5] + ) + + allocation_list = ( + Allocation.objects.filter( + Q( + status__name__in=[ + "Active", + "New", + "Renewal Requested", + ] + ) + & Q(project__status__name__in=["Active", "New"]) + & Q(project__projectuser__user=request.user) + & Q( + project__projectuser__status__name__in=[ + "Active", + ] + ) + & Q(allocationuser__user=request.user) + & Q(allocationuser__status__name__in=["Active", "PendingEULA"]) + ) + .distinct() + .order_by("-created")[:5] + ) + if ALLOCATION_EULA_ENABLE: - user_status = [] + user_status = [] for allocation in allocation_list: if allocation.allocationuser_set.filter(user=request.user).exists(): user_status.append(allocation.allocationuser_set.get(user=request.user).status.name) - context['user_status'] = user_status - - context['project_list'] = project_list - context['allocation_list'] = allocation_list - + context["user_status"] = user_status + + context["project_list"] = project_list + context["allocation_list"] = allocation_list + try: - context['ondemand_url'] = settings.ONDEMAND_URL + context["ondemand_url"] = settings.ONDEMAND_URL except AttributeError: pass else: - template_name = 'portal/nonauthorized_home.html' + template_name = "portal/nonauthorized_home.html" - context['EXTRA_APPS'] = settings.INSTALLED_APPS + context["EXTRA_APPS"] = settings.INSTALLED_APPS - if 'coldfront.plugins.system_monitor' in settings.INSTALLED_APPS: + if "coldfront.plugins.system_monitor" in settings.INSTALLED_APPS: from coldfront.plugins.system_monitor.utils import get_system_monitor_context + context.update(get_system_monitor_context()) return render(request, template_name, context) @@ -73,96 +115,116 @@ def center_summary(request): context = {} # Publications Card - publications_by_year = list(Publication.objects.filter(year__gte=1999).values( - 'unique_id', 'year').distinct().values('year').annotate(num_pub=Count('year')).order_by('-year')) - - publications_by_year = [(ele['year'], ele['num_pub']) - for ele in publications_by_year] - - publication_by_year_bar_chart_data = generate_publication_by_year_chart_data( - publications_by_year) - context['publication_by_year_bar_chart_data'] = publication_by_year_bar_chart_data - context['total_publications_count'] = Publication.objects.filter( - year__gte=1999).values('unique_id', 'year').distinct().count() + publications_by_year = list( + Publication.objects.filter(year__gte=1999) + .values("unique_id", "year") + .distinct() + .values("year") + .annotate(num_pub=Count("year")) + .order_by("-year") + ) + + publications_by_year = [(ele["year"], ele["num_pub"]) for ele in publications_by_year] + + publication_by_year_bar_chart_data = generate_publication_by_year_chart_data(publications_by_year) + context["publication_by_year_bar_chart_data"] = publication_by_year_bar_chart_data + context["total_publications_count"] = ( + Publication.objects.filter(year__gte=1999).values("unique_id", "year").distinct().count() + ) # Research Outputs card - context['total_research_outputs_count'] = ResearchOutput.objects.all().distinct().count() + context["total_research_outputs_count"] = ResearchOutput.objects.all().distinct().count() # Grants Card - total_grants_by_agency_sum = list(Grant.objects.values( - 'funding_agency__name').annotate(total_amount=Sum('total_amount_awarded'))) - - total_grants_by_agency_count = list(Grant.objects.values( - 'funding_agency__name').annotate(count=Count('total_amount_awarded'))) - - total_grants_by_agency_count = { - ele['funding_agency__name']: ele['count'] for ele in total_grants_by_agency_count} - - total_grants_by_agency = [['{}: ${} ({})'.format( - ele['funding_agency__name'], - intcomma(int(ele['total_amount'])), - total_grants_by_agency_count[ele['funding_agency__name']] - ), ele['total_amount']] for ele in total_grants_by_agency_sum] - - total_grants_by_agency = sorted( - total_grants_by_agency, key=operator.itemgetter(1), reverse=True) - grants_agency_chart_data = generate_total_grants_by_agency_chart_data( - total_grants_by_agency) - context['grants_agency_chart_data'] = grants_agency_chart_data - context['grants_total'] = intcomma( - int(sum(list(Grant.objects.values_list('total_amount_awarded', flat=True))))) - context['grants_total_pi_only'] = intcomma( - int(sum(list(Grant.objects.filter(role='PI').values_list('total_amount_awarded', flat=True))))) - context['grants_total_copi_only'] = intcomma( - int(sum(list(Grant.objects.filter(role='CoPI').values_list('total_amount_awarded', flat=True))))) - context['grants_total_sp_only'] = intcomma( - int(sum(list(Grant.objects.filter(role='SP').values_list('total_amount_awarded', flat=True))))) - - return render(request, 'portal/center_summary.html', context) + total_grants_by_agency_sum = list( + Grant.objects.values("funding_agency__name").annotate(total_amount=Sum("total_amount_awarded")) + ) + + total_grants_by_agency_count = list( + Grant.objects.values("funding_agency__name").annotate(count=Count("total_amount_awarded")) + ) + + total_grants_by_agency_count = {ele["funding_agency__name"]: ele["count"] for ele in total_grants_by_agency_count} + + total_grants_by_agency = [ + [ + "{}: ${} ({})".format( + ele["funding_agency__name"], + intcomma(int(ele["total_amount"])), + total_grants_by_agency_count[ele["funding_agency__name"]], + ), + ele["total_amount"], + ] + for ele in total_grants_by_agency_sum + ] + + total_grants_by_agency = sorted(total_grants_by_agency, key=operator.itemgetter(1), reverse=True) + grants_agency_chart_data = generate_total_grants_by_agency_chart_data(total_grants_by_agency) + context["grants_agency_chart_data"] = grants_agency_chart_data + context["grants_total"] = intcomma(int(sum(list(Grant.objects.values_list("total_amount_awarded", flat=True))))) + context["grants_total_pi_only"] = intcomma( + int(sum(list(Grant.objects.filter(role="PI").values_list("total_amount_awarded", flat=True)))) + ) + context["grants_total_copi_only"] = intcomma( + int(sum(list(Grant.objects.filter(role="CoPI").values_list("total_amount_awarded", flat=True)))) + ) + context["grants_total_sp_only"] = intcomma( + int(sum(list(Grant.objects.filter(role="SP").values_list("total_amount_awarded", flat=True)))) + ) + + return render(request, "portal/center_summary.html", context) @cache_page(60 * 15) def allocation_by_fos(request): - - allocations_by_fos = Counter(list(Allocation.objects.filter( - status__name='Active').values_list('project__field_of_science__description', flat=True))) - - user_allocations = AllocationUser.objects.filter( - status__name='Active', allocation__status__name='Active') - - active_users_by_fos = Counter(list(user_allocations.values_list( - 'allocation__project__field_of_science__description', flat=True))) - total_allocations_users = user_allocations.values( - 'user').distinct().count() - - active_pi_count = Project.objects.filter(status__name__in=['Active', 'New']).values_list( - 'pi__username', flat=True).distinct().count() + allocations_by_fos = Counter( + list( + Allocation.objects.filter(status__name="Active").values_list( + "project__field_of_science__description", flat=True + ) + ) + ) + + user_allocations = AllocationUser.objects.filter(status__name="Active", allocation__status__name="Active") + + active_users_by_fos = Counter( + list(user_allocations.values_list("allocation__project__field_of_science__description", flat=True)) + ) + total_allocations_users = user_allocations.values("user").distinct().count() + + active_pi_count = ( + Project.objects.filter(status__name__in=["Active", "New"]) + .values_list("pi__username", flat=True) + .distinct() + .count() + ) context = {} - context['allocations_by_fos'] = dict(allocations_by_fos) - context['active_users_by_fos'] = dict(active_users_by_fos) - context['total_allocations_users'] = total_allocations_users - context['active_pi_count'] = active_pi_count - return render(request, 'portal/allocation_by_fos.html', context) + context["allocations_by_fos"] = dict(allocations_by_fos) + context["active_users_by_fos"] = dict(active_users_by_fos) + context["total_allocations_users"] = total_allocations_users + context["active_pi_count"] = active_pi_count + return render(request, "portal/allocation_by_fos.html", context) @cache_page(60 * 15) def allocation_summary(request): - allocation_resources = [ - allocation.get_parent_resource.parent_resource if allocation.get_parent_resource.parent_resource else allocation.get_parent_resource for allocation in Allocation.objects.filter(status__name='Active')] + allocation.get_parent_resource.parent_resource + if allocation.get_parent_resource.parent_resource + else allocation.get_parent_resource + for allocation in Allocation.objects.filter(status__name="Active") + ] allocations_count_by_resource = dict(Counter(allocation_resources)) - allocation_count_by_resource_type = dict( - Counter([ele.resource_type.name for ele in allocation_resources])) + allocation_count_by_resource_type = dict(Counter([ele.resource_type.name for ele in allocation_resources])) allocations_chart_data = generate_allocations_chart_data() - resources_chart_data = generate_resources_chart_data( - allocation_count_by_resource_type) + resources_chart_data = generate_resources_chart_data(allocation_count_by_resource_type) context = {} - context['allocations_chart_data'] = allocations_chart_data - context['allocations_count_by_resource'] = allocations_count_by_resource - context['resources_chart_data'] = resources_chart_data + context["allocations_chart_data"] = allocations_chart_data + context["allocations_count_by_resource"] = allocations_count_by_resource + context["resources_chart_data"] = resources_chart_data - return render(request, 'portal/allocation_summary.html', context) + return render(request, "portal/allocation_summary.html", context) diff --git a/coldfront/core/project/__init__.py b/coldfront/core/project/__init__.py index 8e75e3212b..ce27ea4442 100644 --- a/coldfront/core/project/__init__.py +++ b/coldfront/core/project/__init__.py @@ -1 +1,5 @@ -default_app_config = 'coldfront.core.project.apps.ProjectConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +default_app_config = "coldfront.core.project.apps.ProjectConfig" diff --git a/coldfront/core/project/admin.py b/coldfront/core/project/admin.py index a9887508d0..f5c9f17e07 100644 --- a/coldfront/core/project/admin.py +++ b/coldfront/core/project/admin.py @@ -1,55 +1,85 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import textwrap from django.contrib import admin -from simple_history.admin import SimpleHistoryAdmin from django.utils.translation import gettext_lazy as _ +from simple_history.admin import SimpleHistoryAdmin -from coldfront.core.project.models import (Project, ProjectAdminComment, - ProjectReview, ProjectStatusChoice, - ProjectUser, ProjectUserMessage, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - ProjectAttribute, - ProjectAttributeType, - AttributeType, - ProjectAttributeUsage) +from coldfront.core.project.models import ( + AttributeType, + Project, + ProjectAdminComment, + ProjectAttribute, + ProjectAttributeType, + ProjectAttributeUsage, + ProjectReview, + ProjectStatusChoice, + ProjectUser, + ProjectUserMessage, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) from coldfront.core.utils.common import import_from_settings -PROJECT_CODE = import_from_settings('PROJECT_CODE', False) +PROJECT_CODE = import_from_settings("PROJECT_CODE", False) + @admin.register(ProjectStatusChoice) class ProjectStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name',) + list_display = ("name",) @admin.register(ProjectUserRoleChoice) class ProjectUserRoleChoiceAdmin(admin.ModelAdmin): - list_display = ('name',) + list_display = ("name",) @admin.register(ProjectUserStatusChoice) class ProjectUserStatusChoiceAdmin(admin.ModelAdmin): - list_display = ('name',) + list_display = ("name",) @admin.register(ProjectUser) class ProjectUserAdmin(SimpleHistoryAdmin): - fields_change = ('user', 'project', 'role', 'status', 'created', 'modified', ) - readonly_fields_change = ('user', 'project', 'created', 'modified', ) - list_display = ('pk', 'project_title', 'PI', 'User', 'role', 'status', - 'created', 'modified',) - list_filter = ('role', 'status') - search_fields = ['user__username', 'user__first_name', 'user__last_name'] - raw_id_fields = ('user', 'project') + fields_change = ( + "user", + "project", + "role", + "status", + "created", + "modified", + ) + readonly_fields_change = ( + "user", + "project", + "created", + "modified", + ) + list_display = ( + "pk", + "project_title", + "PI", + "User", + "role", + "status", + "created", + "modified", + ) + list_filter = ("role", "status") + search_fields = ["user__username", "user__first_name", "user__last_name"] + raw_id_fields = ("user", "project") def project_title(self, obj): return textwrap.shorten(obj.project.title, width=50) def PI(self, obj): - return '{} {} ({})'.format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) + return "{} {} ({})".format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) def User(self, obj): - return '{} {} ({})'.format(obj.user.first_name, obj.user.last_name, obj.user.username) + return "{} {} ({})".format(obj.user.first_name, obj.user.last_name, obj.user.username) def get_fields(self, request, obj): if obj is None: @@ -74,39 +104,51 @@ def get_inline_instances(self, request, obj=None): class ProjectUserInline(admin.TabularInline): model = ProjectUser - fields = ['user', 'project', 'role', 'status', 'enable_notifications', ] - readonly_fields = ['user', 'project', ] + fields = [ + "user", + "project", + "role", + "status", + "enable_notifications", + ] + readonly_fields = [ + "user", + "project", + ] extra = 0 class ProjectAdminCommentInline(admin.TabularInline): model = ProjectAdminComment extra = 0 - fields = ('comment', 'author', 'created'), - readonly_fields = ('author', 'created') + fields = (("comment", "author", "created"),) + readonly_fields = ("author", "created") class ProjectUserMessageInline(admin.TabularInline): model = ProjectUserMessage extra = 0 - fields = ('message', 'author', 'created'), - readonly_fields = ('author', 'created') + fields = (("message", "author", "created"),) + readonly_fields = ("author", "created") class ProjectAttributeInLine(admin.TabularInline): model = ProjectAttribute extra = 0 - fields = ('proj_attr_type', 'value',) + fields = ( + "proj_attr_type", + "value", + ) @admin.register(AttributeType) class AttributeTypeAdmin(admin.ModelAdmin): - list_display = ('name', ) + list_display = ("name",) @admin.register(ProjectAttributeType) class ProjectAttributeTypeAdmin(admin.ModelAdmin): - list_display = ('pk', 'name', 'attribute_type', 'has_usage', 'is_private') + list_display = ("pk", "name", "attribute_type", "has_usage", "is_private") class ProjectAttributeUsageInline(admin.TabularInline): @@ -115,64 +157,78 @@ class ProjectAttributeUsageInline(admin.TabularInline): class UsageValueFilter(admin.SimpleListFilter): - title = _('value') + title = _("value") - parameter_name = 'value' + parameter_name = "value" def lookups(self, request, model_admin): return ( - ('>=0', _('Greater than or equal to 0')), - ('>10', _('Greater than 10')), - ('>100', _('Greater than 100')), - ('>1000', _('Greater than 1000')), - ('>10000', _('Greater than 10000')), + (">=0", _("Greater than or equal to 0")), + (">10", _("Greater than 10")), + (">100", _("Greater than 100")), + (">1000", _("Greater than 1000")), + (">10000", _("Greater than 10000")), ) def queryset(self, request, queryset): - - if self.value() == '>=0': + if self.value() == ">=0": return queryset.filter(allocationattributeusage__value__gte=0) - if self.value() == '>10': + if self.value() == ">10": return queryset.filter(allocationattributeusage__value__gte=10) - if self.value() == '>100': + if self.value() == ">100": return queryset.filter(allocationattributeusage__value__gte=100) - if self.value() == '>1000': + if self.value() == ">1000": return queryset.filter(allocationattributeusage__value__gte=1000) + @admin.register(ProjectAttribute) class ProjectAttributeAdmin(SimpleHistoryAdmin): - readonly_fields_change = ( - 'proj_attr_type', 'created', 'modified', 'project_title') - fields_change = ('project_title', - 'proj_attr_type', 'value', 'created', 'modified',) - list_display = ('pk', 'project', 'pi', 'project_status', - 'proj_attr_type', 'value', 'usage', 'created', 'modified',) - inlines = [ProjectAttributeUsageInline, ] - list_filter = (UsageValueFilter, 'proj_attr_type', - 'project__status') + readonly_fields_change = ("proj_attr_type", "created", "modified", "project_title") + fields_change = ( + "project_title", + "proj_attr_type", + "value", + "created", + "modified", + ) + list_display = ( + "pk", + "project", + "pi", + "project_status", + "proj_attr_type", + "value", + "usage", + "created", + "modified", + ) + inlines = [ + ProjectAttributeUsageInline, + ] + list_filter = (UsageValueFilter, "proj_attr_type", "project__status") search_fields = ( - 'project__pi__first_name', - 'project__pi__last_name', - 'project__pi__username', - 'project__projectuser__user__first_name', - 'project__projectuser__user__last_name', - 'project__projectuser__user__username', + "project__pi__first_name", + "project__pi__last_name", + "project__pi__username", + "project__projectuser__user__first_name", + "project__projectuser__user__last_name", + "project__projectuser__user__username", ) def usage(self, obj): - if hasattr(obj, 'projectattributeusage'): + if hasattr(obj, "projectattributeusage"): return obj.projectattributeusage.value else: - return 'N/A' + return "N/A" def project_status(self, obj): return obj.project.status def pi(self, obj): - return '{} {} ({})'.format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) + return "{} {} ({})".format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) def project(self, obj): return textwrap.shorten(obj.project.title, width=50) @@ -202,40 +258,49 @@ def get_inline_instances(self, request, obj=None): class ValueFilter(admin.SimpleListFilter): - title = _('value') + title = _("value") - parameter_name = 'value' + parameter_name = "value" def lookups(self, request, model_admin): return ( - ('>0', _('Greater than > 0')), - ('>10', _('Greater than > 10')), - ('>100', _('Greater than > 100')), - ('>1000', _('Greater than > 1000')), + (">0", _("Greater than > 0")), + (">10", _("Greater than > 10")), + (">100", _("Greater than > 100")), + (">1000", _("Greater than > 1000")), ) def queryset(self, request, queryset): - - if self.value() == '>0': + if self.value() == ">0": return queryset.filter(value__gt=0) - if self.value() == '>10': + if self.value() == ">10": return queryset.filter(value__gt=10) - if self.value() == '>100': + if self.value() == ">100": return queryset.filter(value__gt=100) - if self.value() == '>1000': + if self.value() == ">1000": return queryset.filter(value__gt=1000) + @admin.register(ProjectAttributeUsage) class ProjectAttributeUsageAdmin(SimpleHistoryAdmin): - list_display = ('project_attribute', 'project', - 'project_pi', 'value',) - readonly_fields = ('project_attribute',) - fields = ('project_attribute', 'value',) - list_filter = ('project_attribute__proj_attr_type', - ValueFilter, ) + list_display = ( + "project_attribute", + "project", + "project_pi", + "value", + ) + readonly_fields = ("project_attribute",) + fields = ( + "project_attribute", + "value", + ) + list_filter = ( + "project_attribute__proj_attr_type", + ValueFilter, + ) def project(self, obj): return obj.project_attribute.project.title @@ -243,19 +308,39 @@ def project(self, obj): def project_pi(self, obj): return obj.project_attribute.project.pi.username + @admin.register(Project) class ProjectAdmin(SimpleHistoryAdmin): - fields_change = ('title', 'pi', 'description', 'status', 'requires_review', 'force_review', 'created', 'modified', ) - readonly_fields_change = ('created', 'modified', ) - list_display = ('pk', 'title', 'PI', 'created', 'modified', 'status') - search_fields = ['pi__username', 'projectuser__user__username', - 'projectuser__user__last_name', 'projectuser__user__last_name', 'title'] - list_filter = ('status', 'force_review') + fields_change = ( + "title", + "pi", + "description", + "status", + "requires_review", + "force_review", + "created", + "modified", + ) + readonly_fields_change = ( + "created", + "modified", + ) + list_display = ("pk", "title", "PI", "created", "modified", "status") + search_fields = [ + "pi__username", + "projectuser__user__username", + "projectuser__user__last_name", + "projectuser__user__last_name", + "title", + ] + list_filter = ("status", "force_review") inlines = [ProjectUserInline, ProjectAdminCommentInline, ProjectUserMessageInline, ProjectAttributeInLine] - raw_id_fields = ['pi', ] + raw_id_fields = [ + "pi", + ] def PI(self, obj): - return '{} {} ({})'.format(obj.pi.first_name, obj.pi.last_name, obj.pi.username) + return "{} {} ({})".format(obj.pi.first_name, obj.pi.last_name, obj.pi.username) def get_fields(self, request, obj): if obj is None: @@ -280,7 +365,7 @@ def get_inline_instances(self, request, obj=None): def get_list_display(self, request): if PROJECT_CODE: list_display = list(self.list_display) - list_display.insert(1, 'project_code') + list_display.insert(1, "project_code") return tuple(list_display) return self.list_display @@ -297,10 +382,13 @@ def save_formset(self, request, form, formset, change): @admin.register(ProjectReview) class ProjectReviewAdmin(SimpleHistoryAdmin): - list_display = ('pk', 'project', 'PI', 'reason_for_not_updating_project', 'created', 'status') - search_fields = ['project__pi__username', 'project__pi__first_name', 'project__pi__last_name',] - list_filter = ('status', ) + list_display = ("pk", "project", "PI", "reason_for_not_updating_project", "created", "status") + search_fields = [ + "project__pi__username", + "project__pi__first_name", + "project__pi__last_name", + ] + list_filter = ("status",) def PI(self, obj): - return '{} {} ({})'.format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) - + return "{} {} ({})".format(obj.project.pi.first_name, obj.project.pi.last_name, obj.project.pi.username) diff --git a/coldfront/core/project/apps.py b/coldfront/core/project/apps.py index 84bbd81163..17a291a8ad 100644 --- a/coldfront/core/project/apps.py +++ b/coldfront/core/project/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class ProjectConfig(AppConfig): - name = 'coldfront.core.project' + name = "coldfront.core.project" diff --git a/coldfront/core/project/forms.py b/coldfront/core/project/forms.py index 04df9eaea5..a861b6918d 100644 --- a/coldfront/core/project/forms.py +++ b/coldfront/core/project/forms.py @@ -1,35 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime from django import forms from django.db.models.functions import Lower from django.shortcuts import get_object_or_404 -from ast import Constant -from django.db.models.functions import Lower -from cProfile import label -from coldfront.core.project.models import (Project, ProjectAttribute, ProjectAttributeType, ProjectReview, - ProjectUserRoleChoice) +from coldfront.core.project.models import Project, ProjectAttribute, ProjectReview, ProjectUserRoleChoice from coldfront.core.utils.common import import_from_settings -EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL = import_from_settings( - 'EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL') -EMAIL_ADMIN_LIST = import_from_settings('EMAIL_ADMIN_LIST', []) -EMAIL_DIRECTOR_EMAIL_ADDRESS = import_from_settings( - 'EMAIL_DIRECTOR_EMAIL_ADDRESS', '') +EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL = import_from_settings("EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL") +EMAIL_ADMIN_LIST = import_from_settings("EMAIL_ADMIN_LIST", []) +EMAIL_DIRECTOR_EMAIL_ADDRESS = import_from_settings("EMAIL_DIRECTOR_EMAIL_ADDRESS", "") class ProjectSearchForm(forms.Form): - """ Search form for the Project list page. - """ - LAST_NAME = 'Last Name' - USERNAME = 'Username' - FIELD_OF_SCIENCE = 'Field of Science' - - last_name = forms.CharField( - label=LAST_NAME, max_length=100, required=False) + """Search form for the Project list page.""" + + LAST_NAME = "Last Name" + USERNAME = "Username" + FIELD_OF_SCIENCE = "Field of Science" + + last_name = forms.CharField(label=LAST_NAME, max_length=100, required=False) username = forms.CharField(label=USERNAME, max_length=100, required=False) - field_of_science = forms.CharField( - label=FIELD_OF_SCIENCE, max_length=100, required=False) + field_of_science = forms.CharField(label=FIELD_OF_SCIENCE, max_length=100, required=False) show_all_projects = forms.BooleanField(initial=False, required=False) @@ -39,32 +35,45 @@ class ProjectAddUserForm(forms.Form): last_name = forms.CharField(max_length=150, required=False, disabled=True) email = forms.EmailField(max_length=100, required=False, disabled=True) source = forms.CharField(max_length=16, disabled=True) - role = forms.ModelChoiceField( - queryset=ProjectUserRoleChoice.objects.all(), empty_label=None) + role = forms.ModelChoiceField(queryset=ProjectUserRoleChoice.objects.all(), empty_label=None) selected = forms.BooleanField(initial=False, required=False) class ProjectAddUsersToAllocationForm(forms.Form): - allocation = forms.MultipleChoiceField( - widget=forms.CheckboxSelectMultiple(attrs={'checked': 'checked'}), required=False) + widget=forms.CheckboxSelectMultiple(attrs={"checked": "checked"}), required=False + ) def __init__(self, request_user, project_pk, *args, **kwargs): super().__init__(*args, **kwargs) project_obj = get_object_or_404(Project, pk=project_pk) allocation_query_set = project_obj.allocation_set.filter( - resources__is_allocatable=True, is_locked=False, status__name__in=['Active', 'New', 'Renewal Requested', 'Payment Pending', 'Payment Requested', 'Paid']) - allocation_choices = [(allocation.id, "%s (%s) %s" % (allocation.get_parent_resource.name, allocation.get_parent_resource.resource_type.name, - allocation.description if allocation.description else '')) for allocation in allocation_query_set] + resources__is_allocatable=True, + is_locked=False, + status__name__in=["Active", "New", "Renewal Requested", "Payment Pending", "Payment Requested", "Paid"], + ) + allocation_choices = [ + ( + allocation.id, + "%s (%s) %s" + % ( + allocation.get_parent_resource.name, + allocation.get_parent_resource.resource_type.name, + allocation.description if allocation.description else "", + ), + ) + for allocation in allocation_query_set + ] allocation_choices_sorted = [] allocation_choices_sorted = sorted(allocation_choices, key=lambda x: x[1][0].lower()) - allocation_choices.insert(0, ('__select_all__', 'Select All')) + allocation_choices.insert(0, ("__select_all__", "Select All")) if allocation_query_set: - self.fields['allocation'].choices = allocation_choices_sorted - self.fields['allocation'].help_text = '
Select allocations to add selected users to.' + self.fields["allocation"].choices = allocation_choices_sorted + self.fields["allocation"].help_text = "
Select allocations to add selected users to." else: - self.fields['allocation'].widget = forms.HiddenInput() + self.fields["allocation"].widget = forms.HiddenInput() + class ProjectRemoveUserForm(forms.Form): username = forms.CharField(max_length=150, disabled=True) @@ -76,16 +85,25 @@ class ProjectRemoveUserForm(forms.Form): class ProjectUserUpdateForm(forms.Form): - role = forms.ModelChoiceField( - queryset=ProjectUserRoleChoice.objects.all(), empty_label=None) + role = forms.ModelChoiceField(queryset=ProjectUserRoleChoice.objects.all(), empty_label=None) enable_notifications = forms.BooleanField(initial=False, required=False) class ProjectReviewForm(forms.Form): - reason = forms.CharField(label='Reason for not updating project information', widget=forms.Textarea(attrs={ - 'placeholder': 'If you have no new information to provide, you are required to provide a statement explaining this in this box. Thank you!'}), required=False) + reason = forms.CharField( + label="Reason for not updating project information", + widget=forms.Textarea( + attrs={ + "placeholder": "If you have no new information to provide, you are required to provide a statement explaining this in this box. Thank you!" + } + ), + required=False, + ) acknowledgement = forms.BooleanField( - label='By checking this box I acknowledge that I have updated my project to the best of my knowledge', initial=False, required=True) + label="By checking this box I acknowledge that I have updated my project to the best of my knowledge", + initial=False, + required=True, + ) def __init__(self, project_pk, *args, **kwargs): super().__init__(*args, **kwargs) @@ -93,57 +111,53 @@ def __init__(self, project_pk, *args, **kwargs): now = datetime.datetime.now(datetime.timezone.utc) if project_obj.grant_set.exists(): - latest_grant = project_obj.grant_set.order_by('-modified')[0] - grant_updated_in_last_year = ( - now - latest_grant.created).days < 365 + latest_grant = project_obj.grant_set.order_by("-modified")[0] + grant_updated_in_last_year = (now - latest_grant.created).days < 365 else: grant_updated_in_last_year = None if project_obj.publication_set.exists(): - latest_publication = project_obj.publication_set.order_by( - '-created')[0] - publication_updated_in_last_year = ( - now - latest_publication.created).days < 365 + latest_publication = project_obj.publication_set.order_by("-created")[0] + publication_updated_in_last_year = (now - latest_publication.created).days < 365 else: publication_updated_in_last_year = None if grant_updated_in_last_year or publication_updated_in_last_year: - self.fields['reason'].widget = forms.HiddenInput() + self.fields["reason"].widget = forms.HiddenInput() else: - self.fields['reason'].required = True + self.fields["reason"].required = True class ProjectReviewEmailForm(forms.Form): - cc = forms.CharField( - required=False - ) - email_body = forms.CharField( - required=True, - widget=forms.Textarea - ) + cc = forms.CharField(required=False) + email_body = forms.CharField(required=True, widget=forms.Textarea) def __init__(self, pk, *args, **kwargs): super().__init__(*args, **kwargs) project_review_obj = get_object_or_404(ProjectReview, pk=int(pk)) - self.fields['email_body'].initial = 'Dear {} {} \n{}'.format( - project_review_obj.project.pi.first_name, project_review_obj.project.pi.last_name, EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL) - self.fields['cc'].initial = ', '.join( - [EMAIL_DIRECTOR_EMAIL_ADDRESS] + EMAIL_ADMIN_LIST) + self.fields["email_body"].initial = "Dear {} {} \n{}".format( + project_review_obj.project.pi.first_name, + project_review_obj.project.pi.last_name, + EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL, + ) + self.fields["cc"].initial = ", ".join([EMAIL_DIRECTOR_EMAIL_ADDRESS] + EMAIL_ADMIN_LIST) -class ProjectAttributeAddForm(forms.ModelForm): + +class ProjectAttributeAddForm(forms.ModelForm): class Meta: - fields = '__all__' + fields = "__all__" model = ProjectAttribute labels = { - 'proj_attr_type' : "Project Attribute Type", + "proj_attr_type": "Project Attribute Type", } def __init__(self, *args, **kwargs): - super(ProjectAttributeAddForm, self).__init__(*args, **kwargs) - user =(kwargs.get('initial')).get('user') - self.fields['proj_attr_type'].queryset = self.fields['proj_attr_type'].queryset.order_by(Lower('name')) + super(ProjectAttributeAddForm, self).__init__(*args, **kwargs) + user = (kwargs.get("initial")).get("user") + self.fields["proj_attr_type"].queryset = self.fields["proj_attr_type"].queryset.order_by(Lower("name")) if not user.is_superuser: - self.fields['proj_attr_type'].queryset = self.fields['proj_attr_type'].queryset.filter(is_private=False) + self.fields["proj_attr_type"].queryset = self.fields["proj_attr_type"].queryset.filter(is_private=False) + class ProjectAttributeDeleteForm(forms.Form): pk = forms.IntegerField(required=False, disabled=True) @@ -154,7 +168,8 @@ class ProjectAttributeDeleteForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() + # class ProjectAttributeChangeForm(forms.Form): # pk = forms.IntegerField(required=False, disabled=True) @@ -181,17 +196,18 @@ class ProjectAttributeUpdateForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() def clean(self): cleaned_data = super().clean() - if cleaned_data.get('new_value') != "": - proj_attr = ProjectAttribute.objects.get(pk=cleaned_data.get('pk')) - proj_attr.value = cleaned_data.get('new_value') + if cleaned_data.get("new_value") != "": + proj_attr = ProjectAttribute.objects.get(pk=cleaned_data.get("pk")) + proj_attr.value = cleaned_data.get("new_value") proj_attr.clean() + class ProjectCreationForm(forms.ModelForm): class Meta: model = Project - fields = ['title', 'description', 'field_of_science'] \ No newline at end of file + fields = ["title", "description", "field_of_science"] diff --git a/coldfront/core/project/management/__init__.py b/coldfront/core/project/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/project/management/__init__.py +++ b/coldfront/core/project/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/project/management/commands/__init__.py b/coldfront/core/project/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/project/management/commands/__init__.py +++ b/coldfront/core/project/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/project/management/commands/add_default_project_choices.py b/coldfront/core/project/management/commands/add_default_project_choices.py index 5b3ccb2c82..8b06af65d8 100644 --- a/coldfront/core/project/management/commands/add_default_project_choices.py +++ b/coldfront/core/project/management/commands/add_default_project_choices.py @@ -1,37 +1,61 @@ -import os -from inspect import Attribute +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand -from coldfront.core.project.models import (ProjectAttributeType, - ProjectReviewStatusChoice, - ProjectStatusChoice, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - AttributeType) +from coldfront.core.project.models import ( + AttributeType, + ProjectAttributeType, + ProjectReviewStatusChoice, + ProjectStatusChoice, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) class Command(BaseCommand): - help = 'Add default project related choices' + help = "Add default project related choices" def handle(self, *args, **options): - for choice in ['New', 'Active', 'Archived', ]: + for choice in [ + "New", + "Active", + "Archived", + ]: ProjectStatusChoice.objects.get_or_create(name=choice) - for choice in ['Completed', 'Pending', ]: + for choice in [ + "Completed", + "Pending", + ]: ProjectReviewStatusChoice.objects.get_or_create(name=choice) - for choice in ['User', 'Manager', ]: + for choice in [ + "User", + "Manager", + ]: ProjectUserRoleChoice.objects.get_or_create(name=choice) - for choice in ['Active', 'Pending - Add', 'Pending - Remove', 'Denied', 'Removed', ]: + for choice in [ + "Active", + "Pending - Add", + "Pending - Remove", + "Denied", + "Removed", + ]: ProjectUserStatusChoice.objects.get_or_create(name=choice) - for attribute_type in ('Date', 'Float', 'Int', 'Text', 'Yes/No'): + for attribute_type in ("Date", "Float", "Int", "Text", "Yes/No"): AttributeType.objects.get_or_create(name=attribute_type) for name, attribute_type, has_usage, is_private in ( - ('Project ID', 'Text', False, False), - ('Account Number', 'Int', False, True), + ("Project ID", "Text", False, False), + ("Account Number", "Int", False, True), ): - ProjectAttributeType.objects.get_or_create(name=name, attribute_type=AttributeType.objects.get( - name=attribute_type), has_usage=has_usage, is_private=is_private) + ProjectAttributeType.objects.get_or_create( + name=name, + attribute_type=AttributeType.objects.get(name=attribute_type), + has_usage=has_usage, + is_private=is_private, + ) diff --git a/coldfront/core/project/management/commands/add_project_codes.py b/coldfront/core/project/management/commands/add_project_codes.py index 812b0556cc..7ae8617505 100644 --- a/coldfront/core/project/management/commands/add_project_codes.py +++ b/coldfront/core/project/management/commands/add_project_codes.py @@ -1,48 +1,62 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand + from coldfront.core.project.models import Project from coldfront.core.project.utils import generate_project_code from coldfront.core.utils.common import import_from_settings -PROJECT_CODE = import_from_settings('PROJECT_CODE', False) -PROJECT_CODE_PADDING = import_from_settings('PROJECT_CODE_PADDING', False) +PROJECT_CODE = import_from_settings("PROJECT_CODE", False) +PROJECT_CODE_PADDING = import_from_settings("PROJECT_CODE_PADDING", False) + class Command(BaseCommand): - help = 'Update existing projects with project codes.' + help = "Update existing projects with project codes." def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - help='Outputting project primary keys and titled, followed by their updated project code', + "--dry-run", + action="store_true", + help="Outputting project primary keys and titled, followed by their updated project code", ) def update_project_code(self, projects): - user_input = input('Assign all existing projects with project codes? You can use the --dry-run flag to preview changes first. [y/N] ') + user_input = input( + "Assign all existing projects with project codes? You can use the --dry-run flag to preview changes first. [y/N] " + ) try: - if user_input == 'y' or user_input == 'Y': + if user_input == "y" or user_input == "Y": for project in projects: project.project_code = generate_project_code(PROJECT_CODE, project.pk, PROJECT_CODE_PADDING) project.save(update_fields=["project_code"]) self.stdout.write(f"Updated {projects.count()} projects with project codes") else: - self.stdout.write('No changes made') + self.stdout.write("No changes made") except AttributeError: - self.stdout.write('Error, no changes made. Please set PROJECT_CODE as a string value inside configuration file.') + self.stdout.write( + "Error, no changes made. Please set PROJECT_CODE as a string value inside configuration file." + ) def project_code_dry_run(self, projects): try: for project in projects: new_code = generate_project_code(PROJECT_CODE, project.pk, PROJECT_CODE_PADDING) - self.stdout.write(f"Project {project.pk}, called {project.title}: new project_code would be '{new_code}'") + self.stdout.write( + f"Project {project.pk}, called {project.title}: new project_code would be '{new_code}'" + ) except AttributeError: - self.stdout.write('Error, no changes made. Please set PROJECT_CODE as a string value inside configuration file.') + self.stdout.write( + "Error, no changes made. Please set PROJECT_CODE as a string value inside configuration file." + ) def handle(self, *args, **options): - dry_run = options['dry_run'] + dry_run = options["dry_run"] projects_without_codes = Project.objects.filter(project_code="") if dry_run: self.project_code_dry_run(projects_without_codes) else: - self.update_project_code(projects_without_codes) \ No newline at end of file + self.update_project_code(projects_without_codes) diff --git a/coldfront/core/project/migrations/0001_initial.py b/coldfront/core/project/migrations/0001_initial.py index 94792bacac..350ef7fd7a 100644 --- a/coldfront/core/project/migrations/0001_initial.py +++ b/coldfront/core/project/migrations/0001_initial.py @@ -1,224 +1,518 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('field_of_science', '0001_initial'), + ("field_of_science", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Project', + name="Project", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(default='\nWe do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!\n ', validators=[django.core.validators.MinLengthValidator(10, 'The project description must be > 10 characters.')])), - ('force_review', models.BooleanField(default=False)), - ('requires_review', models.BooleanField(default=True)), - ('field_of_science', models.ForeignKey(default=149, on_delete=django.db.models.deletion.CASCADE, to='field_of_science.FieldOfScience')), - ('pi', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=255)), + ( + "description", + models.TextField( + default="\nWe do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!\n ", + validators=[ + django.core.validators.MinLengthValidator( + 10, "The project description must be > 10 characters." + ) + ], + ), + ), + ("force_review", models.BooleanField(default=False)), + ("requires_review", models.BooleanField(default=True)), + ( + "field_of_science", + models.ForeignKey( + default=149, on_delete=django.db.models.deletion.CASCADE, to="field_of_science.FieldOfScience" + ), + ), + ("pi", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'ordering': ['title'], - 'permissions': (('can_view_all_projects', 'Can view all projects'), ('can_review_pending_project_reviews', 'Can review pending project reviews')), + "ordering": ["title"], + "permissions": ( + ("can_view_all_projects", "Can view all projects"), + ("can_review_pending_project_reviews", "Can review pending project reviews"), + ), }, ), migrations.CreateModel( - name='ProjectReviewStatusChoice', + name="ProjectReviewStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ProjectStatusChoice', + name="ProjectStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ('name',), + "ordering": ("name",), }, ), migrations.CreateModel( - name='ProjectUserRoleChoice', + name="ProjectUserRoleChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ProjectUserStatusChoice', + name="ProjectUserStatusChoice", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ProjectUserMessage', + name="ProjectUserMessage", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('message', models.TextField()), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("message", models.TextField()), + ("author", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='ProjectReview', + name="ProjectReview", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('reason_for_not_updating_project', models.TextField(blank=True, null=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), - ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.ProjectReviewStatusChoice', verbose_name='Status')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("reason_for_not_updating_project", models.TextField(blank=True, null=True)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), + ( + "status", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="project.ProjectReviewStatusChoice", + verbose_name="Status", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='ProjectAdminComment', + name="ProjectAdminComment", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('comment', models.TextField()), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("comment", models.TextField()), + ("author", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.AddField( - model_name='project', - name='status', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.ProjectStatusChoice'), + model_name="project", + name="status", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.ProjectStatusChoice"), ), migrations.CreateModel( - name='HistoricalProjectUser', + name="HistoricalProjectUser", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('enable_notifications', models.BooleanField(default=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('role', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.ProjectUserRoleChoice')), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.ProjectUserStatusChoice', verbose_name='Status')), - ('user', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("enable_notifications", models.BooleanField(default=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "role", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.ProjectUserRoleChoice", + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.ProjectUserStatusChoice", + verbose_name="Status", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical project user', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project user", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalProjectReview', + name="HistoricalProjectReview", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('reason_for_not_updating_project', models.TextField(blank=True, null=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.ProjectReviewStatusChoice', verbose_name='Status')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("reason_for_not_updating_project", models.TextField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.ProjectReviewStatusChoice", + verbose_name="Status", + ), + ), ], options={ - 'verbose_name': 'historical project review', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project review", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalProject', + name="HistoricalProject", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=255)), - ('description', models.TextField(default='\nWe do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!\n ', validators=[django.core.validators.MinLengthValidator(10, 'The project description must be > 10 characters.')])), - ('force_review', models.BooleanField(default=False)), - ('requires_review', models.BooleanField(default=True)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('field_of_science', models.ForeignKey(blank=True, db_constraint=False, default=149, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='field_of_science.FieldOfScience')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('pi', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), - ('status', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.ProjectStatusChoice')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=255)), + ( + "description", + models.TextField( + default="\nWe do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!\n ", + validators=[ + django.core.validators.MinLengthValidator( + 10, "The project description must be > 10 characters." + ) + ], + ), + ), + ("force_review", models.BooleanField(default=False)), + ("requires_review", models.BooleanField(default=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "field_of_science", + models.ForeignKey( + blank=True, + db_constraint=False, + default=149, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="field_of_science.FieldOfScience", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "pi", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "status", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.ProjectStatusChoice", + ), + ), ], options={ - 'verbose_name': 'historical project', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='ProjectUser', + name="ProjectUser", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('enable_notifications', models.BooleanField(default=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), - ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.ProjectUserRoleChoice')), - ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.ProjectUserStatusChoice', verbose_name='Status')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("enable_notifications", models.BooleanField(default=True)), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), + ( + "role", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.ProjectUserRoleChoice"), + ), + ( + "status", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="project.ProjectUserStatusChoice", + verbose_name="Status", + ), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], options={ - 'verbose_name_plural': 'Project User Status', - 'unique_together': {('user', 'project')}, + "verbose_name_plural": "Project User Status", + "unique_together": {("user", "project")}, }, ), ] diff --git a/coldfront/core/project/migrations/0002_projectusermessage_is_private.py b/coldfront/core/project/migrations/0002_projectusermessage_is_private.py index 5c6833e40c..7a7214ce7d 100644 --- a/coldfront/core/project/migrations/0002_projectusermessage_is_private.py +++ b/coldfront/core/project/migrations/0002_projectusermessage_is_private.py @@ -1,18 +1,21 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.13 on 2022-06-06 15:35 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('project', '0001_initial'), + ("project", "0001_initial"), ] operations = [ migrations.AddField( - model_name='projectusermessage', - name='is_private', + model_name="projectusermessage", + name="is_private", field=models.BooleanField(default=True), ), ] diff --git a/coldfront/core/project/migrations/0003_auto_20221013_1215.py b/coldfront/core/project/migrations/0003_auto_20221013_1215.py index 2f9ad74e39..293b3fae8b 100644 --- a/coldfront/core/project/migrations/0003_auto_20221013_1215.py +++ b/coldfront/core/project/migrations/0003_auto_20221013_1215.py @@ -1,150 +1,307 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.15 on 2022-10-13 16:15 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0002_projectusermessage_is_private'), + ("project", "0002_projectusermessage_is_private"), ] operations = [ migrations.CreateModel( - name='AttributeType', + name="AttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=64)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=64)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ProjectAttribute', + name="ProjectAttribute", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=128)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=128)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='ProjectAttributeUsage', + name="ProjectAttributeUsage", fields=[ - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('project_attribute', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='project.projectattribute')), - ('value', models.FloatField(default=0)), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ( + "project_attribute", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to="project.projectattribute", + ), + ), + ("value", models.FloatField(default=0)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='ProjectAttributeType', + name="ProjectAttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=50)), - ('has_usage', models.BooleanField(default=False)), - ('is_required', models.BooleanField(default=False)), - ('is_unique', models.BooleanField(default=False)), - ('is_private', models.BooleanField(default=True)), - ('is_changeable', models.BooleanField(default=False)), - ('attribute_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.attributetype')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=50)), + ("has_usage", models.BooleanField(default=False)), + ("is_required", models.BooleanField(default=False)), + ("is_unique", models.BooleanField(default=False)), + ("is_private", models.BooleanField(default=True)), + ("is_changeable", models.BooleanField(default=False)), + ( + "attribute_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.attributetype"), + ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.AddField( - model_name='projectattribute', - name='proj_attr_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.projectattributetype'), + model_name="projectattribute", + name="proj_attr_type", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.projectattributetype"), ), migrations.AddField( - model_name='projectattribute', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.project'), + model_name="projectattribute", + name="project", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.project"), ), migrations.CreateModel( - name='HistoricalProjectAttributeUsage', + name="HistoricalProjectAttributeUsage", fields=[ - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.FloatField(default=0)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project_attribute', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.projectattribute')), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.FloatField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project_attribute", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.projectattribute", + ), + ), ], options={ - 'verbose_name': 'historical project attribute usage', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project attribute usage", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalProjectAttributeType', + name="HistoricalProjectAttributeType", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=50)), - ('has_usage', models.BooleanField(default=False)), - ('is_required', models.BooleanField(default=False)), - ('is_unique', models.BooleanField(default=False)), - ('is_private', models.BooleanField(default=True)), - ('is_changeable', models.BooleanField(default=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.attributetype')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=50)), + ("has_usage", models.BooleanField(default=False)), + ("is_required", models.BooleanField(default=False)), + ("is_unique", models.BooleanField(default=False)), + ("is_private", models.BooleanField(default=True)), + ("is_changeable", models.BooleanField(default=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.attributetype", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical project attribute type', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project attribute type", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalProjectAttribute', + name="HistoricalProjectAttribute", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=128)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('proj_attr_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.projectattributetype')), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.project')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=128)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "proj_attr_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.projectattributetype", + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.project", + ), + ), ], options={ - 'verbose_name': 'historical project attribute', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical project attribute", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), diff --git a/coldfront/core/project/migrations/0004_auto_20230406_1133.py b/coldfront/core/project/migrations/0004_auto_20230406_1133.py index 07932a0db2..c9bedc5508 100644 --- a/coldfront/core/project/migrations/0004_auto_20230406_1133.py +++ b/coldfront/core/project/migrations/0004_auto_20230406_1133.py @@ -1,28 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 3.2.17 on 2023-04-06 15:33 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('project', '0003_auto_20221013_1215'), + ("project", "0003_auto_20221013_1215"), ] operations = [ migrations.AlterField( - model_name='projectstatuschoice', - name='name', + model_name="projectstatuschoice", + name="name", field=models.CharField(max_length=64, unique=True), ), migrations.AlterField( - model_name='projectuserrolechoice', - name='name', + model_name="projectuserrolechoice", + name="name", field=models.CharField(max_length=64, unique=True), ), migrations.AlterField( - model_name='projectuserstatuschoice', - name='name', + model_name="projectuserstatuschoice", + name="name", field=models.CharField(max_length=64, unique=True), ), ] diff --git a/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py b/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py index 1fb941e5b0..e34e2cf50a 100644 --- a/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py +++ b/coldfront/core/project/migrations/0005_alter_historicalproject_options_and_more.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 4.2.11 on 2025-03-12 13:44 from django.conf import settings @@ -5,79 +9,108 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('project', '0004_auto_20230406_1133'), + ("project", "0004_auto_20230406_1133"), ] operations = [ migrations.AlterModelOptions( - name='historicalproject', - options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project', 'verbose_name_plural': 'historical projects'}, + name="historicalproject", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project", + "verbose_name_plural": "historical projects", + }, ), migrations.AlterModelOptions( - name='historicalprojectattribute', - options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project attribute', 'verbose_name_plural': 'historical project attributes'}, + name="historicalprojectattribute", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project attribute", + "verbose_name_plural": "historical project attributes", + }, ), migrations.AlterModelOptions( - name='historicalprojectattributetype', - options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project attribute type', 'verbose_name_plural': 'historical project attribute types'}, + name="historicalprojectattributetype", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project attribute type", + "verbose_name_plural": "historical project attribute types", + }, ), migrations.AlterModelOptions( - name='historicalprojectattributeusage', - options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project attribute usage', 'verbose_name_plural': 'historical project attribute usages'}, + name="historicalprojectattributeusage", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project attribute usage", + "verbose_name_plural": "historical project attribute usages", + }, ), migrations.AlterModelOptions( - name='historicalprojectreview', - options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project review', 'verbose_name_plural': 'historical project reviews'}, + name="historicalprojectreview", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project review", + "verbose_name_plural": "historical project reviews", + }, ), migrations.AlterModelOptions( - name='historicalprojectuser', - options={'get_latest_by': ('history_date', 'history_id'), 'ordering': ('-history_date', '-history_id'), 'verbose_name': 'historical project user', 'verbose_name_plural': 'historical Project User Status'}, + name="historicalprojectuser", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical project user", + "verbose_name_plural": "historical Project User Status", + }, ), migrations.AddField( - model_name='historicalproject', - name='project_code', + model_name="historicalproject", + name="project_code", field=models.CharField(blank=True, max_length=10), ), migrations.AddField( - model_name='project', - name='project_code', + model_name="project", + name="project_code", field=models.CharField(blank=True, max_length=10), ), migrations.AlterField( - model_name='historicalproject', - name='history_date', + model_name="historicalproject", + name="history_date", field=models.DateTimeField(db_index=True), ), migrations.AlterField( - model_name='historicalprojectattribute', - name='history_date', + model_name="historicalprojectattribute", + name="history_date", field=models.DateTimeField(db_index=True), ), migrations.AlterField( - model_name='historicalprojectattributetype', - name='history_date', + model_name="historicalprojectattributetype", + name="history_date", field=models.DateTimeField(db_index=True), ), migrations.AlterField( - model_name='historicalprojectattributeusage', - name='history_date', + model_name="historicalprojectattributeusage", + name="history_date", field=models.DateTimeField(db_index=True), ), migrations.AlterField( - model_name='historicalprojectreview', - name='history_date', + model_name="historicalprojectreview", + name="history_date", field=models.DateTimeField(db_index=True), ), migrations.AlterField( - model_name='historicalprojectuser', - name='history_date', + model_name="historicalprojectuser", + name="history_date", field=models.DateTimeField(db_index=True), ), migrations.AlterUniqueTogether( - name='project', - unique_together={('title', 'pi')}, + name="project", + unique_together={("title", "pi")}, ), ] diff --git a/coldfront/core/project/migrations/__init__.py b/coldfront/core/project/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/project/migrations/__init__.py +++ b/coldfront/core/project/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/project/models.py b/coldfront/core/project/models.py index d06636293c..2f6153fab0 100644 --- a/coldfront/core/project/models.py +++ b/coldfront/core/project/models.py @@ -1,37 +1,42 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import textwrap from enum import Enum from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models -from ast import literal_eval -from coldfront.core.utils.validate import AttributeValidator from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords from coldfront.core.field_of_science.models import FieldOfScience from coldfront.core.utils.common import import_from_settings +from coldfront.core.utils.validate import AttributeValidator + +PROJECT_ENABLE_PROJECT_REVIEW = import_from_settings("PROJECT_ENABLE_PROJECT_REVIEW", False) -PROJECT_ENABLE_PROJECT_REVIEW = import_from_settings('PROJECT_ENABLE_PROJECT_REVIEW', False) class ProjectPermission(Enum): - """ A project permission stores the user, manager, pi, and update fields of a project. """ + """A project permission stores the user, manager, pi, and update fields of a project.""" + + USER = "user" + MANAGER = "manager" + PI = "pi" + UPDATE = "update" - USER = 'user' - MANAGER = 'manager' - PI = 'pi' - UPDATE = 'update' class ProjectStatusChoice(TimeStampedModel): - """ A project status choice indicates the status of the project. Examples include Active, Archived, and New. - + """A project status choice indicates the status of the project. Examples include Active, Archived, and New. + Attributes: name (str): name of project status choice """ + class Meta: - ordering = ('name',) + ordering = ("name",) class ProjectStatusChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -46,9 +51,10 @@ def __str__(self): def natural_key(self): return (self.name,) + class Project(TimeStampedModel): - """ A project is a container that includes users, allocations, publications, grants, and other research output. - + """A project is a container that includes users, allocations, publications, grants, and other research output. + Attributes: title (str): name of the project pi (User): represents the User object of the project's PI @@ -58,9 +64,10 @@ class Project(TimeStampedModel): force_review (bool): indicates whether or not to force a review for the project requires_review (bool): indicates whether or not the project requires review """ + class Meta: - ordering = ['title'] - unique_together = ('title', 'pi') + ordering = ["title"] + unique_together = ("title", "pi") permissions = ( ("can_view_all_projects", "Can view all projects"), @@ -71,19 +78,23 @@ class ProjectManager(models.Manager): def get_by_natural_key(self, title, pi_username): return self.get(title=title, pi__username=pi_username) - - DEFAULT_DESCRIPTION = ''' + DEFAULT_DESCRIPTION = """ We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you! - ''' + """ - title = models.CharField(max_length=255,) - pi = models.ForeignKey(User, on_delete=models.CASCADE,) + title = models.CharField( + max_length=255, + ) + pi = models.ForeignKey( + User, + on_delete=models.CASCADE, + ) description = models.TextField( default=DEFAULT_DESCRIPTION, validators=[ MinLengthValidator( 10, - 'The project description must be > 10 characters.', + "The project description must be > 10 characters.", ) ], ) @@ -97,13 +108,18 @@ def get_by_natural_key(self, title, pi_username): project_code = models.CharField(max_length=10, blank=True) def clean(self): - """ Validates the project and raises errors if the project is invalid. """ + """Validates the project and raises errors if the project is invalid.""" - if 'Auto-Import Project'.lower() in self.title.lower(): - raise ValidationError('You must update the project title. You cannot have "Auto-Import Project" in the title.') + if "Auto-Import Project".lower() in self.title.lower(): + raise ValidationError( + 'You must update the project title. You cannot have "Auto-Import Project" in the title.' + ) - if 'We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!' in self.description: - raise ValidationError('You must update the project description.') + if ( + "We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!" + in self.description + ): + raise ValidationError("You must update the project description.") @property def last_project_review(self): @@ -113,7 +129,7 @@ def last_project_review(self): """ if self.projectreview_set.exists(): - return self.projectreview_set.order_by('-created')[0] + return self.projectreview_set.order_by("-created")[0] else: return None @@ -125,7 +141,7 @@ def latest_grant(self): """ if self.grant_set.exists(): - return self.grant_set.order_by('-modified')[0] + return self.grant_set.order_by("-modified")[0] else: return None @@ -137,7 +153,7 @@ def latest_publication(self): """ if self.publication_set.exists(): - return self.publication_set.order_by('-created')[0] + return self.publication_set.order_by("-created")[0] else: return None @@ -148,7 +164,7 @@ def needs_review(self): bool: whether or not the project needs review """ - if self.status.name == 'Archived': + if self.status.name == "Archived": return False now = datetime.datetime.now(datetime.timezone.utc) @@ -163,7 +179,7 @@ def needs_review(self): return False if self.projectreview_set.exists(): - last_review = self.projectreview_set.order_by('-created')[0] + last_review = self.projectreview_set.order_by("-created")[0] last_review_over_365_days = (now - last_review.created).days > 365 else: last_review = None @@ -190,13 +206,13 @@ def user_permissions(self, user): if user.is_superuser: return list(ProjectPermission) - user_conditions = (models.Q(status__name__in=('Active', 'New')) & models.Q(user=user)) + user_conditions = models.Q(status__name__in=("Active", "New")) & models.Q(user=user) if not self.projectuser_set.filter(user_conditions).exists(): return [] permissions = [ProjectPermission.USER] - if self.projectuser_set.filter(user_conditions & models.Q(role__name='Manager')).exists(): + if self.projectuser_set.filter(user_conditions & models.Q(role__name="Manager")).exists(): permissions.append(ProjectPermission.MANAGER) if self.projectuser_set.filter(user_conditions & models.Q(project__pi_id=user.id)).exists(): @@ -226,9 +242,10 @@ def __str__(self): def natural_key(self): return (self.title,) + self.pi.natural_key() + class ProjectAdminComment(TimeStampedModel): - """ A project admin comment is a comment that an admin can make on a project. - + """A project admin comment is a comment that an admin can make on a project. + Attributes: project (Project): links the project the comment is from to the comment author (User): represents the admin who authored the comment @@ -242,9 +259,10 @@ class ProjectAdminComment(TimeStampedModel): def __str__(self): return self.comment + class ProjectUserMessage(TimeStampedModel): - """ A project user message is a message sent to a user in a project. - + """A project user message is a message sent to a user in a project. + Attributes: project (Project): links the project the message is from to the message author (User): represents the user who authored the message @@ -260,9 +278,10 @@ class ProjectUserMessage(TimeStampedModel): def __str__(self): return self.message + class ProjectReviewStatusChoice(TimeStampedModel): - """ A project review status choice is an option a user can choose when setting a project's status. Examples include Completed and Pending. - + """A project review status choice is an option a user can choose when setting a project's status. Examples include Completed and Pending. + Attributes: name (str): name of the status choice """ @@ -273,11 +292,14 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class ProjectReview(TimeStampedModel): - """ A project review is what a user submits to their PI when their project status is Pending. - + """A project review is what a user submits to their PI when their project status is Pending. + Attributes: project (Project): links the project to its review status (ProjectReviewStatusChoice): links the project review to its status @@ -285,19 +307,22 @@ class ProjectReview(TimeStampedModel): """ project = models.ForeignKey(Project, on_delete=models.CASCADE) - status = models.ForeignKey(ProjectReviewStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + status = models.ForeignKey(ProjectReviewStatusChoice, on_delete=models.CASCADE, verbose_name="Status") reason_for_not_updating_project = models.TextField(blank=True, null=True) history = HistoricalRecords() + class ProjectUserRoleChoice(TimeStampedModel): - """ A project user role choice is an option a PI, manager, or admin has while selecting a user's role. Examples include Manager and User. - + """A project user role choice is an option a PI, manager, or admin has while selecting a user's role. Examples include Manager and User. + Attributes: - name (str): name of the user role choice + name (str): name of the user role choice """ class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class ProjectUserRoleChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -312,14 +337,18 @@ def __str__(self): def natural_key(self): return (self.name,) + class ProjectUserStatusChoice(TimeStampedModel): - """ A project user status choice indicates the status of a project user. Examples include Active, Pending, and Denied. - + """A project user status choice indicates the status of a project user. Examples include Active, Pending, and Denied. + Attributes: name (str): name of the project user status choice """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class ProjectUserStatusChoiceManager(models.Manager): def get_by_natural_key(self, name): @@ -334,9 +363,10 @@ def __str__(self): def natural_key(self): return (self.name,) + class ProjectUser(TimeStampedModel): - """ A project user represents a user on the project. - + """A project user represents a user on the project. + Attributes: user (User): represents the User object of the project user project (Project): links user to its project @@ -348,35 +378,39 @@ class ProjectUser(TimeStampedModel): user = models.ForeignKey(User, on_delete=models.CASCADE) project = models.ForeignKey(Project, on_delete=models.CASCADE) role = models.ForeignKey(ProjectUserRoleChoice, on_delete=models.CASCADE) - status = models.ForeignKey(ProjectUserStatusChoice, on_delete=models.CASCADE, verbose_name='Status') + status = models.ForeignKey(ProjectUserStatusChoice, on_delete=models.CASCADE, verbose_name="Status") enable_notifications = models.BooleanField(default=True) history = HistoricalRecords() def __str__(self): - return '%s %s (%s)' % (self.user.first_name, self.user.last_name, self.user.username) + return "%s %s (%s)" % (self.user.first_name, self.user.last_name, self.user.username) class Meta: - unique_together = ('user', 'project') + unique_together = ("user", "project") verbose_name_plural = "Project User Status" + class AttributeType(TimeStampedModel): - """ An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. - + """An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. + Attributes: name (str): name of attribute data type """ - + name = models.CharField(max_length=64) def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class ProjectAttributeType(TimeStampedModel): - """ A project attribute type indicates the type of the attribute. Examples include Project ID and Account Number. - + """A project attribute type indicates the type of the attribute. Examples include Project ID and Account Number. + Attributes: attribute_type (AttributeType): indicates the data type of the attribute name (str): name of project attribute type @@ -397,17 +431,20 @@ class ProjectAttributeType(TimeStampedModel): history = HistoricalRecords() def __str__(self): - return '%s (%s)' % (self.name, self.attribute_type.name) + return "%s (%s)" % (self.name, self.attribute_type.name) def __repr__(self) -> str: return str(self) class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class ProjectAttribute(TimeStampedModel): - """ A project attribute class links a project attribute type and a project. - + """A project attribute class links a project attribute type and a project. + Attributes: proj_attr_type (ProjectAttributeType): project attribute type to link project (Project): project to link @@ -421,17 +458,18 @@ class ProjectAttribute(TimeStampedModel): history = HistoricalRecords() def save(self, *args, **kwargs): - """ Saves the project attribute. """ + """Saves the project attribute.""" super().save(*args, **kwargs) if self.proj_attr_type.has_usage and not ProjectAttributeUsage.objects.filter(project_attribute=self).exists(): - ProjectAttributeUsage.objects.create( - project_attribute=self) + ProjectAttributeUsage.objects.create(project_attribute=self) def clean(self): - """ Validates the project and raises errors if the project is invalid. """ - if self.proj_attr_type.is_unique and self.project.projectattribute_set.filter(proj_attr_type=self.proj_attr_type).exists(): - raise ValidationError("'{}' attribute already exists for this project.".format( - self.proj_attr_type)) + """Validates the project and raises errors if the project is invalid.""" + if ( + self.proj_attr_type.is_unique + and self.project.projectattribute_set.filter(proj_attr_type=self.proj_attr_type).exists() + ): + raise ValidationError("'{}' attribute already exists for this project.".format(self.proj_attr_type)) expected_value_type = self.proj_attr_type.attribute_type.name.strip() @@ -447,20 +485,20 @@ def clean(self): validator.validate_date() def __str__(self): - return '%s' % (self.proj_attr_type.name) + return "%s" % (self.proj_attr_type.name) + class ProjectAttributeUsage(TimeStampedModel): - """ Project attribute usage indicates the usage of a project attribute. - + """Project attribute usage indicates the usage of a project attribute. + Attributes: project_attribute (ProjectAttribute): links the usage to its project attribute value (float): usage value of the project attribute """ - project_attribute = models.OneToOneField( - ProjectAttribute, on_delete=models.CASCADE, primary_key=True) + project_attribute = models.OneToOneField(ProjectAttribute, on_delete=models.CASCADE, primary_key=True) value = models.FloatField(default=0) history = HistoricalRecords() def __str__(self): - return '{}: {}'.format(self.project_attribute.proj_attr_type.name, self.value) + return "{}: {}".format(self.project_attribute.proj_attr_type.name, self.value) diff --git a/coldfront/core/project/signals.py b/coldfront/core/project/signals.py index 511463094b..fc4b0c5c87 100644 --- a/coldfront/core/project/signals.py +++ b/coldfront/core/project/signals.py @@ -1,16 +1,20 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import django.dispatch project_new = django.dispatch.Signal() - #providing_args=["project_obj"] +# providing_args=["project_obj"] project_archive = django.dispatch.Signal() - #providing_args=["project_obj"] +# providing_args=["project_obj"] project_update = django.dispatch.Signal() - #providing_args=["project_obj"] +# providing_args=["project_obj"] project_activate_user = django.dispatch.Signal() - #providing_args=["project_user_pk"] +# providing_args=["project_user_pk"] project_remove_user = django.dispatch.Signal() - #providing_args=["project_user_pk"] +# providing_args=["project_user_pk"] diff --git a/coldfront/core/project/test_views.py b/coldfront/core/project/test_views.py index be02b1fd8f..116fdd2619 100644 --- a/coldfront/core/project/test_views.py +++ b/coldfront/core/project/test_views.py @@ -1,19 +1,23 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from django.test import TestCase +from coldfront.core.project.models import ProjectUserStatusChoice from coldfront.core.test_helpers import utils from coldfront.core.test_helpers.factories import ( - UserFactory, - ProjectFactory, - ProjectUserFactory, PAttributeTypeFactory, ProjectAttributeFactory, - ProjectStatusChoiceFactory, ProjectAttributeTypeFactory, + ProjectFactory, + ProjectStatusChoiceFactory, + ProjectUserFactory, ProjectUserRoleChoiceFactory, + UserFactory, ) -from coldfront.core.project.models import ProjectUserStatusChoice logging.disable(logging.CRITICAL) @@ -24,22 +28,20 @@ class ProjectViewTestBase(TestCase): @classmethod def setUpTestData(cls): """Set up users and project for testing""" - cls.backend = 'django.contrib.auth.backends.ModelBackend' - cls.project = ProjectFactory(status=ProjectStatusChoiceFactory(name='Active')) + cls.backend = "django.contrib.auth.backends.ModelBackend" + cls.project = ProjectFactory(status=ProjectStatusChoiceFactory(name="Active")) - user_role = ProjectUserRoleChoiceFactory(name='User') + user_role = ProjectUserRoleChoiceFactory(name="User") project_user = ProjectUserFactory(project=cls.project, role=user_role) cls.project_user = project_user.user - manager_role = ProjectUserRoleChoiceFactory(name='Manager') - pi_user = ProjectUserFactory( - project=cls.project, role=manager_role, user=cls.project.pi - ) + manager_role = ProjectUserRoleChoiceFactory(name="Manager") + pi_user = ProjectUserFactory(project=cls.project, role=manager_role, user=cls.project.pi) cls.pi_user = pi_user.user cls.admin_user = UserFactory(is_staff=True, is_superuser=True) cls.nonproject_user = UserFactory(is_staff=False, is_superuser=False) - attributetype = PAttributeTypeFactory(name='string') + attributetype = PAttributeTypeFactory(name="string") cls.projectattributetype = ProjectAttributeTypeFactory(attribute_type=attributetype) def project_access_tstbase(self, url): @@ -60,7 +62,7 @@ class ProjectDetailViewTest(ProjectViewTestBase): def setUpTestData(cls): """Set up users and project for testing""" super(ProjectDetailViewTest, cls).setUpTestData() - cls.url = f'/project/{cls.project.pk}/' + cls.url = f"/project/{cls.project.pk}/" def test_projectdetail_access(self): """Test project detail page access""" @@ -76,18 +78,17 @@ def test_projectdetail_permissions(self): """Test project detail page access permissions""" # admin has is_allowed_to_update_project set to True response = utils.login_and_get_page(self.client, self.admin_user, self.url) - self.assertEqual(response.context['is_allowed_to_update_project'], True) + self.assertEqual(response.context["is_allowed_to_update_project"], True) # pi has is_allowed_to_update_project set to True response = utils.login_and_get_page(self.client, self.pi_user, self.url) - self.assertEqual(response.context['is_allowed_to_update_project'], True) + self.assertEqual(response.context["is_allowed_to_update_project"], True) # non-manager user has is_allowed_to_update_project set to False response = utils.login_and_get_page(self.client, self.project_user, self.url) - self.assertEqual(response.context['is_allowed_to_update_project'], False) + self.assertEqual(response.context["is_allowed_to_update_project"], False) def test_projectdetail_request_allocation_button_visibility(self): - """Test visibility of projectdetail request allocation button across user levels - """ - button_text = 'Request Resource Allocation' + """Test visibility of projectdetail request allocation button across user levels""" + button_text = "Request Resource Allocation" # admin can see request allocation button utils.page_contains_for_user(self, self.admin_user, self.url, button_text) # pi can see request allocation button @@ -96,24 +97,22 @@ def test_projectdetail_request_allocation_button_visibility(self): utils.page_does_not_contain_for_user(self, self.project_user, self.url, button_text) def test_projectdetail_edituser_button_visibility(self): - """Test visibility of projectdetail edit button across user levels - """ + """Test visibility of projectdetail edit button across user levels""" # admin can see edit button - utils.page_contains_for_user(self, self.admin_user, self.url, 'fa-user-edit') + utils.page_contains_for_user(self, self.admin_user, self.url, "fa-user-edit") # pi can see edit button - utils.page_contains_for_user(self, self.pi_user, self.url, 'fa-user-edit') + utils.page_contains_for_user(self, self.pi_user, self.url, "fa-user-edit") # non-manager user cannot see edit button - utils.page_does_not_contain_for_user(self, self.project_user, self.url, 'fa-user-edit') + utils.page_does_not_contain_for_user(self, self.project_user, self.url, "fa-user-edit") def test_projectdetail_addnotification_button_visibility(self): - """Test visibility of projectdetail add notification button across user levels - """ + """Test visibility of projectdetail add notification button across user levels""" # admin can see add notification button - utils.page_contains_for_user(self, self.admin_user, self.url, 'Add Notification') + utils.page_contains_for_user(self, self.admin_user, self.url, "Add Notification") # pi cannot see add notification button - utils.page_does_not_contain_for_user(self, self.pi_user, self.url, 'Add Notification') + utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Add Notification") # non-manager user cannot see add notification button - utils.page_does_not_contain_for_user(self, self.project_user, self.url, 'Add Notification') + utils.page_does_not_contain_for_user(self, self.project_user, self.url, "Add Notification") class ProjectCreateTest(ProjectViewTestBase): @@ -123,7 +122,7 @@ class ProjectCreateTest(ProjectViewTestBase): def setUpTestData(cls): """Set up users and project for testing""" super(ProjectCreateTest, cls).setUpTestData() - cls.url = '/project/create/' + cls.url = "/project/create/" def test_project_access(self): """Test access to project create page""" @@ -142,9 +141,9 @@ class ProjectAttributeCreateTest(ProjectViewTestBase): def setUpTestData(cls): """Set up users and project for testing""" super(ProjectAttributeCreateTest, cls).setUpTestData() - int_attributetype = PAttributeTypeFactory(name='Int') + int_attributetype = PAttributeTypeFactory(name="Int") cls.int_projectattributetype = ProjectAttributeTypeFactory(attribute_type=int_attributetype) - cls.url = f'/project/{cls.project.pk}/project-attribute-create/' + cls.url = f"/project/{cls.project.pk}/project-attribute-create/" def test_project_access(self): """Test access to project attribute create page""" @@ -160,41 +159,37 @@ def test_project_attribute_create_post(self): """Test project attribute creation post response""" self.client.force_login(self.admin_user, backend=self.backend) - response = self.client.post(self.url, data={ - 'proj_attr_type': self.projectattributetype.pk, - 'value': 'test_value', - 'project': self.project.pk - }) - redirect_url = f'/project/{self.project.pk}/' - self.assertRedirects( - response, redirect_url, status_code=302, target_status_code=200 + response = self.client.post( + self.url, + data={"proj_attr_type": self.projectattributetype.pk, "value": "test_value", "project": self.project.pk}, ) + redirect_url = f"/project/{self.project.pk}/" + self.assertRedirects(response, redirect_url, status_code=302, target_status_code=200) def test_project_attribute_create_post_required_values(self): """ProjectAttributeCreate correctly flags missing project or value""" self.client.force_login(self.admin_user, backend=self.backend) # missing project - response = self.client.post(self.url, data={ - 'proj_attr_type': self.projectattributetype.pk, 'value': 'test_value' - }) - self.assertFormError(response, 'form', 'project', 'This field is required.') + response = self.client.post( + self.url, data={"proj_attr_type": self.projectattributetype.pk, "value": "test_value"} + ) + self.assertFormError(response, "form", "project", "This field is required.") # missing value - response = self.client.post(self.url, data={ - 'proj_attr_type': self.projectattributetype.pk, 'project': self.project.pk - }) - self.assertFormError(response, 'form', 'value', 'This field is required.') + response = self.client.post( + self.url, data={"proj_attr_type": self.projectattributetype.pk, "project": self.project.pk} + ) + self.assertFormError(response, "form", "value", "This field is required.") def test_project_attribute_create_value_type_match(self): """ProjectAttributeCreate correctly flags value-type mismatch""" self.client.force_login(self.admin_user, backend=self.backend) # test that value must be numeric if proj_attr_type is string - response = self.client.post(self.url, data={ - 'proj_attr_type': self.int_projectattributetype.pk, - 'value': True, - 'project': self.project.pk - }) - self.assertContains(response, 'Invalid Value True. Value must be an int.') + response = self.client.post( + self.url, + data={"proj_attr_type": self.int_projectattributetype.pk, "value": True, "project": self.project.pk}, + ) + self.assertContains(response, "Invalid Value True. Value must be an int.") class ProjectAttributeUpdateTest(ProjectViewTestBase): @@ -207,7 +202,7 @@ def setUpTestData(cls): cls.projectattribute = ProjectAttributeFactory( value=36238, proj_attr_type=cls.projectattributetype, project=cls.project ) - cls.url = f'/project/{cls.project.pk}/project-attribute-update/{cls.projectattribute.pk}' + cls.url = f"/project/{cls.project.pk}/project-attribute-update/{cls.projectattribute.pk}" def test_project_attribute_update_access(self): """Test access to project attribute update page""" @@ -228,7 +223,7 @@ def setUpTestData(cls): cls.projectattribute = ProjectAttributeFactory( value=36238, proj_attr_type=cls.projectattributetype, project=cls.project ) - cls.url = f'/project/{cls.project.pk}/project-attribute-delete/' + cls.url = f"/project/{cls.project.pk}/project-attribute-delete/" def test_project_attribute_delete_access(self): """test access to project attribute delete page""" @@ -250,11 +245,8 @@ def setUpTestData(cls): super(ProjectListViewTest, cls).setUpTestData() # add 100 projects to test pagination, permissions, search functionality additional_projects = [ProjectFactory() for i in list(range(100))] - cls.additional_projects = [ - p for p in additional_projects - if p.pi.last_name != cls.project.pi.last_name - ] - cls.url = '/project/' + cls.additional_projects = [p for p in additional_projects if p.pi.last_name != cls.project.pi.last_name] + cls.url = "/project/" ### ProjectListView access tests ### @@ -273,51 +265,51 @@ def test_project_list_display_members(self): """Project list displays only projects that user is an active member of""" # deactivated projectuser won't see project on their page response = utils.login_and_get_page(self.client, self.project_user, self.url) - self.assertEqual(len(response.context['object_list']), 1) + self.assertEqual(len(response.context["object_list"]), 1) proj_user = self.project.projectuser_set.get(user=self.project_user) - proj_user.status, _ = ProjectUserStatusChoice.objects.get_or_create(name='Removed') + proj_user.status, _ = ProjectUserStatusChoice.objects.get_or_create(name="Removed") proj_user.save() response = utils.login_and_get_page(self.client, self.project_user, self.url) - self.assertEqual(len(response.context['object_list']), 0) + self.assertEqual(len(response.context["object_list"]), 0) def test_project_list_displayall_permission_admin(self): """Projectlist displayall option displays all projects to admin""" - url = self.url + '?show_all_projects=on' + url = self.url + "?show_all_projects=on" response = utils.login_and_get_page(self.client, self.admin_user, url) - self.assertGreaterEqual(101, len(response.context['object_list'])) + self.assertGreaterEqual(101, len(response.context["object_list"])) def test_project_list_displayall_permission_pi(self): """Projectlist displayall option displays only the pi's projects to the pi""" - url = self.url + '?show_all_projects=on' + url = self.url + "?show_all_projects=on" response = utils.login_and_get_page(self.client, self.pi_user, url) - self.assertEqual(len(response.context['object_list']), 1) + self.assertEqual(len(response.context["object_list"]), 1) def test_project_list_displayall_permission_project_user(self): - """Projectlist displayall displays only projects projectuser belongs to - """ - url = self.url + '?show_all_projects=on' + """Projectlist displayall displays only projects projectuser belongs to""" + url = self.url + "?show_all_projects=on" response = utils.login_and_get_page(self.client, self.project_user, url) - self.assertEqual(len(response.context['object_list']), 1) + self.assertEqual(len(response.context["object_list"]), 1) ### ProjectListView search tests ### def test_project_list_search(self): """Test that project list search works.""" - url_base = self.url + '?show_all_projects=on' + url_base = self.url + "?show_all_projects=on" url = ( - f'{url_base}&last_name={self.project.pi.last_name}' + - f'&field_of_science={self.project.field_of_science.description}' + f"{url_base}&last_name={self.project.pi.last_name}" + + f"&field_of_science={self.project.field_of_science.description}" ) # search by project project_title response = utils.login_and_get_page(self.client, self.admin_user, url) - self.assertEqual(len(response.context['object_list']), 1) + self.assertEqual(len(response.context["object_list"]), 1) class ProjectRemoveUsersViewTest(ProjectViewTestBase): """Tests for ProjectRemoveUsersView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/remove-users/' + self.url = f"/project/{self.project.pk}/remove-users/" def test_projectremoveusersview_access(self): """test access to project remove users page""" @@ -326,9 +318,10 @@ def test_projectremoveusersview_access(self): class ProjectUpdateViewTest(ProjectViewTestBase): """Tests for ProjectUpdateView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/update/' + self.url = f"/project/{self.project.pk}/update/" def test_projectupdateview_access(self): """test access to project update page""" @@ -337,9 +330,10 @@ def test_projectupdateview_access(self): class ProjectReviewListViewTest(ProjectViewTestBase): """Tests for ProjectReviewListView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/project-review-list' + self.url = "/project/project-review-list" def test_projectreviewlistview_access(self): """test access to project review list page""" @@ -348,9 +342,10 @@ def test_projectreviewlistview_access(self): class ProjectArchivedListViewTest(ProjectViewTestBase): """Tests for ProjectArchivedListView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/archived/' + self.url = "/project/archived/" def test_projectarchivedlistview_access(self): """test access to project archived list page""" @@ -359,9 +354,10 @@ def test_projectarchivedlistview_access(self): class ProjectNoteCreateViewTest(ProjectViewTestBase): """Tests for ProjectNoteCreateView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/projectnote/add' + self.url = f"/project/{self.project.pk}/projectnote/add" def test_projectnotecreateview_access(self): """test access to project note create page""" @@ -370,9 +366,10 @@ def test_projectnotecreateview_access(self): class ProjectAddUsersSearchView(ProjectViewTestBase): """Tests for ProjectAddUsersSearchView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/add-users-search/' + self.url = f"/project/{self.project.pk}/add-users-search/" def test_projectadduserssearchview_access(self): """test access to project add users search page""" @@ -381,9 +378,10 @@ def test_projectadduserssearchview_access(self): class ProjectUserDetailViewTest(ProjectViewTestBase): """Tests for ProjectUserDetailView""" + def setUp(self): """set up users and project for testing""" - self.url = f'/project/{self.project.pk}/user-detail/{self.project_user.pk}' + self.url = f"/project/{self.project.pk}/user-detail/{self.project_user.pk}" def test_projectuserdetailview_access(self): """test access to project user detail page""" diff --git a/coldfront/core/project/tests.py b/coldfront/core/project/tests.py index ad0113d93b..f648bd8e07 100644 --- a/coldfront/core/project/tests.py +++ b/coldfront/core/project/tests.py @@ -1,46 +1,50 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from unittest.mock import patch from django.core.exceptions import ValidationError from django.test import TestCase, TransactionTestCase +from coldfront.core.project.models import ( + Project, + ProjectAttribute, + ProjectAttributeType, +) from coldfront.core.project.utils import generate_project_code from coldfront.core.test_helpers.factories import ( - UserFactory, - ProjectFactory, FieldOfScienceFactory, + PAttributeTypeFactory, ProjectAttributeFactory, - ProjectStatusChoiceFactory, ProjectAttributeTypeFactory, - PAttributeTypeFactory, -) -from coldfront.core.project.models import ( - Project, - ProjectAttribute, - ProjectAttributeType, + ProjectFactory, + ProjectStatusChoiceFactory, + UserFactory, ) logging.disable(logging.CRITICAL) -class TestProject(TestCase): +class TestProject(TestCase): class Data: """Collection of test data, separated for readability""" def __init__(self): - user = UserFactory(username='cgray') + user = UserFactory(username="cgray") user.userprofile.is_pi = True - field_of_science = FieldOfScienceFactory(description='Chemistry') - status = ProjectStatusChoiceFactory(name='Active') + field_of_science = FieldOfScienceFactory(description="Chemistry") + status = ProjectStatusChoiceFactory(name="Active") self.initial_fields = { - 'pi': user, - 'title': 'Angular momentum in QGP holography', - 'description': 'We want to estimate the quark chemical potential of a rotating sample of plasma.', - 'field_of_science': field_of_science, - 'status': status, - 'force_review': True + "pi": user, + "title": "Angular momentum in QGP holography", + "description": "We want to estimate the quark chemical potential of a rotating sample of plasma.", + "field_of_science": field_of_science, + "status": status, + "force_review": True, } self.unsaved_object = Project(**self.initial_fields) @@ -69,11 +73,11 @@ def test_fields_generic(self): def test_title_maxlength(self): """Test that the title field has a maximum length of 255 characters""" expected_maximum_length = 255 - maximum_title = 'x' * expected_maximum_length + maximum_title = "x" * expected_maximum_length project_obj = self.data.unsaved_object - project_obj.title = maximum_title + 'x' + project_obj.title = maximum_title + "x" with self.assertRaises(ValidationError): project_obj.clean_fields() @@ -89,7 +93,7 @@ def test_auto_import_project_title(self): project_obj = self.data.unsaved_object assert project_obj.pk is None - project_obj.title = 'Auto-Import Project' + project_obj.title = "Auto-Import Project" with self.assertRaises(ValidationError): project_obj.clean() @@ -98,7 +102,7 @@ def test_description_minlength(self): If description is less than 10 characters, an error should be raised """ expected_minimum_length = 10 - minimum_description = 'x' * expected_minimum_length + minimum_description = "x" * expected_minimum_length project_obj = self.data.unsaved_object @@ -139,8 +143,7 @@ def test_pi_foreignkey_on_delete(self): self.assertEqual(0, len(Project.objects.all())) def test_fos_foreignkey_on_delete(self): - """Test that a project is deleted when its field of science is deleted. - """ + """Test that a project is deleted when its field of science is deleted.""" project_obj = self.data.unsaved_object project_obj.save() @@ -169,10 +172,9 @@ def test_status_foreignkey_on_delete(self): class TestProjectAttribute(TestCase): - @classmethod def setUpTestData(cls): - project_attr_types = [('Project ID', 'Text'), ('Account Number', 'Int')] + project_attr_types = [("Project ID", "Text"), ("Account Number", "Int")] for atype in project_attr_types: ProjectAttributeTypeFactory( name=atype[0], @@ -182,7 +184,7 @@ def setUpTestData(cls): ) cls.project = ProjectFactory() cls.new_attr = ProjectAttributeFactory( - proj_attr_type=ProjectAttributeType.objects.get(name='Account Number'), + proj_attr_type=ProjectAttributeType.objects.get(name="Account Number"), project=cls.project, value=1243, ) @@ -193,7 +195,7 @@ def test_unique_attrs_one_per_project(self): saved if the attribute type is unique """ self.assertEqual(1, len(self.project.projectattribute_set.all())) - proj_attr_type = ProjectAttributeType.objects.get(name='Account Number') + proj_attr_type = ProjectAttributeType.objects.get(name="Account Number") new_attr = ProjectAttribute(project=self.project, proj_attr_type=proj_attr_type) with self.assertRaises(ValidationError): new_attr.clean() @@ -201,23 +203,21 @@ def test_unique_attrs_one_per_project(self): def test_attribute_must_match_datatype(self): """Test that the attribute value must match the attribute type""" - proj_attr_type = ProjectAttributeType.objects.get(name='Account Number') - new_attr = ProjectAttribute( - project=self.project, proj_attr_type=proj_attr_type, value='abc' - ) + proj_attr_type = ProjectAttributeType.objects.get(name="Account Number") + new_attr = ProjectAttribute(project=self.project, proj_attr_type=proj_attr_type, value="abc") with self.assertRaises(ValidationError): new_attr.clean() class TestProjectCode(TransactionTestCase): - """Tear down database after each run to prevent conflicts across cases """ + """Tear down database after each run to prevent conflicts across cases""" + reset_sequences = True def setUp(self): - self.user = UserFactory(username='capeo') - self.field_of_science = FieldOfScienceFactory(description='Physics') - self.status = ProjectStatusChoiceFactory(name='Active') - + self.user = UserFactory(username="capeo") + self.field_of_science = FieldOfScienceFactory(description="Physics") + self.status = ProjectStatusChoiceFactory(name="Active") def create_project_with_code(self, title, project_code, project_code_padding=0): """Helper method to create a project and a project code with a specific prefix and padding""" @@ -235,45 +235,44 @@ def create_project_with_code(self, title, project_code, project_code_padding=0): return project.project_code - - @patch('coldfront.config.core.PROJECT_CODE', 'BFO') - @patch('coldfront.config.core.PROJECT_CODE_PADDING', 3) + @patch("coldfront.config.core.PROJECT_CODE", "BFO") + @patch("coldfront.config.core.PROJECT_CODE_PADDING", 3) def test_project_code_increment_after_deletion(self): - from coldfront.config.core import PROJECT_CODE - from coldfront.config.core import PROJECT_CODE_PADDING + from coldfront.config.core import PROJECT_CODE, PROJECT_CODE_PADDING + """Test that the project code increments by one after a project is deleted""" # Create the first project - project_with_code_padding1 = self.create_project_with_code('Project 1', PROJECT_CODE, PROJECT_CODE_PADDING) - self.assertEqual(project_with_code_padding1, 'BFO001') + project_with_code_padding1 = self.create_project_with_code("Project 1", PROJECT_CODE, PROJECT_CODE_PADDING) + self.assertEqual(project_with_code_padding1, "BFO001") # Delete the first project - project_obj1 = Project.objects.get(title='Project 1') + project_obj1 = Project.objects.get(title="Project 1") project_obj1.delete() # Create the second project - project_with_code_padding2 = self.create_project_with_code('Project 2', PROJECT_CODE, PROJECT_CODE_PADDING) - self.assertEqual(project_with_code_padding2, 'BFO002') + project_with_code_padding2 = self.create_project_with_code("Project 2", PROJECT_CODE, PROJECT_CODE_PADDING) + self.assertEqual(project_with_code_padding2, "BFO002") - - @patch('coldfront.config.core.PROJECT_CODE','BFO') + @patch("coldfront.config.core.PROJECT_CODE", "BFO") def test_no_padding(self): from coldfront.config.core import PROJECT_CODE + """Test with code and no padding""" - project_with_code = self.create_project_with_code('Project 1', PROJECT_CODE) - self.assertEqual(project_with_code, 'BFO1') # No padding + project_with_code = self.create_project_with_code("Project 1", PROJECT_CODE) + self.assertEqual(project_with_code, "BFO1") # No padding - @patch('coldfront.config.core.PROJECT_CODE', 'BFO') - @patch('coldfront.config.core.PROJECT_CODE_PADDING', 3) + @patch("coldfront.config.core.PROJECT_CODE", "BFO") + @patch("coldfront.config.core.PROJECT_CODE_PADDING", 3) def test_different_prefix_padding(self): - from coldfront.config.core import PROJECT_CODE - from coldfront.config.core import PROJECT_CODE_PADDING + from coldfront.config.core import PROJECT_CODE, PROJECT_CODE_PADDING + """Test with code and padding""" # Create two projects with codes - project_with_code_padding1 = self.create_project_with_code('Project 1', PROJECT_CODE, PROJECT_CODE_PADDING) - project_with_code_padding2 = self.create_project_with_code('Project 2', PROJECT_CODE, PROJECT_CODE_PADDING) + project_with_code_padding1 = self.create_project_with_code("Project 1", PROJECT_CODE, PROJECT_CODE_PADDING) + project_with_code_padding2 = self.create_project_with_code("Project 2", PROJECT_CODE, PROJECT_CODE_PADDING) # Test the generated project codes - self.assertEqual(project_with_code_padding1, 'BFO001') - self.assertEqual(project_with_code_padding2, 'BFO002') + self.assertEqual(project_with_code_padding1, "BFO001") + self.assertEqual(project_with_code_padding2, "BFO002") diff --git a/coldfront/core/project/urls.py b/coldfront/core/project/urls.py index 06a5fb67b4..1cf88e85c7 100644 --- a/coldfront/core/project/urls.py +++ b/coldfront/core/project/urls.py @@ -1,28 +1,60 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.project.views as project_views urlpatterns = [ - path('/', project_views.ProjectDetailView.as_view(), name='project-detail'), - path('/archive', project_views.ProjectArchiveProjectView.as_view(), name='project-archive'), - path('', project_views.ProjectListView.as_view(), name='project-list'), - path('project-user-update-email-notification/', project_views.project_update_email_notification, name='project-user-update-email-notification'), - path('archived/', project_views.ProjectArchivedListView.as_view(), name='project-archived-list'), - path('create/', project_views.ProjectCreateView.as_view(), name='project-create'), - path('/update/', project_views.ProjectUpdateView.as_view(), name='project-update'), - path('/add-users-search/', project_views.ProjectAddUsersSearchView.as_view(), name='project-add-users-search'), - path('/add-users-search-results/', project_views.ProjectAddUsersSearchResultsView.as_view(), name='project-add-users-search-results'), - path('/add-users/', project_views.ProjectAddUsersView.as_view(), name='project-add-users'), - path('/remove-users/', project_views.ProjectRemoveUsersView.as_view(), name='project-remove-users'), - path('/user-detail/', project_views.ProjectUserDetail.as_view(), name='project-user-detail'), - path('/review/', project_views.ProjectReviewView.as_view(), name='project-review'), - path('project-review-list', project_views.ProjectReviewListView.as_view(),name='project-review-list'), - path('project-review-complete//', project_views.ProjectReviewCompleteView.as_view(), - name='project-review-complete'), - path('project-review//email', project_views.ProjectReviewEmailView.as_view(), name='project-review-email'), - path('/projectnote/add', project_views.ProjectNoteCreateView.as_view(), name='project-note-add'), - path('/project-attribute-create/', project_views.ProjectAttributeCreateView.as_view(), name='project-attribute-create'), - path('/project-attribute-delete/', project_views.ProjectAttributeDeleteView.as_view(), name='project-attribute-delete'), - path('/project-attribute-update/', project_views.ProjectAttributeUpdateView.as_view(), name='project-attribute-update'), - + path("/", project_views.ProjectDetailView.as_view(), name="project-detail"), + path("/archive", project_views.ProjectArchiveProjectView.as_view(), name="project-archive"), + path("", project_views.ProjectListView.as_view(), name="project-list"), + path( + "project-user-update-email-notification/", + project_views.project_update_email_notification, + name="project-user-update-email-notification", + ), + path("archived/", project_views.ProjectArchivedListView.as_view(), name="project-archived-list"), + path("create/", project_views.ProjectCreateView.as_view(), name="project-create"), + path("/update/", project_views.ProjectUpdateView.as_view(), name="project-update"), + path( + "/add-users-search/", project_views.ProjectAddUsersSearchView.as_view(), name="project-add-users-search" + ), + path( + "/add-users-search-results/", + project_views.ProjectAddUsersSearchResultsView.as_view(), + name="project-add-users-search-results", + ), + path("/add-users/", project_views.ProjectAddUsersView.as_view(), name="project-add-users"), + path("/remove-users/", project_views.ProjectRemoveUsersView.as_view(), name="project-remove-users"), + path( + "/user-detail/", + project_views.ProjectUserDetail.as_view(), + name="project-user-detail", + ), + path("/review/", project_views.ProjectReviewView.as_view(), name="project-review"), + path("project-review-list", project_views.ProjectReviewListView.as_view(), name="project-review-list"), + path( + "project-review-complete//", + project_views.ProjectReviewCompleteView.as_view(), + name="project-review-complete", + ), + path("project-review//email", project_views.ProjectReviewEmailView.as_view(), name="project-review-email"), + path("/projectnote/add", project_views.ProjectNoteCreateView.as_view(), name="project-note-add"), + path( + "/project-attribute-create/", + project_views.ProjectAttributeCreateView.as_view(), + name="project-attribute-create", + ), + path( + "/project-attribute-delete/", + project_views.ProjectAttributeDeleteView.as_view(), + name="project-attribute-delete", + ), + path( + "/project-attribute-update/", + project_views.ProjectAttributeUpdateView.as_view(), + name="project-attribute-update", + ), ] diff --git a/coldfront/core/project/utils.py b/coldfront/core/project/utils.py index 31f08e05eb..5c4ce847a5 100644 --- a/coldfront/core/project/utils.py +++ b/coldfront/core/project/utils.py @@ -1,27 +1,42 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + def add_project_status_choices(apps, schema_editor): - ProjectStatusChoice = apps.get_model('project', 'ProjectStatusChoice') + ProjectStatusChoice = apps.get_model("project", "ProjectStatusChoice") - for choice in ['New', 'Active', 'Archived', ]: + for choice in [ + "New", + "Active", + "Archived", + ]: ProjectStatusChoice.objects.get_or_create(name=choice) def add_project_user_role_choices(apps, schema_editor): - ProjectUserRoleChoice = apps.get_model('project', 'ProjectUserRoleChoice') + ProjectUserRoleChoice = apps.get_model("project", "ProjectUserRoleChoice") - for choice in ['User', 'Manager', ]: + for choice in [ + "User", + "Manager", + ]: ProjectUserRoleChoice.objects.get_or_create(name=choice) def add_project_user_status_choices(apps, schema_editor): - ProjectUserStatusChoice = apps.get_model('project', 'ProjectUserStatusChoice') - - for choice in ['Active', 'Pending Remove', 'Denied', 'Removed', ]: + ProjectUserStatusChoice = apps.get_model("project", "ProjectUserStatusChoice") + + for choice in [ + "Active", + "Pending Remove", + "Denied", + "Removed", + ]: ProjectUserStatusChoice.objects.get_or_create(name=choice) def generate_project_code(project_code: str, project_pk: int, padding: int = 0) -> str: - """ Generate a formatted project code by combining an uppercased user-defined project code, project primary key and requested padding value (default = 0). @@ -33,6 +48,3 @@ def generate_project_code(project_code: str, project_pk: int, padding: int = 0) """ return f"{project_code.upper()}{str(project_pk).zfill(padding)}" - - - diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index f73796331d..9fac3ab514 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -1,66 +1,70 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import pprint -import django -import logging -from django import forms +import logging +from django import forms from django.conf import settings from django.contrib import messages -from django import forms +from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.contrib.auth.decorators import user_passes_test, login_required from django.contrib.auth.models import User -from coldfront.core.project.utils import generate_project_code -from coldfront.core.utils.common import import_from_settings from django.contrib.messages.views import SuccessMessageMixin from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q -from django.forms import formset_factory, modelformset_factory -from django.http import (HttpResponse, HttpResponseForbidden, - HttpResponseRedirect) +from django.forms import formset_factory +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render -from django.template.loader import render_to_string from django.urls import reverse -from coldfront.core.allocation.utils import generate_guauge_data_from_usage, get_user_resources from django.views import View from django.views.generic import CreateView, DetailView, ListView, UpdateView from django.views.generic.base import TemplateView from django.views.generic.edit import FormView -from coldfront.config.core import ALLOCATION_EULA_ENABLE - -from coldfront.core.allocation.models import (Allocation, - AllocationStatusChoice, - AllocationUser, - AllocationUserStatusChoice) -from coldfront.core.allocation.signals import (allocation_activate_user, - allocation_remove_user) -from coldfront.core.project.signals import (project_new, - project_archive, - project_activate_user, - project_remove_user, - project_update) +from coldfront.config.core import ALLOCATION_EULA_ENABLE +from coldfront.core.allocation.models import ( + Allocation, + AllocationStatusChoice, + AllocationUser, + AllocationUserStatusChoice, +) +from coldfront.core.allocation.signals import allocation_activate_user, allocation_remove_user +from coldfront.core.allocation.utils import generate_guauge_data_from_usage from coldfront.core.grant.models import Grant -from coldfront.core.project.forms import (ProjectAddUserForm, - ProjectAddUsersToAllocationForm, - ProjectAttributeAddForm, - ProjectAttributeDeleteForm, - ProjectRemoveUserForm, - ProjectReviewEmailForm, - ProjectReviewForm, - ProjectSearchForm, - ProjectUserUpdateForm, - ProjectAttributeUpdateForm, - ProjectCreationForm) -from coldfront.core.project.models import (Project, - ProjectAttribute, - ProjectReview, - ProjectReviewStatusChoice, - ProjectStatusChoice, - ProjectUser, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - ProjectUserMessage) +from coldfront.core.project.forms import ( + ProjectAddUserForm, + ProjectAddUsersToAllocationForm, + ProjectAttributeAddForm, + ProjectAttributeDeleteForm, + ProjectAttributeUpdateForm, + ProjectCreationForm, + ProjectRemoveUserForm, + ProjectReviewEmailForm, + ProjectReviewForm, + ProjectSearchForm, + ProjectUserUpdateForm, +) +from coldfront.core.project.models import ( + Project, + ProjectAttribute, + ProjectReview, + ProjectReviewStatusChoice, + ProjectStatusChoice, + ProjectUser, + ProjectUserMessage, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) +from coldfront.core.project.signals import ( + project_activate_user, + project_archive, + project_new, + project_remove_user, + project_update, +) +from coldfront.core.project.utils import generate_project_code from coldfront.core.publication.models import Publication from coldfront.core.research_output.models import ResearchOutput from coldfront.core.user.forms import UserSearchForm @@ -68,134 +72,152 @@ from coldfront.core.utils.common import get_domain_url, import_from_settings from coldfront.core.utils.mail import send_email, send_email_template -EMAIL_ENABLED = import_from_settings('EMAIL_ENABLED', False) -ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings( - 'ALLOCATION_ENABLE_ALLOCATION_RENEWAL', True) -ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings( - 'ALLOCATION_DEFAULT_ALLOCATION_LENGTH', 365) +EMAIL_ENABLED = import_from_settings("EMAIL_ENABLED", False) +ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings("ALLOCATION_ENABLE_ALLOCATION_RENEWAL", True) +ALLOCATION_DEFAULT_ALLOCATION_LENGTH = import_from_settings("ALLOCATION_DEFAULT_ALLOCATION_LENGTH", 365) if EMAIL_ENABLED: - EMAIL_DIRECTOR_EMAIL_ADDRESS = import_from_settings( - 'EMAIL_DIRECTOR_EMAIL_ADDRESS') - EMAIL_SENDER = import_from_settings('EMAIL_SENDER') + EMAIL_DIRECTOR_EMAIL_ADDRESS = import_from_settings("EMAIL_DIRECTOR_EMAIL_ADDRESS") + EMAIL_SENDER = import_from_settings("EMAIL_SENDER") -PROJECT_CODE = import_from_settings('PROJECT_CODE', False) -PROJECT_CODE_PADDING = import_from_settings('PROJECT_CODE_PADDING', False) +PROJECT_CODE = import_from_settings("PROJECT_CODE", False) +PROJECT_CODE_PADDING = import_from_settings("PROJECT_CODE_PADDING", False) logger = logging.getLogger(__name__) + class ProjectDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView): model = Project - template_name = 'project/project_detail.html' - context_object_name = 'project' + template_name = "project/project_detail.html" + context_object_name = "project" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('project.can_view_all_projects'): + if self.request.user.has_perm("project.can_view_all_projects"): return True project_obj = self.get_object() - if project_obj.projectuser_set.filter(user=self.request.user, status__name='Active').exists(): + if project_obj.projectuser_set.filter(user=self.request.user, status__name="Active").exists(): return True - messages.error( - self.request, 'You do not have permission to view the previous page.') + messages.error(self.request, "You do not have permission to view the previous page.") return False def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # Can the user update the project? if self.request.user.is_superuser: - context['is_allowed_to_update_project'] = True + context["is_allowed_to_update_project"] = True elif self.object.projectuser_set.filter(user=self.request.user).exists(): - project_user = self.object.projectuser_set.get( - user=self.request.user) - if project_user.role.name == 'Manager': - context['is_allowed_to_update_project'] = True + project_user = self.object.projectuser_set.get(user=self.request.user) + if project_user.role.name == "Manager": + context["is_allowed_to_update_project"] = True else: - context['is_allowed_to_update_project'] = False + context["is_allowed_to_update_project"] = False else: - context['is_allowed_to_update_project'] = False + context["is_allowed_to_update_project"] = False - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) if self.request.user.is_superuser: - attributes_with_usage = [attribute for attribute in project_obj.projectattribute_set.all( - ).order_by('proj_attr_type__name') if hasattr(attribute, 'projectattributeusage')] + attributes_with_usage = [ + attribute + for attribute in project_obj.projectattribute_set.all().order_by("proj_attr_type__name") + if hasattr(attribute, "projectattributeusage") + ] - attributes = [attribute for attribute in project_obj.projectattribute_set.all( - ).order_by('proj_attr_type__name')] + attributes = [ + attribute for attribute in project_obj.projectattribute_set.all().order_by("proj_attr_type__name") + ] else: - attributes_with_usage = [attribute for attribute in project_obj.projectattribute_set.filter( - proj_attr_type__is_private=False) if hasattr(attribute, 'projectattributeusage')] + attributes_with_usage = [ + attribute + for attribute in project_obj.projectattribute_set.filter(proj_attr_type__is_private=False) + if hasattr(attribute, "projectattributeusage") + ] - attributes = [attribute for attribute in project_obj.projectattribute_set.filter( - proj_attr_type__is_private=False)] + attributes = [ + attribute for attribute in project_obj.projectattribute_set.filter(proj_attr_type__is_private=False) + ] guage_data = [] invalid_attributes = [] for attribute in attributes_with_usage: try: - guage_data.append(generate_guauge_data_from_usage(attribute.proj_attr_type.name, - float(attribute.value), float(attribute.projectattributeusage.value))) + guage_data.append( + generate_guauge_data_from_usage( + attribute.proj_attr_type.name, + float(attribute.value), + float(attribute.projectattributeusage.value), + ) + ) except ValueError: - logger.error("Allocation attribute '%s' is not an int but has a usage", - attribute.allocation_attribute_type.name) + logger.error( + "Allocation attribute '%s' is not an int but has a usage", attribute.allocation_attribute_type.name + ) invalid_attributes.append(attribute) for a in invalid_attributes: attributes_with_usage.remove(a) # Only show 'Active Users' - project_users = self.object.projectuser_set.filter( - status__name='Active').order_by('user__username') + project_users = self.object.projectuser_set.filter(status__name="Active").order_by("user__username") - context['mailto'] = 'mailto:' + \ - ','.join([user.user.email for user in project_users]) + context["mailto"] = "mailto:" + ",".join([user.user.email for user in project_users]) - if self.request.user.is_superuser or self.request.user.has_perm('allocation.can_view_all_allocations'): - allocations = Allocation.objects.prefetch_related( - 'resources').filter(project=self.object).order_by('-end_date') + if self.request.user.is_superuser or self.request.user.has_perm("allocation.can_view_all_allocations"): + allocations = ( + Allocation.objects.prefetch_related("resources").filter(project=self.object).order_by("-end_date") + ) else: - if self.object.status.name in ['Active', 'New', ]: - allocations = Allocation.objects.filter( - Q(project=self.object) & - Q(project__projectuser__user=self.request.user) & - Q(project__projectuser__status__name__in=['Active', ]) & - Q(allocationuser__user=self.request.user) & - Q(allocationuser__status__name__in=['Active', 'PendingEULA' ]) - ).distinct().order_by('-end_date') + if self.object.status.name in [ + "Active", + "New", + ]: + allocations = ( + Allocation.objects.filter( + Q(project=self.object) + & Q(project__projectuser__user=self.request.user) + & Q( + project__projectuser__status__name__in=[ + "Active", + ] + ) + & Q(allocationuser__user=self.request.user) + & Q(allocationuser__status__name__in=["Active", "PendingEULA"]) + ) + .distinct() + .order_by("-end_date") + ) else: - allocations = Allocation.objects.prefetch_related( - 'resources').filter(project=self.object) - - user_status = [] + allocations = Allocation.objects.prefetch_related("resources").filter(project=self.object) + + user_status = [] for allocation in allocations: if allocation.allocationuser_set.filter(user=self.request.user).exists(): user_status.append(allocation.allocationuser_set.get(user=self.request.user).status.name) - context['publications'] = Publication.objects.filter( - project=self.object, status='Active').order_by('-year') - context['research_outputs'] = ResearchOutput.objects.filter( - project=self.object).order_by('-created') - context['grants'] = Grant.objects.filter( - project=self.object, status__name__in=['Active', 'Pending', 'Archived']) - context['allocations'] = allocations - context['user_allocation_status'] = user_status - context['attributes'] = attributes - context['guage_data'] = guage_data - context['attributes_with_usage'] = attributes_with_usage - context['project_users'] = project_users - context['ALLOCATION_ENABLE_ALLOCATION_RENEWAL'] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL + context["publications"] = Publication.objects.filter(project=self.object, status="Active").order_by("-year") + context["research_outputs"] = ResearchOutput.objects.filter(project=self.object).order_by("-created") + context["grants"] = Grant.objects.filter( + project=self.object, status__name__in=["Active", "Pending", "Archived"] + ) + context["allocations"] = allocations + context["user_allocation_status"] = user_status + context["attributes"] = attributes + context["guage_data"] = guage_data + context["attributes_with_usage"] = attributes_with_usage + context["project_users"] = project_users + context["ALLOCATION_ENABLE_ALLOCATION_RENEWAL"] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL try: - context['ondemand_url'] = settings.ONDEMAND_URL + context["ondemand_url"] = settings.ONDEMAND_URL except AttributeError: pass @@ -203,106 +225,144 @@ def get_context_data(self, **kwargs): class ProjectListView(LoginRequiredMixin, ListView): - model = Project - template_name = 'project/project_list.html' - prefetch_related = ['pi', 'status', 'field_of_science', ] - context_object_name = 'project_list' + template_name = "project/project_list.html" + prefetch_related = [ + "pi", + "status", + "field_of_science", + ] + context_object_name = "project_list" paginate_by = 25 def get_queryset(self): - - order_by = self.request.GET.get('order_by', 'id') - direction = self.request.GET.get('direction', 'asc') + order_by = self.request.GET.get("order_by", "id") + direction = self.request.GET.get("direction", "asc") if order_by != "name": - if direction == 'asc': - direction = '' - if direction == 'des': - direction = '-' + if direction == "asc": + direction = "" + if direction == "des": + direction = "-" order_by = direction + order_by project_search_form = ProjectSearchForm(self.request.GET) if project_search_form.is_valid(): data = project_search_form.cleaned_data - if data.get('show_all_projects') and (self.request.user.is_superuser or self.request.user.has_perm('project.can_view_all_projects')): - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - status__name__in=['New', 'Active', ]).order_by(order_by) + if data.get("show_all_projects") and ( + self.request.user.is_superuser or self.request.user.has_perm("project.can_view_all_projects") + ): + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + status__name__in=[ + "New", + "Active", + ] + ) + .order_by(order_by) + ) else: - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - Q(status__name__in=['New', 'Active', ]) & - Q(projectuser__user=self.request.user) & - Q(projectuser__status__name='Active') - ).order_by(order_by) + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "New", + "Active", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) + ) # Last Name - if data.get('last_name'): - projects = projects.filter( - pi__last_name__icontains=data.get('last_name')) + if data.get("last_name"): + projects = projects.filter(pi__last_name__icontains=data.get("last_name")) # Username - if data.get('username'): + if data.get("username"): projects = projects.filter( - Q(pi__username__icontains=data.get('username')) | - Q(projectuser__user__username__icontains=data.get('username')) & - Q(projectuser__status__name='Active') + Q(pi__username__icontains=data.get("username")) + | Q(projectuser__user__username__icontains=data.get("username")) + & Q(projectuser__status__name="Active") ) # Field of Science - if data.get('field_of_science'): - projects = projects.filter( - field_of_science__description__icontains=data.get('field_of_science')) + if data.get("field_of_science"): + projects = projects.filter(field_of_science__description__icontains=data.get("field_of_science")) else: - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - Q(status__name__in=['New', 'Active', ]) & - Q(projectuser__user=self.request.user) & - Q(projectuser__status__name='Active') - ).order_by(order_by) + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "New", + "Active", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) + ) return projects.distinct() def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) projects_count = self.get_queryset().count() - context['projects_count'] = projects_count + context["projects_count"] = projects_count project_search_form = ProjectSearchForm(self.request.GET) if project_search_form.is_valid(): - context['project_search_form'] = project_search_form + context["project_search_form"] = project_search_form data = project_search_form.cleaned_data - filter_parameters = '' + filter_parameters = "" for key, value in data.items(): if value: if isinstance(value, list): for ele in value: - filter_parameters += '{}={}&'.format(key, ele) + filter_parameters += "{}={}&".format(key, ele) else: - filter_parameters += '{}={}&'.format(key, value) - context['project_search_form'] = project_search_form + filter_parameters += "{}={}&".format(key, value) + context["project_search_form"] = project_search_form else: filter_parameters = None - context['project_search_form'] = ProjectSearchForm() + context["project_search_form"] = ProjectSearchForm() - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % (order_by, direction) + direction = self.request.GET.get("direction") + filter_parameters_with_order_by = filter_parameters + "order_by=%s&direction=%s&" % (order_by, direction) else: filter_parameters_with_order_by = filter_parameters if filter_parameters: - context['expand_accordion'] = 'show' + context["expand_accordion"] = "show" - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = filter_parameters_with_order_by + context["filter_parameters"] = filter_parameters + context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - project_list = context.get('project_list') + project_list = context.get("project_list") paginator = Paginator(project_list, self.paginate_by) - page = self.request.GET.get('page') + page = self.request.GET.get("page") try: project_list = paginator.page(page) @@ -315,103 +375,136 @@ def get_context_data(self, **kwargs): class ProjectArchivedListView(LoginRequiredMixin, ListView): - model = Project - template_name = 'project/project_archived_list.html' - prefetch_related = ['pi', 'status', 'field_of_science', ] - context_object_name = 'project_list' + template_name = "project/project_archived_list.html" + prefetch_related = [ + "pi", + "status", + "field_of_science", + ] + context_object_name = "project_list" paginate_by = 10 def get_queryset(self): - - order_by = self.request.GET.get('order_by', 'id') - direction = self.request.GET.get('direction', '') + order_by = self.request.GET.get("order_by", "id") + direction = self.request.GET.get("direction", "") if order_by != "name": - if direction == 'des': - direction = '-' + if direction == "des": + direction = "-" order_by = direction + order_by project_search_form = ProjectSearchForm(self.request.GET) if project_search_form.is_valid(): data = project_search_form.cleaned_data - if data.get('show_all_projects') and (self.request.user.is_superuser or self.request.user.has_perm('project.can_view_all_projects')): - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - status__name__in=['Archived', ]).order_by(order_by) + if data.get("show_all_projects") and ( + self.request.user.is_superuser or self.request.user.has_perm("project.can_view_all_projects") + ): + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + status__name__in=[ + "Archived", + ] + ) + .order_by(order_by) + ) else: - - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - Q(status__name__in=['Archived', ]) & - Q(projectuser__user=self.request.user) & - Q(projectuser__status__name='Active') - ).order_by(order_by) + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "Archived", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) + ) # Last Name - if data.get('last_name'): - projects = projects.filter( - pi__last_name__icontains=data.get('last_name')) + if data.get("last_name"): + projects = projects.filter(pi__last_name__icontains=data.get("last_name")) # Username - if data.get('username'): - projects = projects.filter( - pi__username__icontains=data.get('username')) + if data.get("username"): + projects = projects.filter(pi__username__icontains=data.get("username")) # Field of Science - if data.get('field_of_science'): - projects = projects.filter( - field_of_science__description__icontains=data.get('field_of_science')) + if data.get("field_of_science"): + projects = projects.filter(field_of_science__description__icontains=data.get("field_of_science")) else: - projects = Project.objects.prefetch_related('pi', 'field_of_science', 'status',).filter( - Q(status__name__in=['Archived', ]) & - Q(projectuser__user=self.request.user) & - Q(projectuser__status__name='Active') - ).order_by(order_by) + projects = ( + Project.objects.prefetch_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "Archived", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) + ) return projects def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) projects_count = self.get_queryset().count() - context['projects_count'] = projects_count - context['expand'] = False + context["projects_count"] = projects_count + context["expand"] = False project_search_form = ProjectSearchForm(self.request.GET) if project_search_form.is_valid(): - context['project_search_form'] = project_search_form + context["project_search_form"] = project_search_form data = project_search_form.cleaned_data - filter_parameters = '' + filter_parameters = "" for key, value in data.items(): if value: if isinstance(value, list): for ele in value: - filter_parameters += '{}={}&'.format(key, ele) + filter_parameters += "{}={}&".format(key, ele) else: - filter_parameters += '{}={}&'.format(key, value) - context['project_search_form'] = project_search_form + filter_parameters += "{}={}&".format(key, value) + context["project_search_form"] = project_search_form else: filter_parameters = None - context['project_search_form'] = ProjectSearchForm() + context["project_search_form"] = ProjectSearchForm() - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % (order_by, direction) + direction = self.request.GET.get("direction") + filter_parameters_with_order_by = filter_parameters + "order_by=%s&direction=%s&" % (order_by, direction) else: filter_parameters_with_order_by = filter_parameters if filter_parameters: - context['expand_accordion'] = 'show' + context["expand_accordion"] = "show" - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = filter_parameters_with_order_by + context["filter_parameters"] = filter_parameters + context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - project_list = context.get('project_list') + project_list = context.get("project_list") paginator = Paginator(project_list, self.paginate_by) - page = self.request.GET.get('page') + page = self.request.GET.get("page") try: project_list = paginator.page(page) @@ -424,58 +517,58 @@ def get_context_data(self, **kwargs): class ProjectArchiveProjectView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_archive.html' + template_name = "project/project_archive.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project = get_object_or_404(Project, pk=pk) - context['project'] = project + context["project"] = project return context def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project = get_object_or_404(Project, pk=pk) - project_status_archive = ProjectStatusChoice.objects.get( - name='Archived') - allocation_status_expired = AllocationStatusChoice.objects.get( - name='Expired') + project_status_archive = ProjectStatusChoice.objects.get(name="Archived") + allocation_status_expired = AllocationStatusChoice.objects.get(name="Expired") end_date = datetime.datetime.now() project.status = project_status_archive project.save() # project signals - project_archive.send(sender=self.__class__,project_obj=project) + project_archive.send(sender=self.__class__, project_obj=project) - for allocation in project.allocation_set.filter(status__name='Active'): + for allocation in project.allocation_set.filter(status__name="Active"): allocation.status = allocation_status_expired allocation.end_date = end_date allocation.save() - return redirect(reverse('project-detail', kwargs={'pk': project.pk})) + return redirect(reverse("project-detail", kwargs={"pk": project.pk})) class ProjectCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = Project - template_name_suffix = '_create_form' + template_name_suffix = "_create_form" form_class = ProjectCreationForm def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True @@ -485,22 +578,22 @@ def test_func(self): def form_valid(self, form): project_obj = form.save(commit=False) form.instance.pi = self.request.user - form.instance.status = ProjectStatusChoice.objects.get(name='New') + form.instance.status = ProjectStatusChoice.objects.get(name="New") project_obj.save() self.object = project_obj - project_user_obj = ProjectUser.objects.create( + ProjectUser.objects.create( user=self.request.user, project=project_obj, - role=ProjectUserRoleChoice.objects.get(name='Manager'), - status=ProjectUserStatusChoice.objects.get(name='Active') + role=ProjectUserRoleChoice.objects.get(name="Manager"), + status=ProjectUserStatusChoice.objects.get(name="Active"), ) if PROJECT_CODE: - ''' + """ Set the ProjectCode object, if PROJECT_CODE is defined. If PROJECT_CODE_PADDING is defined, the set amount of padding will be added to PROJECT_CODE. - ''' + """ project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0) project_obj.save(update_fields=["project_code"]) @@ -510,17 +603,21 @@ def form_valid(self, form): return super().form_valid(form) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.pk}) + return reverse("project-detail", kwargs={"pk": self.object.pk}) class ProjectUpdateView(SuccessMessageMixin, LoginRequiredMixin, UserPassesTestMixin, UpdateView): model = Project - template_name_suffix = '_update_form' - fields = ['title', 'description', 'field_of_science', ] - success_message = 'Project updated.' + template_name_suffix = "_update_form" + fields = [ + "title", + "description", + "field_of_science", + ] + success_message = "Project updated." def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True @@ -529,252 +626,263 @@ def test_func(self): if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if PROJECT_CODE and project_obj.project_code == "": - ''' + """ Updates project code if no value was set, providing the feature is activated. - ''' + """ project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0) project_obj.save(update_fields=["project_code"]) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error(request, 'You cannot update an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot update an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def get_success_url(self): # project signals project_update.send(sender=self.__class__, project_obj=self.object) - return reverse('project-detail', kwargs={'pk': self.object.pk}) + return reverse("project-detail", kwargs={"pk": self.object.pk}) class ProjectAddUsersSearchView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_add_users.html' + template_name = "project/project_add_users.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add users to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add users to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['user_search_form'] = UserSearchForm() - context['project'] = Project.objects.get(pk=self.kwargs.get('pk')) + context["user_search_form"] = UserSearchForm() + context["project"] = Project.objects.get(pk=self.kwargs.get("pk")) return context class ProjectAddUsersSearchResultsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/add_user_search_results.html' + template_name = "project/add_user_search_results.html" raise_exception = True def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add users to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add users to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - user_search_string = request.POST.get('q') - search_by = request.POST.get('search_by') - pk = self.kwargs.get('pk') + user_search_string = request.POST.get("q") + search_by = request.POST.get("search_by") + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) - users_to_exclude = [ele.user.username for ele in project_obj.projectuser_set.filter( - status__name='Active')] + users_to_exclude = [ele.user.username for ele in project_obj.projectuser_set.filter(status__name="Active")] - cobmined_user_search_obj = CombinedUserSearch( - user_search_string, search_by, users_to_exclude) + cobmined_user_search_obj = CombinedUserSearch(user_search_string, search_by, users_to_exclude) context = cobmined_user_search_obj.search() - matches = context.get('matches') + matches = context.get("matches") for match in matches: - match.update( - {'role': ProjectUserRoleChoice.objects.get(name='User')}) + match.update({"role": ProjectUserRoleChoice.objects.get(name="User")}) if matches: formset = formset_factory(ProjectAddUserForm, max_num=len(matches)) - formset = formset(initial=matches, prefix='userform') - context['formset'] = formset - context['user_search_string'] = user_search_string - context['search_by'] = search_by + formset = formset(initial=matches, prefix="userform") + context["formset"] = formset + context["user_search_string"] = user_search_string + context["search_by"] = search_by if len(user_search_string.split()) > 1: users_already_in_project = [] for ele in user_search_string.split(): if ele in users_to_exclude: users_already_in_project.append(ele) - context['users_already_in_project'] = users_already_in_project + context["users_already_in_project"] = users_already_in_project # The following block of code is used to hide/show the allocation div in the form. - if project_obj.allocation_set.filter(status__name__in=['Active', 'New', 'Renewal Requested']).exists(): - div_allocation_class = 'placeholder_div_class' + if project_obj.allocation_set.filter(status__name__in=["Active", "New", "Renewal Requested"]).exists(): + div_allocation_class = "placeholder_div_class" else: - div_allocation_class = 'd-none' - context['div_allocation_class'] = div_allocation_class + div_allocation_class = "d-none" + context["div_allocation_class"] = div_allocation_class ### - allocation_form = ProjectAddUsersToAllocationForm( - request.user, project_obj.pk, prefix='allocationform') - context['pk'] = pk - context['allocation_form'] = allocation_form + allocation_form = ProjectAddUsersToAllocationForm(request.user, project_obj.pk, prefix="allocationform") + context["pk"] = pk + context["allocation_form"] = allocation_form return render(request, self.template_name, context) class ProjectAddUsersView(LoginRequiredMixin, UserPassesTestMixin, View): - def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add users to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add users to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - user_search_string = request.POST.get('q') - search_by = request.POST.get('search_by') - pk = self.kwargs.get('pk') + user_search_string = request.POST.get("q") + search_by = request.POST.get("search_by") + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) - users_to_exclude = [ele.user.username for ele in project_obj.projectuser_set.filter( - status__name='Active')] + users_to_exclude = [ele.user.username for ele in project_obj.projectuser_set.filter(status__name="Active")] - cobmined_user_search_obj = CombinedUserSearch( - user_search_string, search_by, users_to_exclude) + cobmined_user_search_obj = CombinedUserSearch(user_search_string, search_by, users_to_exclude) context = cobmined_user_search_obj.search() - matches = context.get('matches') + matches = context.get("matches") for match in matches: - match.update( - {'role': ProjectUserRoleChoice.objects.get(name='User')}) + match.update({"role": ProjectUserRoleChoice.objects.get(name="User")}) formset = formset_factory(ProjectAddUserForm, max_num=len(matches)) - formset = formset(request.POST, initial=matches, prefix='userform') + formset = formset(request.POST, initial=matches, prefix="userform") allocation_form = ProjectAddUsersToAllocationForm( - request.user, project_obj.pk, request.POST, prefix='allocationform') + request.user, project_obj.pk, request.POST, prefix="allocationform" + ) added_users_count = 0 if formset.is_valid() and allocation_form.is_valid(): - project_user_active_status_choice = ProjectUserStatusChoice.objects.get( - name='Active') - allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get( - name='Active') + project_user_active_status_choice = ProjectUserStatusChoice.objects.get(name="Active") + allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get(name="Active") if ALLOCATION_EULA_ENABLE: - allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get( - name='PendingEULA') - - allocation_form_data = allocation_form.cleaned_data['allocation'] - if '__select_all__' in allocation_form_data: - allocation_form_data.remove('__select_all__') + allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get(name="PendingEULA") + + allocation_form_data = allocation_form.cleaned_data["allocation"] + if "__select_all__" in allocation_form_data: + allocation_form_data.remove("__select_all__") for form in formset: user_form_data = form.cleaned_data - if user_form_data['selected']: + if user_form_data["selected"]: added_users_count += 1 # Will create local copy of user if not already present in local database - user_obj, _ = User.objects.get_or_create( - username=user_form_data.get('username')) - user_obj.first_name = user_form_data.get('first_name') - user_obj.last_name = user_form_data.get('last_name') - user_obj.email = user_form_data.get('email') + user_obj, _ = User.objects.get_or_create(username=user_form_data.get("username")) + user_obj.first_name = user_form_data.get("first_name") + user_obj.last_name = user_form_data.get("last_name") + user_obj.email = user_form_data.get("email") user_obj.save() - role_choice = user_form_data.get('role') + role_choice = user_form_data.get("role") # Is the user already in the project? if project_obj.projectuser_set.filter(user=user_obj).exists(): - project_user_obj = project_obj.projectuser_set.get( - user=user_obj) + project_user_obj = project_obj.projectuser_set.get(user=user_obj) project_user_obj.role = role_choice project_user_obj.status = project_user_active_status_choice project_user_obj.save() else: project_user_obj = ProjectUser.objects.create( - user=user_obj, project=project_obj, role=role_choice, status=project_user_active_status_choice) + user=user_obj, + project=project_obj, + role=role_choice, + status=project_user_active_status_choice, + ) # project signals - project_activate_user.send(sender=self.__class__,project_user_pk=project_user_obj.pk) + project_activate_user.send(sender=self.__class__, project_user_pk=project_user_obj.pk) for allocation in Allocation.objects.filter(pk__in=allocation_form_data): has_eula = allocation.get_eula() user_status_choice = allocation_user_active_status_choice if allocation.allocationuser_set.filter(user=user_obj).exists(): - if ALLOCATION_EULA_ENABLE and has_eula and (allocation_user_obj.status != allocation_user_active_status_choice): + allocation_user_obj = allocation.allocationuser_set.get(user=user_obj) + if ( + ALLOCATION_EULA_ENABLE + and has_eula + and (allocation_user_obj.status != allocation_user_active_status_choice) + ): user_status_choice = allocation_user_pending_status_choice - allocation_user_obj = allocation.allocationuser_set.get( - user=user_obj) allocation_user_obj.status = user_status_choice allocation_user_obj.save() else: if ALLOCATION_EULA_ENABLE and has_eula: user_status_choice = allocation_user_pending_status_choice allocation_user_obj = AllocationUser.objects.create( - allocation=allocation, - user=user_obj, - status=user_status_choice) - if (user_status_choice == allocation_user_active_status_choice): - allocation_activate_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) - - messages.success( - request, 'Added {} users to project.'.format(added_users_count)) + allocation=allocation, user=user_obj, status=user_status_choice + ) + if user_status_choice == allocation_user_active_status_choice: + allocation_activate_user.send( + sender=self.__class__, allocation_user_pk=allocation_user_obj.pk + ) + + messages.success(request, "Added {} users to project.".format(added_users_count)) else: if not formset.is_valid(): for error in formset.errors: @@ -784,207 +892,213 @@ def post(self, request, *args, **kwargs): for error in allocation_form.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": pk})) class ProjectRemoveUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_remove_users.html' + template_name = "project/project_remove_users.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot remove users from an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot remove users from an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def get_users_to_remove(self, project_obj): users_to_remove = [ - - {'username': ele.user.username, - 'first_name': ele.user.first_name, - 'last_name': ele.user.last_name, - 'email': ele.user.email, - 'role': ele.role} - - for ele in project_obj.projectuser_set.filter(status__name='Active').order_by('user__username') if ele.user != self.request.user and ele.user != project_obj.pi + { + "username": ele.user.username, + "first_name": ele.user.first_name, + "last_name": ele.user.last_name, + "email": ele.user.email, + "role": ele.role, + } + for ele in project_obj.projectuser_set.filter(status__name="Active").order_by("user__username") + if ele.user != self.request.user and ele.user != project_obj.pi ] return users_to_remove def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) users_to_remove = self.get_users_to_remove(project_obj) context = {} if users_to_remove: - formset = formset_factory( - ProjectRemoveUserForm, max_num=len(users_to_remove)) - formset = formset(initial=users_to_remove, prefix='userform') - context['formset'] = formset + formset = formset_factory(ProjectRemoveUserForm, max_num=len(users_to_remove)) + formset = formset(initial=users_to_remove, prefix="userform") + context["formset"] = formset - context['project'] = get_object_or_404(Project, pk=pk) + context["project"] = get_object_or_404(Project, pk=pk) return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) users_to_remove = self.get_users_to_remove(project_obj) - formset = formset_factory( - ProjectRemoveUserForm, max_num=len(users_to_remove)) - formset = formset( - request.POST, initial=users_to_remove, prefix='userform') + formset = formset_factory(ProjectRemoveUserForm, max_num=len(users_to_remove)) + formset = formset(request.POST, initial=users_to_remove, prefix="userform") remove_users_count = 0 if formset.is_valid(): - project_user_removed_status_choice = ProjectUserStatusChoice.objects.get( - name='Removed') - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get( - name='Removed') + project_user_removed_status_choice = ProjectUserStatusChoice.objects.get(name="Removed") + allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get(name="Removed") for form in formset: user_form_data = form.cleaned_data - if user_form_data['selected']: - + if user_form_data["selected"]: remove_users_count += 1 - user_obj = User.objects.get( - username=user_form_data.get('username')) + user_obj = User.objects.get(username=user_form_data.get("username")) if project_obj.pi == user_obj: continue - project_user_obj = project_obj.projectuser_set.get( - user=user_obj) + project_user_obj = project_obj.projectuser_set.get(user=user_obj) project_user_obj.status = project_user_removed_status_choice project_user_obj.save() # project signals - project_remove_user.send(sender=self.__class__,project_user_pk=project_user_obj.pk) + project_remove_user.send(sender=self.__class__, project_user_pk=project_user_obj.pk) # get allocation to remove users from allocations_to_remove_user_from = project_obj.allocation_set.filter( - status__name__in=['Active', 'New', 'Renewal Requested']) + status__name__in=["Active", "New", "Renewal Requested"] + ) for allocation in allocations_to_remove_user_from: - for allocation_user_obj in allocation.allocationuser_set.filter(user=user_obj, status__name__in=['Active', ]): + for allocation_user_obj in allocation.allocationuser_set.filter( + user=user_obj, + status__name__in=[ + "Active", + ], + ): allocation_user_obj.status = allocation_user_removed_status_choice allocation_user_obj.save() - allocation_remove_user.send(sender=self.__class__, - allocation_user_pk=allocation_user_obj.pk) + allocation_remove_user.send( + sender=self.__class__, allocation_user_pk=allocation_user_obj.pk + ) if remove_users_count == 1: - messages.success( - request, 'Removed {} user from project.'.format(remove_users_count)) + messages.success(request, "Removed {} user from project.".format(remove_users_count)) else: - messages.success( - request, 'Removed {} users from project.'.format(remove_users_count)) + messages.success(request, "Removed {} users from project.".format(remove_users_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": pk})) class ProjectUserDetail(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_user_detail.html' + template_name = "project/project_user_detail.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def get(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - project_user_pk = self.kwargs.get('project_user_pk') + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_user_pk = self.kwargs.get("project_user_pk") if project_obj.projectuser_set.filter(pk=project_user_pk).exists(): - project_user_obj = project_obj.projectuser_set.get( - pk=project_user_pk) + project_user_obj = project_obj.projectuser_set.get(pk=project_user_pk) project_user_update_form = ProjectUserUpdateForm( - initial={'role': project_user_obj.role, 'enable_notifications': project_user_obj.enable_notifications}) + initial={"role": project_user_obj.role, "enable_notifications": project_user_obj.enable_notifications} + ) context = {} - context['project_obj'] = project_obj - context['project_user_update_form'] = project_user_update_form - context['project_user_obj'] = project_user_obj + context["project_obj"] = project_obj + context["project_user_update_form"] = project_user_update_form + context["project_user_obj"] = project_user_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - project_user_pk = self.kwargs.get('project_user_pk') + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_user_pk = self.kwargs.get("project_user_pk") - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot update a user in an archived project.') - return HttpResponseRedirect(reverse('project-user-detail', kwargs={'pk': project_user_pk})) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot update a user in an archived project.") + return HttpResponseRedirect(reverse("project-user-detail", kwargs={"pk": project_user_pk})) if project_obj.projectuser_set.filter(id=project_user_pk).exists(): - project_user_obj = project_obj.projectuser_set.get( - pk=project_user_pk) + project_user_obj = project_obj.projectuser_set.get(pk=project_user_pk) if project_user_obj.user == project_user_obj.project.pi: - messages.error( - request, 'PI role and email notification option cannot be changed.') - return HttpResponseRedirect(reverse('project-user-detail', kwargs={'pk': project_user_pk})) + messages.error(request, "PI role and email notification option cannot be changed.") + return HttpResponseRedirect(reverse("project-user-detail", kwargs={"pk": project_user_pk})) - project_user_update_form = ProjectUserUpdateForm(request.POST, - initial={'role': project_user_obj.role.name, - 'enable_notifications': project_user_obj.enable_notifications} - ) + project_user_update_form = ProjectUserUpdateForm( + request.POST, + initial={ + "role": project_user_obj.role.name, + "enable_notifications": project_user_obj.enable_notifications, + }, + ) if project_user_update_form.is_valid(): form_data = project_user_update_form.cleaned_data - project_user_obj.role = ProjectUserRoleChoice.objects.get( - name=form_data.get('role')) - - if(project_user_obj.role.name=="Manager"): + project_user_obj.role = ProjectUserRoleChoice.objects.get(name=form_data.get("role")) + + if project_user_obj.role.name == "Manager": project_user_obj.enable_notifications = True else: - project_user_obj.enable_notifications = form_data.get( - 'enable_notifications') + project_user_obj.enable_notifications = form_data.get("enable_notifications") project_user_obj.save() - messages.success(request, 'User details updated.') - return HttpResponseRedirect(reverse('project-user-detail', kwargs={'pk': project_obj.pk, 'project_user_pk': project_user_obj.pk})) + messages.success(request, "User details updated.") + return HttpResponseRedirect( + reverse( + "project-user-detail", kwargs={"pk": project_obj.pk, "project_user_pk": project_user_obj.pk} + ) + ) @login_required def project_update_email_notification(request): - if request.method == "POST": data = request.POST - project_user_obj = get_object_or_404( - ProjectUser, pk=data.get('user_project_id')) - + project_user_obj = get_object_or_404(ProjectUser, pk=data.get("user_project_id")) project_obj = project_user_obj.project @@ -992,7 +1106,7 @@ def project_update_email_notification(request): if project_obj.pi == request.user: allowed = True - if project_obj.projectuser_set.filter(user=request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter(user=request.user, role__name="Manager", status__name="Active").exists(): allowed = True if project_user_obj.user == request.user: @@ -1001,189 +1115,193 @@ def project_update_email_notification(request): if request.user.is_superuser: allowed = True - if allowed == False: - return HttpResponse('not allowed', status=403) + if allowed is False: + return HttpResponse("not allowed", status=403) else: - checked = data.get('checked') - if checked == 'true': + checked = data.get("checked") + if checked == "true": project_user_obj.enable_notifications = True project_user_obj.save() - return HttpResponse('checked', status=200) - elif checked == 'false': + return HttpResponse("checked", status=200) + elif checked == "false": project_user_obj.enable_notifications = False project_user_obj.save() - return HttpResponse('unchecked', status=200) + return HttpResponse("unchecked", status=200) else: - return HttpResponse('no checked', status=400) + return HttpResponse("no checked", status=400) else: - return HttpResponse('no POST', status=400) + return HttpResponse("no POST", status=400) class ProjectReviewView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_review.html' + template_name = "project/project_review.html" login_url = "/" # redirect URL if fail test_func def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permissions to review this project.') + messages.error(self.request, "You do not have permissions to review this project.") def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if not project_obj.needs_review: - messages.error(request, 'You do not need to review this project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + messages.error(request, "You do not need to review this project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) - if 'Auto-Import Project'.lower() in project_obj.title.lower(): + if "Auto-Import Project".lower() in project_obj.title.lower(): messages.error( - request, 'You must update the project title before reviewing your project. You cannot have "Auto-Import Project" in the title.') - return HttpResponseRedirect(reverse('project-update', kwargs={'pk': project_obj.pk})) + request, + 'You must update the project title before reviewing your project. You cannot have "Auto-Import Project" in the title.', + ) + return HttpResponseRedirect(reverse("project-update", kwargs={"pk": project_obj.pk})) - if 'We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!' in project_obj.description: - messages.error( - request, 'You must update the project description before reviewing your project.') - return HttpResponseRedirect(reverse('project-update', kwargs={'pk': project_obj.pk})) + if ( + "We do not have information about your research. Please provide a detailed description of your work and update your field of science. Thank you!" + in project_obj.description + ): + messages.error(request, "You must update the project description before reviewing your project.") + return HttpResponseRedirect(reverse("project-update", kwargs={"pk": project_obj.pk})) return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) project_review_form = ProjectReviewForm(project_obj.pk) context = {} - context['project'] = project_obj - context['project_review_form'] = project_review_form - context['project_users'] = ', '.join(['{} {}'.format(ele.user.first_name, ele.user.last_name) - for ele in project_obj.projectuser_set.filter(status__name='Active').order_by('user__last_name')]) + context["project"] = project_obj + context["project_review_form"] = project_review_form + context["project_users"] = ", ".join( + [ + "{} {}".format(ele.user.first_name, ele.user.last_name) + for ele in project_obj.projectuser_set.filter(status__name="Active").order_by("user__last_name") + ] + ) return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) project_review_form = ProjectReviewForm(project_obj.pk, request.POST) - project_review_status_choice = ProjectReviewStatusChoice.objects.get( - name='Pending') + project_review_status_choice = ProjectReviewStatusChoice.objects.get(name="Pending") if project_review_form.is_valid(): form_data = project_review_form.cleaned_data - project_review_obj = ProjectReview.objects.create( + ProjectReview.objects.create( project=project_obj, - reason_for_not_updating_project=form_data.get('reason'), - status=project_review_status_choice) + reason_for_not_updating_project=form_data.get("reason"), + status=project_review_status_choice, + ) project_obj.force_review = False project_obj.save() domain_url = get_domain_url(self.request) - url = '{}{}'.format(domain_url, reverse('project-review-list')) + url = "{}{}".format(domain_url, reverse("project-review-list")) if EMAIL_ENABLED: send_email_template( - 'New project review has been submitted', - 'email/new_project_review.txt', - {'url': url}, + "New project review has been submitted", + "email/new_project_review.txt", + {"url": url}, EMAIL_SENDER, - [EMAIL_DIRECTOR_EMAIL_ADDRESS, ] + [ + EMAIL_DIRECTOR_EMAIL_ADDRESS, + ], ) - messages.success(request, 'Project reviewed successfully.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + messages.success(request, "Project reviewed successfully.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: - messages.error( - request, 'There was an error in processing your project review.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + messages.error(request, "There was an error in processing your project review.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) class ProjectReviewListView(LoginRequiredMixin, UserPassesTestMixin, ListView): - model = ProjectReview - template_name = 'project/project_review_list.html' - prefetch_related = ['project', ] - context_object_name = 'project_review_list' + template_name = "project/project_review_list.html" + prefetch_related = [ + "project", + ] + context_object_name = "project_review_list" def get_queryset(self): - return ProjectReview.objects.filter(status__name='Pending') + return ProjectReview.objects.filter(status__name="Pending") def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('project.can_review_pending_project_reviews'): + if self.request.user.has_perm("project.can_review_pending_project_reviews"): return True - messages.error( - self.request, 'You do not have permission to review pending project reviews.') + messages.error(self.request, "You do not have permission to review pending project reviews.") class ProjectReviewCompleteView(LoginRequiredMixin, UserPassesTestMixin, View): login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('project.can_review_pending_project_reviews'): + if self.request.user.has_perm("project.can_review_pending_project_reviews"): return True - messages.error( - self.request, 'You do not have permission to mark a pending project review as completed.') + messages.error(self.request, "You do not have permission to mark a pending project review as completed.") def get(self, request, project_review_pk): - project_review_obj = get_object_or_404( - ProjectReview, pk=project_review_pk) + project_review_obj = get_object_or_404(ProjectReview, pk=project_review_pk) - project_review_status_completed_obj = ProjectReviewStatusChoice.objects.get( - name='Completed') + project_review_status_completed_obj = ProjectReviewStatusChoice.objects.get(name="Completed") project_review_obj.status = project_review_status_completed_obj project_review_obj.project.project_needs_review = False project_review_obj.save() - messages.success(request, 'Project review for {} has been completed'.format( - project_review_obj.project.title) - ) + messages.success(request, "Project review for {} has been completed".format(project_review_obj.project.title)) - return HttpResponseRedirect(reverse('project-review-list')) + return HttpResponseRedirect(reverse("project-review-list")) class ProjectReviewEmailView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = ProjectReviewEmailForm - template_name = 'project/project_review_email.html' + template_name = "project/project_review_email.html" login_url = "/" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - if self.request.user.has_perm('project.can_review_pending_project_reviews'): + if self.request.user.has_perm("project.can_review_pending_project_reviews"): return True - messages.error( - self.request, 'You do not have permission to send email for a pending project review.') + messages.error(self.request, "You do not have permission to send email for a pending project review.") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_review_obj = get_object_or_404(ProjectReview, pk=pk) - context['project_review'] = project_review_obj + context["project_review"] = project_review_obj return context @@ -1191,88 +1309,87 @@ def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" if form_class is None: form_class = self.get_form_class() - return form_class(self.kwargs.get('pk'), **self.get_form_kwargs()) + return form_class(self.kwargs.get("pk"), **self.get_form_kwargs()) def form_valid(self, form): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_review_obj = get_object_or_404(ProjectReview, pk=pk) form_data = form.cleaned_data receiver_list = [project_review_obj.project.pi.email] - cc = form_data.get('cc').strip() + cc = form_data.get("cc").strip() if cc: - cc = cc.split(',') + cc = cc.split(",") else: cc = [] send_email( - 'Request for more information', - form_data.get('email_body'), - EMAIL_DIRECTOR_EMAIL_ADDRESS, - receiver_list, - cc + "Request for more information", form_data.get("email_body"), EMAIL_DIRECTOR_EMAIL_ADDRESS, receiver_list, cc ) - messages.success(self.request, 'Email sent to {} {} ({})'.format( - project_review_obj.project.pi.first_name, - project_review_obj.project.pi.last_name, - project_review_obj.project.pi.username) + messages.success( + self.request, + "Email sent to {} {} ({})".format( + project_review_obj.project.pi.first_name, + project_review_obj.project.pi.last_name, + project_review_obj.project.pi.username, + ), ) return super().form_valid(form) def get_success_url(self): - return reverse('project-review-list') + return reverse("project-review-list") class ProjectNoteCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = ProjectUserMessage - fields = '__all__' - template_name = 'project/project_note_create.html' + fields = "__all__" + template_name = "project/project_note_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True else: - messages.error( - self.request, 'You do not have permission to add allocation notes.') + messages.error(self.request, "You do not have permission to add allocation notes.") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) - context['project'] = project_obj + context["project"] = project_obj return context def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) author = self.request.user - initial['project'] = project_obj - initial['author'] = author + initial["project"] = project_obj + initial["author"] = author return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['project'].widget = forms.HiddenInput() - form.fields['author'].widget = forms.HiddenInput() - form.order_fields([ 'project', 'author', 'message', 'is_private' ]) + form.fields["project"].widget = forms.HiddenInput() + form.fields["author"].widget = forms.HiddenInput() + form.order_fields(["project", "author", "message", "is_private"]) return form def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.kwargs.get('pk')}) + return reverse("project-detail", kwargs={"pk": self.kwargs.get("pk")}) + class ProjectAttributeCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = ProjectAttribute form_class = ProjectAttributeAddForm - template_name = 'project/project_attribute_create.html' + template_name = "project/project_attribute_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if self.request.user.is_superuser: return True @@ -1280,44 +1397,45 @@ def test_func(self): if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permission to add project attributes.') + messages.error(self.request, "You do not have permission to add project attributes.") def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') - initial['project'] = get_object_or_404(Project, pk=pk) - initial['user'] = self.request.user + pk = self.kwargs.get("pk") + initial["project"] = get_object_or_404(Project, pk=pk) + initial["user"] = self.request.user return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['project'].widget = forms.HiddenInput() + form.fields["project"].widget = forms.HiddenInput() return form def get_context_data(self, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") context = super().get_context_data(*args, **kwargs) - context['project'] = get_object_or_404(Project, pk=pk) + context["project"] = get_object_or_404(Project, pk=pk) return context def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project_id}) + return reverse("project-detail", kwargs={"pk": self.object.project_id}) class ProjectAttributeDeleteView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = ProjectAttribute form_class = ProjectAttributeDeleteForm - template_name = 'project/project_attribute_delete.html' + template_name = "project/project_attribute_delete.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if self.request.user.is_superuser: return True @@ -1325,11 +1443,12 @@ def test_func(self): if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permission to add project attributes.') + messages.error(self.request, "You do not have permission to add project attributes.") def get_avail_attrs(self, project_obj): if not self.request.user.is_superuser: @@ -1337,76 +1456,59 @@ def get_avail_attrs(self, project_obj): else: avail_attrs = ProjectAttribute.objects.filter(project=project_obj) avail_attrs_dicts = [ - { - 'pk' : attr.pk, - 'selected' : False, - 'name' : str(attr.proj_attr_type), - 'value' : attr.value - } - + {"pk": attr.pk, "selected": False, "name": str(attr.proj_attr_type), "value": attr.value} for attr in avail_attrs ] return avail_attrs_dicts def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") project_obj = get_object_or_404(Project, pk=pk) - project_attributes_to_delete = self.get_avail_attrs( - project_obj) + project_attributes_to_delete = self.get_avail_attrs(project_obj) context = {} if project_attributes_to_delete: - formset = formset_factory(ProjectAttributeDeleteForm, max_num=len( - project_attributes_to_delete)) - formset = formset( - initial=project_attributes_to_delete, prefix='attributeform') - context['formset'] = formset - context['project'] = project_obj + formset = formset_factory(ProjectAttributeDeleteForm, max_num=len(project_attributes_to_delete)) + formset = formset(initial=project_attributes_to_delete, prefix="attributeform") + context["formset"] = formset + context["project"] = project_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") attr_to_delete = self.get_avail_attrs(pk) - formset = formset_factory( - ProjectAttributeDeleteForm, - max_num=len(attr_to_delete) - ) - formset = formset( - request.POST, - initial=attr_to_delete, - prefix='attributeform' - ) + formset = formset_factory(ProjectAttributeDeleteForm, max_num=len(attr_to_delete)) + formset = formset(request.POST, initial=attr_to_delete, prefix="attributeform") attributes_deleted_count = 0 if formset.is_valid(): for form in formset: form_data = form.cleaned_data - if form_data['selected']: + if form_data["selected"]: attributes_deleted_count += 1 - proj_attr = ProjectAttribute.objects.get( - pk=form_data['pk']) + proj_attr = ProjectAttribute.objects.get(pk=form_data["pk"]) proj_attr.delete() - messages.success(request, 'Deleted {} attributes from project.'.format( - attributes_deleted_count)) + messages.success(request, "Deleted {} attributes from project.".format(attributes_deleted_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": pk})) + class ProjectAttributeUpdateView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'project/project_attribute_update.html' + template_name = "project/project_attribute_update.html" def test_func(self): - """ UserPassesTestMixin Tests""" - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) + """UserPassesTestMixin Tests""" + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) if self.request.user.is_superuser: return True @@ -1414,52 +1516,73 @@ def test_func(self): if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def get(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - project_attribute_pk = self.kwargs.get('project_attribute_pk') - + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_attribute_pk = self.kwargs.get("project_attribute_pk") if project_obj.projectattribute_set.filter(pk=project_attribute_pk).exists(): - project_attribute_obj = project_obj.projectattribute_set.get( - pk=project_attribute_pk) + project_attribute_obj = project_obj.projectattribute_set.get(pk=project_attribute_pk) project_attribute_update_form = ProjectAttributeUpdateForm( - initial={'pk': self.kwargs.get('project_attribute_pk'),'name': project_attribute_obj, 'value': project_attribute_obj.value, 'type' : project_attribute_obj.proj_attr_type}) + initial={ + "pk": self.kwargs.get("project_attribute_pk"), + "name": project_attribute_obj, + "value": project_attribute_obj.value, + "type": project_attribute_obj.proj_attr_type, + } + ) context = {} - context['project_obj'] = project_obj - context['project_attribute_update_form'] = project_attribute_update_form - context['project_attribute_obj'] = project_attribute_obj + context["project_obj"] = project_obj + context["project_attribute_update_form"] = project_attribute_update_form + context["project_attribute_obj"] = project_attribute_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('pk')) - project_attribute_pk = self.kwargs.get('project_attribute_pk') + project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_attribute_pk = self.kwargs.get("project_attribute_pk") if project_obj.projectattribute_set.filter(pk=project_attribute_pk).exists(): - project_attribute_obj = project_obj.projectattribute_set.get( - pk=project_attribute_pk) - - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot update an attribute in an archived project.') - return HttpResponseRedirect(reverse('project-attribute-update', kwargs={'pk': project_obj.pk, 'project_attribute_pk': project_attribute_obj.pk})) + project_attribute_obj = project_obj.projectattribute_set.get(pk=project_attribute_pk) + + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot update an attribute in an archived project.") + return HttpResponseRedirect( + reverse( + "project-attribute-update", + kwargs={"pk": project_obj.pk, "project_attribute_pk": project_attribute_obj.pk}, + ) + ) - project_attribute_update_form = ProjectAttributeUpdateForm(request.POST, initial={'pk': self.kwargs.get('project_attribute_pk'),}) + project_attribute_update_form = ProjectAttributeUpdateForm( + request.POST, + initial={ + "pk": self.kwargs.get("project_attribute_pk"), + }, + ) if project_attribute_update_form.is_valid(): form_data = project_attribute_update_form.cleaned_data - project_attribute_obj.value = form_data.get( - 'new_value') + project_attribute_obj.value = form_data.get("new_value") project_attribute_obj.save() - messages.success(request, 'Attribute Updated.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + messages.success(request, "Attribute Updated.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: for error in project_attribute_update_form.errors.values(): messages.error(request, error) - return HttpResponseRedirect(reverse('project-attribute-update', kwargs={'pk': project_obj.pk, 'project_attribute_pk': project_attribute_obj.pk})) + return HttpResponseRedirect( + reverse( + "project-attribute-update", + kwargs={"pk": project_obj.pk, "project_attribute_pk": project_attribute_obj.pk}, + ) + ) diff --git a/coldfront/core/publication/__init__.py b/coldfront/core/publication/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/publication/__init__.py +++ b/coldfront/core/publication/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/publication/admin.py b/coldfront/core/publication/admin.py index 9ae5fc989d..11708656f4 100644 --- a/coldfront/core/publication/admin.py +++ b/coldfront/core/publication/admin.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin @@ -6,10 +10,13 @@ @admin.register(PublicationSource) class PublicationSourceAdmin(SimpleHistoryAdmin): - list_display = ('name', 'url',) + list_display = ( + "name", + "url", + ) @admin.register(Publication) class PublicationAdmin(SimpleHistoryAdmin): - list_display = ('title', 'author', 'journal', 'year') - search_fields = ('project__pi__username', 'project__pi__last_name', 'title') + list_display = ("title", "author", "journal", "year") + search_fields = ("project__pi__username", "project__pi__last_name", "title") diff --git a/coldfront/core/publication/apps.py b/coldfront/core/publication/apps.py index aafa0bf636..41ba13b33f 100644 --- a/coldfront/core/publication/apps.py +++ b/coldfront/core/publication/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class PublicationConfig(AppConfig): - name = 'coldfront.core.publication' + name = "coldfront.core.publication" diff --git a/coldfront/core/publication/forms.py b/coldfront/core/publication/forms.py index 773c0fdaf6..ae9c1a5ae7 100644 --- a/coldfront/core/publication/forms.py +++ b/coldfront/core/publication/forms.py @@ -1,6 +1,8 @@ -from django import forms +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later -from coldfront.core.publication.models import PublicationSource +from django import forms class PublicationAddForm(forms.Form): @@ -12,12 +14,11 @@ class PublicationAddForm(forms.Form): class PublicationSearchForm(forms.Form): - search_id = forms.CharField( - label='Search ID', widget=forms.Textarea, required=True) + search_id = forms.CharField(label="Search ID", widget=forms.Textarea, required=True) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['search_id'].help_text = '
Enter ID such as DOI or Bibliographic Code to search.' + self.fields["search_id"].help_text = "
Enter ID such as DOI or Bibliographic Code to search." class PublicationResultForm(forms.Form): @@ -37,7 +38,7 @@ class PublicationDeleteForm(forms.Form): class PublicationExportForm(forms.Form): - title = forms.CharField(max_length=255, disabled=True) - year = forms.CharField(max_length=30, disabled=True) - unique_id = forms.CharField(max_length=255, disabled=True) - selected = forms.BooleanField(initial=False, required=False) + title = forms.CharField(max_length=255, disabled=True) + year = forms.CharField(max_length=30, disabled=True) + unique_id = forms.CharField(max_length=255, disabled=True) + selected = forms.BooleanField(initial=False, required=False) diff --git a/coldfront/core/publication/management/__init__.py b/coldfront/core/publication/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/publication/management/__init__.py +++ b/coldfront/core/publication/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/publication/management/commands/__init__.py b/coldfront/core/publication/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/publication/management/commands/__init__.py +++ b/coldfront/core/publication/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/publication/management/commands/add_default_publication_sources.py b/coldfront/core/publication/management/commands/add_default_publication_sources.py index 186b829f94..dd38b380c9 100644 --- a/coldfront/core/publication/management/commands/add_default_publication_sources.py +++ b/coldfront/core/publication/management/commands/add_default_publication_sources.py @@ -1,4 +1,6 @@ -import os +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later from django.core.management.base import BaseCommand @@ -6,12 +8,12 @@ class Command(BaseCommand): - help = 'Add default project related choices' + help = "Add default project related choices" def handle(self, *args, **options): PublicationSource.objects.all().delete() for name, url in [ - ('doi', 'https://doi.org/'), - ('manual', None), - ]: + ("doi", "https://doi.org/"), + ("manual", None), + ]: PublicationSource.objects.get_or_create(name=name, url=url) diff --git a/coldfront/core/publication/migrations/0001_initial.py b/coldfront/core/publication/migrations/0001_initial.py index e13e2b8f1a..a51110ad46 100644 --- a/coldfront/core/publication/migrations/0001_initial.py +++ b/coldfront/core/publication/migrations/0001_initial.py @@ -1,78 +1,155 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('project', '0001_initial'), + ("project", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='PublicationSource', + name="PublicationSource", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=255)), - ('url', models.URLField()), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=255)), + ("url", models.URLField()), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='HistoricalPublication', + name="HistoricalPublication", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=1024)), - ('author', models.CharField(max_length=1024)), - ('year', models.PositiveIntegerField()), - ('unique_id', models.CharField(blank=True, max_length=255, null=True)), - ('status', models.CharField(choices=[('Active', 'Active'), ('Archived', 'Archived')], default='Active', max_length=16)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), - ('source', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='publication.PublicationSource')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=1024)), + ("author", models.CharField(max_length=1024)), + ("year", models.PositiveIntegerField()), + ("unique_id", models.CharField(blank=True, max_length=255, null=True)), + ( + "status", + models.CharField( + choices=[("Active", "Active"), ("Archived", "Archived")], default="Active", max_length=16 + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), + ( + "source", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="publication.PublicationSource", + ), + ), ], options={ - 'verbose_name': 'historical publication', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical publication", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='Publication', + name="Publication", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(max_length=1024)), - ('author', models.CharField(max_length=1024)), - ('year', models.PositiveIntegerField()), - ('unique_id', models.CharField(blank=True, max_length=255, null=True)), - ('status', models.CharField(choices=[('Active', 'Active'), ('Archived', 'Archived')], default='Active', max_length=16)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), - ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='publication.PublicationSource')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(max_length=1024)), + ("author", models.CharField(max_length=1024)), + ("year", models.PositiveIntegerField()), + ("unique_id", models.CharField(blank=True, max_length=255, null=True)), + ( + "status", + models.CharField( + choices=[("Active", "Active"), ("Archived", "Archived")], default="Active", max_length=16 + ), + ), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), + ( + "source", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="publication.PublicationSource"), + ), ], options={ - 'unique_together': {('project', 'unique_id')}, + "unique_together": {("project", "unique_id")}, }, ), ] diff --git a/coldfront/core/publication/migrations/0002_auto_20191223_1115.py b/coldfront/core/publication/migrations/0002_auto_20191223_1115.py index 33833567e1..5ddfa6f927 100644 --- a/coldfront/core/publication/migrations/0002_auto_20191223_1115.py +++ b/coldfront/core/publication/migrations/0002_auto_20191223_1115.py @@ -1,25 +1,28 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2019-12-23 11:15 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('publication', '0001_initial'), + ("publication", "0001_initial"), ] operations = [ migrations.AddField( - model_name='historicalpublication', - name='journal', - field=models.CharField(default='', max_length=1024), + model_name="historicalpublication", + name="journal", + field=models.CharField(default="", max_length=1024), preserve_default=False, ), migrations.AddField( - model_name='publication', - name='journal', - field=models.CharField(default='', max_length=1024), + model_name="publication", + name="journal", + field=models.CharField(default="", max_length=1024), preserve_default=False, ), ] diff --git a/coldfront/core/publication/migrations/0003_auto_20200104_1700.py b/coldfront/core/publication/migrations/0003_auto_20200104_1700.py index d5430d78b0..8c13cd568d 100644 --- a/coldfront/core/publication/migrations/0003_auto_20200104_1700.py +++ b/coldfront/core/publication/migrations/0003_auto_20200104_1700.py @@ -1,23 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2020-01-04 17:00 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('publication', '0002_auto_20191223_1115'), + ("publication", "0002_auto_20191223_1115"), ] operations = [ migrations.AlterField( - model_name='publicationsource', - name='name', + model_name="publicationsource", + name="name", field=models.CharField(max_length=255, unique=True), ), migrations.AlterField( - model_name='publicationsource', - name='url', + model_name="publicationsource", + name="url", field=models.URLField(blank=True, null=True), ), ] diff --git a/coldfront/core/publication/migrations/0004_add_manual_publication_source.py b/coldfront/core/publication/migrations/0004_add_manual_publication_source.py index 2e9d48a5d5..8563366955 100644 --- a/coldfront/core/publication/migrations/0004_add_manual_publication_source.py +++ b/coldfront/core/publication/migrations/0004_add_manual_publication_source.py @@ -1,19 +1,23 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2020-01-04 21:56 -from django.db import migrations, models +from django.db import migrations def add_manual_publication_source(apps, schema_editor): - PublicationSource = apps.get_model('publication', 'PublicationSource') + PublicationSource = apps.get_model("publication", "PublicationSource") for name, url in [ - ('manual', None), - ]: + ("manual", None), + ]: PublicationSource.objects.get_or_create(name=name, url=url) -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('publication', '0003_auto_20200104_1700'), + ("publication", "0003_auto_20200104_1700"), ] operations = [ diff --git a/coldfront/core/publication/migrations/__init__.py b/coldfront/core/publication/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/publication/migrations/__init__.py +++ b/coldfront/core/publication/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/publication/models.py b/coldfront/core/publication/models.py index a034ca8aec..945a376598 100644 --- a/coldfront/core/publication/models.py +++ b/coldfront/core/publication/models.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.db import models from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords @@ -6,8 +10,8 @@ class PublicationSource(TimeStampedModel): - """ A publication source is a source that a publication is cited/ derived from. Examples include doi and adsabs. - + """A publication source is a source that a publication is cited/ derived from. Examples include doi and adsabs. + Attributes: name (str): source name url (URL): links to the url of the source @@ -21,8 +25,8 @@ def __str__(self): class Publication(TimeStampedModel): - """ A publication source is a source that a publication is cited/ derived from. Examples include doi and adsabs. - + """A publication source is a source that a publication is cited/ derived from. Examples include doi and adsabs. + Attributes: project (Project): links the publication to its project title (str): publication title @@ -42,15 +46,14 @@ class Publication(TimeStampedModel): unique_id = models.CharField(max_length=255, null=True, blank=True) source = models.ForeignKey(PublicationSource, on_delete=models.CASCADE) STATUS_CHOICES = ( - ('Active', 'Active'), - ('Archived', 'Archived'), + ("Active", "Active"), + ("Archived", "Archived"), ) - status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='Active') + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="Active") history = HistoricalRecords() - class Meta: - unique_together = ('project', 'unique_id') + unique_together = ("project", "unique_id") def __str__(self): return self.title diff --git a/coldfront/core/publication/tests.py b/coldfront/core/publication/tests.py index cd71f34929..249840082a 100644 --- a/coldfront/core/publication/tests.py +++ b/coldfront/core/publication/tests.py @@ -1,21 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import contextlib import itertools -from unittest.mock import Mock, sentinel, patch +from unittest.mock import Mock, patch, sentinel + import bibtexparser.bibdatabase import bibtexparser.bparser -from django.test import TestCase import doi2bib +from django.test import TestCase +import coldfront.core.publication +from coldfront.core.publication.models import Publication +from coldfront.core.publication.views import PublicationSearchResultView +from coldfront.core.test_helpers.decorators import ( + makes_remote_requests, +) from coldfront.core.test_helpers.factories import ( ProjectFactory, PublicationSourceFactory, ) -from coldfront.core.test_helpers.decorators import ( - makes_remote_requests, -) -from coldfront.core.publication.models import Publication -from coldfront.core.publication.views import PublicationSearchResultView -import coldfront.core.publication class TestPublication(TestCase): @@ -27,27 +32,27 @@ def __init__(self): source = PublicationSourceFactory() self.initial_fields = { - 'project': project, - 'title': 'Test publication!', - 'author': 'coldfront et al.', - 'year': 1, - 'journal': 'Wall of the North', - 'unique_id': '5/10/20', - 'source': source, - 'status': 'Active', + "project": project, + "title": "Test publication!", + "author": "coldfront et al.", + "year": 1, + "journal": "Wall of the North", + "unique_id": "5/10/20", + "source": source, + "status": "Active", } self.unsaved_publication = Publication(**self.initial_fields) self.journals = [ - 'First academic journal of the world', - 'Second academic journal of the world', - 'New age journal', + "First academic journal of the world", + "Second academic journal of the world", + "New age journal", ] def setUp(self): self.data = self.Data() - self.unique_id_generator = ('unique_id_{}'.format(id) for id in itertools.count()) + self.unique_id_generator = ("unique_id_{}".format(id) for id in itertools.count()) def test_fields_generic(self): self.assertEqual(0, len(Publication.objects.all())) @@ -89,12 +94,12 @@ def test_journal_unique_publications(self): for journal in journals: with self.subTest(item=journal): these_fields = fields.copy() - these_fields['journal'] = journal - these_fields['unique_id'] = next(self.unique_id_generator) + these_fields["journal"] = journal + these_fields["unique_id"] = next(self.unique_id_generator) pub, created = Publication.objects.get_or_create(**these_fields) self.assertEqual(True, created) - self.assertEqual(these_fields['journal'], pub.journal) + self.assertEqual(these_fields["journal"], pub.journal) all_pubs = Publication.objects.all() self.assertEqual(len(journals), len(all_pubs)) @@ -105,31 +110,30 @@ class TestDataRetrieval(TestCase): class Data: """Collection of test data, separated for readability""" - NO_JOURNAL_INFO_FROM_DOI = '[no journal info from DOI]' + NO_JOURNAL_INFO_FROM_DOI = "[no journal info from DOI]" def __init__(self): self.expected_pubdata = [ { - 'unique_id': '10.1038/s41524-017-0032-0', - 'title': 'Construction of ground-state preserving sparse lattice models for predictive materials simulations', - 'author': 'Wenxuan Huang and Alexander Urban and Ziqin Rong and Zhiwei Ding and Chuan Luo and Gerbrand Ceder', - 'year': '2017', - 'journal': 'npj Computational Materials', + "unique_id": "10.1038/s41524-017-0032-0", + "title": "Construction of ground-state preserving sparse lattice models for predictive materials simulations", + "author": "Wenxuan Huang and Alexander Urban and Ziqin Rong and Zhiwei Ding and Chuan Luo and Gerbrand Ceder", + "year": "2017", + "journal": "npj Computational Materials", }, { - 'unique_id': '10.1145/2484762.2484798', - 'title': 'The institute for cyber-enabled research', - 'author': 'Dirk Colbry and Bill Punch and Wolfgang Bauer', - 'year': '2013', - 'journal': self.NO_JOURNAL_INFO_FROM_DOI, - + "unique_id": "10.1145/2484762.2484798", + "title": "The institute for cyber-enabled research", + "author": "Dirk Colbry and Bill Punch and Wolfgang Bauer", + "year": "2013", + "journal": self.NO_JOURNAL_INFO_FROM_DOI, }, ] # everything we might test will use this source source = PublicationSourceFactory() for pubdata_dict in self.expected_pubdata: - pubdata_dict['source_pk'] = source.pk + pubdata_dict["source_pk"] = source.pk class Mocks: """Set of mocks for testing, for simplified setup in test cases @@ -148,6 +152,7 @@ def mock_get_bib(unique_id): # ensure specified unique_id is used here if unique_id == self._unique_id: return sentinel.status, sentinel.bib_str + crossref = Mock(spec_set=doi2bib.crossref) crossref.get_bib.side_effect = mock_get_bib @@ -158,11 +163,12 @@ def mock_parse(thing_to_parse): db = bibdatabase_cls() db.entries = [self._bibdatabase_first_entry.copy()] return db + bibtexparser_cls = Mock(spec_set=bibtexparser.bparser.BibTexParser) bibtexparser_cls.return_value.parse.side_effect = mock_parse as_text = Mock(spec_set=bibtexparser.bibdatabase.as_text) - as_text.side_effect = lambda bib_entry: 'as_text({})'.format(bib_entry) + as_text.side_effect = lambda bib_entry: "as_text({})".format(bib_entry) self.crossref = crossref self.bibtexparser_cls = bibtexparser_cls @@ -172,13 +178,13 @@ def mock_parse(thing_to_parse): def patch(self): def dotpath(qualname): module_under_test = coldfront.core.publication.views - return '{}.{}'.format(module_under_test.__name__, qualname) + return "{}.{}".format(module_under_test.__name__, qualname) with contextlib.ExitStack() as stack: patches = [ - patch(dotpath('BibTexParser'), new=self.bibtexparser_cls), - patch(dotpath('crossref'), new=self.crossref), - patch(dotpath('as_text'), new=self.as_text), + patch(dotpath("BibTexParser"), new=self.bibtexparser_cls), + patch(dotpath("crossref"), new=self.crossref), + patch(dotpath("as_text"), new=self.as_text), ] for p in patches: stack.enter_context(p) @@ -201,7 +207,7 @@ def test_doi_retrieval(self): self.assertNotEqual(0, len(expected_pubdata)) # check assumption for pubdata_dict in expected_pubdata: - unique_id = pubdata_dict['unique_id'] + unique_id = pubdata_dict["unique_id"] with self.subTest(unique_id=unique_id): retrieved_data = self.run_target_method(unique_id) self.assertEqual(pubdata_dict, retrieved_data) @@ -211,23 +217,23 @@ def test_doi_extraction(self): testdata = pubdata.copy() # several adjustments required, below # test cases with NO_JOURNAL_INFO_FROM_DOI need more setup, done later in context - is_nojournal_test = testdata['journal'] == self.data.NO_JOURNAL_INFO_FROM_DOI + is_nojournal_test = testdata["journal"] == self.data.NO_JOURNAL_INFO_FROM_DOI # mutate test data so that it's definitely nonrealistic, thus # assuring that we *are* mocking the right stuff - for k in (k for k in testdata if k != 'source_pk'): - testdata[k] += '[not real]' + for k in (k for k in testdata if k != "source_pk"): + testdata[k] += "[not real]" - unique_id = testdata['unique_id'] + unique_id = testdata["unique_id"] # source_pk doesn't pertain to data returned from the remote api mocked_bibdatabase_entry = testdata.copy() - del mocked_bibdatabase_entry['source_pk'] + del mocked_bibdatabase_entry["source_pk"] # for no-journal tests, we emulate not having any 'journal' key in # data returned from remote api if is_nojournal_test: - del mocked_bibdatabase_entry['journal'] + del mocked_bibdatabase_entry["journal"] mocks = self.Mocks(mocked_bibdatabase_entry, unique_id) @@ -236,9 +242,9 @@ def test_doi_extraction(self): mock_as_text = mocks.as_text.side_effect # we expect `as_text` to be run on... - as_text_expected_on = ['author', 'title', 'year'] + as_text_expected_on = ["author", "title", "year"] if not is_nojournal_test: - as_text_expected_on.append('journal') + as_text_expected_on.append("journal") for key in as_text_expected_on: transformed = mock_as_text(expected_data[key]) @@ -251,7 +257,7 @@ def test_doi_extraction(self): # for no-journal tests, we expect a special string if is_nojournal_test: - expected_data['journal'] = self.data.NO_JOURNAL_INFO_FROM_DOI + expected_data["journal"] = self.data.NO_JOURNAL_INFO_FROM_DOI # finally done with setup... now to run the test... with self.subTest(unique_id=unique_id): diff --git a/coldfront/core/publication/urls.py b/coldfront/core/publication/urls.py index 8da5660175..d80aa78807 100644 --- a/coldfront/core/publication/urls.py +++ b/coldfront/core/publication/urls.py @@ -1,12 +1,36 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.publication.views as publication_views urlpatterns = [ - path('publication-search//', publication_views.PublicationSearchView.as_view(), name='publication-search'), - path('publication-search-result//', publication_views.PublicationSearchResultView.as_view(), name='publication-search-result'), - path('add-publication//', publication_views.PublicationAddView.as_view(), name='add-publication'), - path('add-publication-manually//', publication_views.PublicationAddManuallyView.as_view(), name='add-publication-manually'), - path('project//delete-publications/', publication_views.PublicationDeletePublicationsView.as_view(), name='publication-delete-publications'), - path('project//export-publications/', publication_views.PublicationExportPublicationsView.as_view(), name='publication-export-publications'), + path( + "publication-search//", + publication_views.PublicationSearchView.as_view(), + name="publication-search", + ), + path( + "publication-search-result//", + publication_views.PublicationSearchResultView.as_view(), + name="publication-search-result", + ), + path("add-publication//", publication_views.PublicationAddView.as_view(), name="add-publication"), + path( + "add-publication-manually//", + publication_views.PublicationAddManuallyView.as_view(), + name="add-publication-manually", + ), + path( + "project//delete-publications/", + publication_views.PublicationDeletePublicationsView.as_view(), + name="publication-delete-publications", + ), + path( + "project//export-publications/", + publication_views.PublicationExportPublicationsView.as_view(), + name="publication-export-publications", + ), ] diff --git a/coldfront/core/publication/views.py b/coldfront/core/publication/views.py index 4d9dd5cf4b..6cb8f36cfc 100644 --- a/coldfront/core/publication/views.py +++ b/coldfront/core/publication/views.py @@ -1,104 +1,107 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import ast +import io import re import uuid + import requests -import os -import io -from io import StringIO from bibtexparser.bibdatabase import as_text from bibtexparser.bparser import BibTexParser from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.db import IntegrityError from django.forms import formset_factory -from django.http import HttpResponseRedirect, HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse -from django.views.generic import DetailView, ListView, TemplateView, View +from django.views.generic import TemplateView, View from django.views.generic.edit import FormView -from django.views.static import serve +from doi2bib import crossref from coldfront.core.project.models import Project from coldfront.core.publication.forms import ( PublicationAddForm, PublicationDeleteForm, + PublicationExportForm, PublicationResultForm, PublicationSearchForm, - PublicationExportForm, ) from coldfront.core.publication.models import Publication, PublicationSource -from doi2bib import crossref - -MANUAL_SOURCE = 'manual' +MANUAL_SOURCE = "manual" class PublicationSearchView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'publication/publication_add_publication_search.html' + template_name = "publication/publication_add_publication_search.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add publications to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add publications to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['publication_search_form'] = PublicationSearchForm() - context['project'] = Project.objects.get( - pk=self.kwargs.get('project_pk')) + context["publication_search_form"] = PublicationSearchForm() + context["project"] = Project.objects.get(pk=self.kwargs.get("project_pk")) return context class PublicationSearchResultView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'publication/publication_add_publication_search_result.html' + template_name = "publication/publication_add_publication_search_result.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add publications to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'project_pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add publications to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"project_pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def _search_id(self, unique_id): matching_source_obj = None for source in PublicationSource.objects.all(): - if source.name == 'doi': + if source.name == "doi": try: status, bib_str = crossref.get_bib(unique_id) bp = BibTexParser(interpolate_strings=False) @@ -106,57 +109,79 @@ def _search_id(self, unique_id): bib_json = bib_database.entries[0] matching_source_obj = source break - except: + except Exception: continue - elif source.name == 'adsabs': + elif source.name == "adsabs": try: - url = 'http://adsabs.harvard.edu/cgi-bin/nph-bib_query?bibcode={}&data_type=BIBTEX'.format( - unique_id) + url = "http://adsabs.harvard.edu/cgi-bin/nph-bib_query?bibcode={}&data_type=BIBTEX".format( + unique_id + ) r = requests.get(url, timeout=5) bp = BibTexParser(interpolate_strings=False) bib_database = bp.parse(r.text) bib_json = bib_database.entries[0] matching_source_obj = source break - except: + except Exception: continue if not matching_source_obj: return False - year = as_text(bib_json['year']) - author = as_text(bib_json['author']).replace('{\\textquotesingle}', "'").replace('{\\textendash}', '-').replace( - '{\\textemdash}', '-').replace('{\\textasciigrave}', ' ').replace('{\\textdaggerdbl}', ' ').replace('{\\textdagger}', ' ') - title = as_text(bib_json['title']).replace('{\\textquotesingle}', "'").replace('{\\textendash}', '-').replace( - '{\\textemdash}', '-').replace('{\\textasciigrave}', ' ').replace('{\\textdaggerdbl}', ' ').replace('{\\textdagger}', ' ') + year = as_text(bib_json["year"]) + author = ( + as_text(bib_json["author"]) + .replace("{\\textquotesingle}", "'") + .replace("{\\textendash}", "-") + .replace("{\\textemdash}", "-") + .replace("{\\textasciigrave}", " ") + .replace("{\\textdaggerdbl}", " ") + .replace("{\\textdagger}", " ") + ) + title = ( + as_text(bib_json["title"]) + .replace("{\\textquotesingle}", "'") + .replace("{\\textendash}", "-") + .replace("{\\textemdash}", "-") + .replace("{\\textasciigrave}", " ") + .replace("{\\textdaggerdbl}", " ") + .replace("{\\textdagger}", " ") + ) author = re.sub("{|}", "", author) title = re.sub("{|}", "", title) # not all bibtex entries will have a journal field - if 'journal' in bib_json: - journal = as_text(bib_json['journal']).replace('{\\textquotesingle}', "'").replace('{\\textendash}', '-').replace( - '{\\textemdash}', '-').replace('{\\textasciigrave}', ' ').replace('{\\textdaggerdbl}', ' ').replace('{\\textdagger}', ' ') + if "journal" in bib_json: + journal = ( + as_text(bib_json["journal"]) + .replace("{\\textquotesingle}", "'") + .replace("{\\textendash}", "-") + .replace("{\\textemdash}", "-") + .replace("{\\textasciigrave}", " ") + .replace("{\\textdaggerdbl}", " ") + .replace("{\\textdagger}", " ") + ) journal = re.sub("{|}", "", journal) else: # fallback: clearly indicate that data was absent source_name = matching_source_obj.name - journal = '[no journal info from {}]'.format(source_name.upper()) + journal = "[no journal info from {}]".format(source_name.upper()) pub_dict = {} - pub_dict['author'] = author - pub_dict['year'] = year - pub_dict['title'] = title - pub_dict['journal'] = journal - pub_dict['unique_id'] = unique_id - pub_dict['source_pk'] = matching_source_obj.pk + pub_dict["author"] = author + pub_dict["year"] = year + pub_dict["title"] = title + pub_dict["journal"] = journal + pub_dict["unique_id"] = unique_id + pub_dict["source_pk"] = matching_source_obj.pk return pub_dict def post(self, request, *args, **kwargs): - search_ids = list(set(request.POST.get('search_id').split())) - project_pk = self.kwargs.get('project_pk') + search_ids = list(set(request.POST.get("search_id").split())) + project_pk = self.kwargs.get("project_pk") project_obj = get_object_or_404(Project, pk=project_pk) pubs = [] @@ -166,134 +191,143 @@ def post(self, request, *args, **kwargs): pubs.append(pub_dict) formset = formset_factory(PublicationResultForm, max_num=len(pubs)) - formset = formset(initial=pubs, prefix='pubform') + formset = formset(initial=pubs, prefix="pubform") context = {} - context['project_pk'] = project_obj.pk - context['formset'] = formset - context['search_ids'] = search_ids - context['pubs'] = pubs + context["project_pk"] = project_obj.pk + context["formset"] = formset + context["search_ids"] = search_ids + context["pubs"] = pubs return render(request, self.template_name, context) class PublicationAddView(LoginRequiredMixin, UserPassesTestMixin, View): - def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot add publications to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add publications to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - pubs = ast.literal_eval(request.POST.get('pubs')) - project_pk = self.kwargs.get('project_pk') + pubs = ast.literal_eval(request.POST.get("pubs")) + project_pk = self.kwargs.get("project_pk") project_obj = get_object_or_404(Project, pk=project_pk) formset = formset_factory(PublicationResultForm, max_num=len(pubs)) - formset = formset(request.POST, initial=pubs, prefix='pubform') + formset = formset(request.POST, initial=pubs, prefix="pubform") publications_added = 0 publications_skipped = [] if formset.is_valid(): for form in formset: form_data = form.cleaned_data - - if form_data['selected']: - source_obj = PublicationSource.objects.get( - pk=form_data.get('source_pk')) - author = form_data.get('author') - if len(author) > 1024: author = author[:1024] + + if form_data["selected"]: + source_obj = PublicationSource.objects.get(pk=form_data.get("source_pk")) + author = form_data.get("author") + if len(author) > 1024: + author = author[:1024] publication_obj, created = Publication.objects.get_or_create( project=project_obj, - unique_id=form_data.get('unique_id'), - defaults = { - 'title':form_data.get('title'), - 'author':author, - 'year':form_data.get('year'), - 'journal':form_data.get('journal'), - 'source':source_obj - } + unique_id=form_data.get("unique_id"), + defaults={ + "title": form_data.get("title"), + "author": author, + "year": form_data.get("year"), + "journal": form_data.get("journal"), + "source": source_obj, + }, ) if created: publications_added += 1 else: - publications_skipped.append(form_data.get('unique_id')) + publications_skipped.append(form_data.get("unique_id")) - msg = '' + msg = "" if publications_added: - msg += 'Added {} publication{} to project.'.format( - publications_added, 's' if publications_added > 1 else '') + msg += "Added {} publication{} to project.".format( + publications_added, "s" if publications_added > 1 else "" + ) if publications_skipped: - msg += 'Publication already exists on this project. Skipped adding: {}'.format( - ', '.join(publications_skipped)) + msg += "Publication already exists on this project. Skipped adding: {}".format( + ", ".join(publications_skipped) + ) messages.success(request, msg) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_pk})) + class PublicationAddManuallyView(LoginRequiredMixin, UserPassesTestMixin, FormView): form_class = PublicationAddForm - template_name = 'publication/publication_add_publication_manually.html' + template_name = "publication/publication_add_publication_manually.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error(self.request, 'You do not have permission to add a new publication to this project.') + messages.error(self.request, "You do not have permission to add a new publication to this project.") def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error(request, 'You cannot add publications to an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot add publications to an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) def get_initial(self): initial = super().get_initial() - initial['source'] = MANUAL_SOURCE + initial["source"] = MANUAL_SOURCE return initial def form_valid(self, form): form_data = form.cleaned_data - project_obj = get_object_or_404(Project, pk=self.kwargs.get('project_pk')) - pub_obj = Publication.objects.create( + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + Publication.objects.create( project=project_obj, - title=form_data.get('title'), - author=form_data.get('author'), - year=form_data.get('year'), - journal=form_data.get('journal'), + title=form_data.get("title"), + author=form_data.get("author"), + year=form_data.get("year"), + journal=form_data.get("journal"), unique_id=uuid.uuid4(), source=PublicationSource.objects.get(name=MANUAL_SOURCE), ) @@ -302,181 +336,162 @@ def form_valid(self, form): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['project'] = Project.objects.get(pk=self.kwargs.get('project_pk')) + context["project"] = Project.objects.get(pk=self.kwargs.get("project_pk")) return context def get_success_url(self): - messages.success(self.request, 'Added a publication.') - return reverse('project-detail', kwargs={'pk': self.kwargs.get('project_pk')}) + messages.success(self.request, "Added a publication.") + return reverse("project-detail", kwargs={"pk": self.kwargs.get("project_pk")}) class PublicationDeletePublicationsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'publication/publication_delete_publications.html' + template_name = "publication/publication_delete_publications.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permission to delete publications from this project.') + messages.error(self.request, "You do not have permission to delete publications from this project.") def get_publications_to_delete(self, project_obj): - publications_do_delete = [ - {'title': publication.title, - 'year': publication.year} - for publication in project_obj.publication_set.all().order_by('-year') + {"title": publication.title, "year": publication.year} + for publication in project_obj.publication_set.all().order_by("-year") ] return publications_do_delete def get(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) publications_do_delete = self.get_publications_to_delete(project_obj) context = {} if publications_do_delete: - formset = formset_factory( - PublicationDeleteForm, max_num=len(publications_do_delete)) - formset = formset(initial=publications_do_delete, - prefix='publicationform') - context['formset'] = formset + formset = formset_factory(PublicationDeleteForm, max_num=len(publications_do_delete)) + formset = formset(initial=publications_do_delete, prefix="publicationform") + context["formset"] = formset - context['project'] = project_obj + context["project"] = project_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) publications_do_delete = self.get_publications_to_delete(project_obj) - context = {} - formset = formset_factory( - PublicationDeleteForm, max_num=len(publications_do_delete)) - formset = formset( - request.POST, initial=publications_do_delete, prefix='publicationform') + formset = formset_factory(PublicationDeleteForm, max_num=len(publications_do_delete)) + formset = formset(request.POST, initial=publications_do_delete, prefix="publicationform") publications_deleted_count = 0 if formset.is_valid(): for form in formset: publication_form_data = form.cleaned_data - if publication_form_data['selected']: - + if publication_form_data["selected"]: publication_obj = Publication.objects.get( project=project_obj, - title=publication_form_data.get('title'), - year=publication_form_data.get('year') + title=publication_form_data.get("title"), + year=publication_form_data.get("year"), ) publication_obj.delete() publications_deleted_count += 1 - messages.success(request, 'Deleted {} publications from project.'.format( - publications_deleted_count)) + messages.success(request, "Deleted {} publications from project.".format(publications_deleted_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project.id}) + return reverse("project-detail", kwargs={"pk": self.object.project.id}) class PublicationExportPublicationsView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'publication/publication_export_publications.html' + template_name = "publication/publication_export_publications.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True - messages.error( - self.request, 'You do not have permission to delete publications from this project.') + messages.error(self.request, "You do not have permission to delete publications from this project.") def get_publications_to_export(self, project_obj): - publications_do_delete = [ - {'title': publication.title, - 'year': publication.year, - 'unique_id': publication.unique_id, } - for publication in project_obj.publication_set.all().order_by('-year') + { + "title": publication.title, + "year": publication.year, + "unique_id": publication.unique_id, + } + for publication in project_obj.publication_set.all().order_by("-year") ] return publications_do_delete def get(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) publications_do_export = self.get_publications_to_export(project_obj) context = {} if publications_do_export: - formset = formset_factory( - PublicationExportForm, max_num=len(publications_do_export)) - formset = formset(initial=publications_do_export, - prefix='publicationform') - context['formset'] = formset + formset = formset_factory(PublicationExportForm, max_num=len(publications_do_export)) + formset = formset(initial=publications_do_export, prefix="publicationform") + context["formset"] = formset - context['project'] = project_obj + context["project"] = project_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) publications_do_export = self.get_publications_to_export(project_obj) - context = {} - formset = formset_factory( - PublicationExportForm, max_num=len(publications_do_export)) - formset = formset( - request.POST, initial=publications_do_export, prefix='publicationform') + formset = formset_factory(PublicationExportForm, max_num=len(publications_do_export)) + formset = formset(request.POST, initial=publications_do_export, prefix="publicationform") - publications_deleted_count = 0 - bib_text = '' + bib_text = "" if formset.is_valid(): for form in formset: publication_form_data = form.cleaned_data - if publication_form_data['selected']: - + if publication_form_data["selected"]: publication_obj = Publication.objects.get( project=project_obj, - title=publication_form_data.get('title'), - year=publication_form_data.get('year'), - unique_id=publication_form_data.get('unique_id'), + title=publication_form_data.get("title"), + year=publication_form_data.get("year"), + unique_id=publication_form_data.get("unique_id"), ) - print("id is"+publication_obj.display_uid()) - temp_id = publication_obj.display_uid() + print("id is" + publication_obj.display_uid()) + publication_obj.display_uid() status, bib_str = crossref.get_bib(publication_obj.display_uid()) bp = BibTexParser(interpolate_strings=False) - bib_database = bp.parse(bib_str) + bp.parse(bib_str) bib_text += bib_str - response = HttpResponse(content_type='text/plain') - response['Content-Disposition'] = 'attachment; filename=refs.bib' + response = HttpResponse(content_type="text/plain") + response["Content-Disposition"] = "attachment; filename=refs.bib" buffer = io.StringIO() buffer.write(bib_text) output = buffer.getvalue() @@ -487,7 +502,7 @@ def post(self, request, *args, **kwargs): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.object.project.id}) + return reverse("project-detail", kwargs={"pk": self.object.project.id}) diff --git a/coldfront/core/research_output/__init__.py b/coldfront/core/research_output/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/research_output/__init__.py +++ b/coldfront/core/research_output/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/research_output/admin.py b/coldfront/core/research_output/admin.py index 18198b9736..bf5196d66a 100644 --- a/coldfront/core/research_output/admin.py +++ b/coldfront/core/research_output/admin.py @@ -1,32 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin from coldfront.core.research_output.models import ResearchOutput - -_research_output_fields_for_end = ['created_by', 'project', 'created', 'modified'] +_research_output_fields_for_end = ["created_by", "project", "created", "modified"] @admin.register(ResearchOutput) class ResearchOutputAdmin(SimpleHistoryAdmin): list_display = [ - field.name for field in ResearchOutput._meta.get_fields() - if field.name not in _research_output_fields_for_end + field.name for field in ResearchOutput._meta.get_fields() if field.name not in _research_output_fields_for_end ] + _research_output_fields_for_end list_filter = ( - 'project', - 'created_by', + "project", + "created_by", ) ordering = ( - 'project', - '-created', + "project", + "-created", ) # display the noneditable fields on the "change" form - readonly_fields = [ - field.name for field in ResearchOutput._meta.get_fields() - if not field.editable - ] + readonly_fields = [field.name for field in ResearchOutput._meta.get_fields() if not field.editable] # the view implements some Add logic that we need not replicate here # to simplify: remove ability to add via admin interface diff --git a/coldfront/core/research_output/apps.py b/coldfront/core/research_output/apps.py index 2b02d013a7..75d0e8094d 100644 --- a/coldfront/core/research_output/apps.py +++ b/coldfront/core/research_output/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class ResearchOutputConfig(AppConfig): - name = 'coldfront.core.research_output' + name = "coldfront.core.research_output" diff --git a/coldfront/core/research_output/forms.py b/coldfront/core/research_output/forms.py index ca7d166658..dde2d62892 100644 --- a/coldfront/core/research_output/forms.py +++ b/coldfront/core/research_output/forms.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.forms import ModelForm from coldfront.core.research_output.models import ResearchOutput @@ -6,4 +10,6 @@ class ResearchOutputForm(ModelForm): class Meta: model = ResearchOutput - exclude = ['project', ] + exclude = [ + "project", + ] diff --git a/coldfront/core/research_output/migrations/0001_initial.py b/coldfront/core/research_output/migrations/0001_initial.py index 84e0822a60..294881725e 100644 --- a/coldfront/core/research_output/migrations/0001_initial.py +++ b/coldfront/core/research_output/migrations/0001_initial.py @@ -1,56 +1,109 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2020-02-10 03:30 -from django.conf import settings import django.core.validators -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('project', '0001_initial'), + ("project", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='ResearchOutput', + name="ResearchOutput", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(blank=True, max_length=128)), - ('description', models.TextField(validators=[django.core.validators.MinLengthValidator(3)])), - ('created', models.DateTimeField(auto_now_add=True)), - ('created_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='project.Project')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(blank=True, max_length=128)), + ("description", models.TextField(validators=[django.core.validators.MinLengthValidator(3)])), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ("project", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="project.Project")), ], ), migrations.CreateModel( - name='HistoricalResearchOutput', + name="HistoricalResearchOutput", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('title', models.CharField(blank=True, max_length=128)), - ('description', models.TextField(validators=[django.core.validators.MinLengthValidator(3)])), - ('created', models.DateTimeField(blank=True, editable=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('created_by', models.ForeignKey(blank=True, db_constraint=False, editable=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('project', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='project.Project')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("title", models.CharField(blank=True, max_length=128)), + ("description", models.TextField(validators=[django.core.validators.MinLengthValidator(3)])), + ("created", models.DateTimeField(blank=True, editable=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + editable=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="project.Project", + ), + ), ], options={ - 'verbose_name': 'historical research output', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical research output", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), diff --git a/coldfront/core/research_output/migrations/__init__.py b/coldfront/core/research_output/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/research_output/migrations/__init__.py +++ b/coldfront/core/research_output/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/research_output/models.py b/coldfront/core/research_output/models.py index 92977c5984..ddbfdc765a 100644 --- a/coldfront/core/research_output/models.py +++ b/coldfront/core/research_output/models.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.contrib.auth.models import User from django.core.validators import MinLengthValidator from django.db import models -from django.contrib.auth.models import User from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords @@ -8,8 +12,8 @@ class ResearchOutput(TimeStampedModel): - """ A research output represents anything related a project that would not fall under the publication section. Examples include magazine or newspaper articles, media coverage, databases, software, or other products created. - + """A research output represents anything related a project that would not fall under the publication section. Examples include magazine or newspaper articles, media coverage, databases, software, or other products created. + Attributes: project (Project): links project to research output title (str): title of research output @@ -37,7 +41,7 @@ class ResearchOutput(TimeStampedModel): history = HistoricalRecords() def save(self, *args, **kwargs): - """ Saves the research output. """ + """Saves the research output.""" if not self.pk: # ensure that created_by is set initially - preventing most # accidental omission @@ -46,7 +50,7 @@ def save(self, *args, **kwargs): # populated by the code that adds the ResearchOutput to the # database if not self.created_by: - raise ValueError('Model INSERT must set a created_by User') + raise ValueError("Model INSERT must set a created_by User") # since title is optional, we want to simplify and standardize "no title" entries # we do this at the model layer to ensure as consistent behavior as possible diff --git a/coldfront/core/research_output/tests.py b/coldfront/core/research_output/tests.py index 9e98d62024..1e2f2af4bf 100644 --- a/coldfront/core/research_output/tests.py +++ b/coldfront/core/research_output/tests.py @@ -1,13 +1,17 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime + from django.core.exceptions import ValidationError -from django.db import IntegrityError from django.test import TestCase +from coldfront.core.research_output.models import ResearchOutput from coldfront.core.test_helpers.factories import ( ProjectFactory, UserFactory, ) -from coldfront.core.research_output.models import ResearchOutput class TestResearchOutput(TestCase): @@ -16,13 +20,13 @@ class Data: def __init__(self): project = ProjectFactory() - user = UserFactory(username='submitter') + user = UserFactory(username="submitter") self.initial_fields = { - 'project': project, - 'title': 'Something we made!', - 'description': 'something, really', - 'created_by': user, + "project": project, + "title": "Something we made!", + "description": "something, really", + "created_by": user, } self.unsaved_object = ResearchOutput(**self.initial_fields) @@ -50,25 +54,25 @@ def test_title_optional(self): self.assertEqual(0, len(ResearchOutput.objects.all())) research_output_obj = self.data.unsaved_object - research_output_obj.title = '' + research_output_obj.title = "" research_output_obj.save() self.assertEqual(1, len(ResearchOutput.objects.all())) retrieved_obj = ResearchOutput.objects.get(pk=research_output_obj.pk) - self.assertEqual('', retrieved_obj.title) + self.assertEqual("", retrieved_obj.title) def test_empty_title_sanitized(self): research_output_obj = self.data.unsaved_object - research_output_obj.title = ' \t\n ' + research_output_obj.title = " \t\n " research_output_obj.save() retrieved_obj = ResearchOutput.objects.get(pk=research_output_obj.pk) - self.assertEqual('', retrieved_obj.title) + self.assertEqual("", retrieved_obj.title) def test_description_minlength(self): expected_minimum_length = 3 - minimum_description = 'x' * expected_minimum_length + minimum_description = "x" * expected_minimum_length research_output_obj = self.data.unsaved_object @@ -110,7 +114,7 @@ def test_created_by_foreignkey_on_delete(self): try: retrieved_obj = ResearchOutput.objects.get(pk=research_output_obj.pk) except ResearchOutput.DoesNotExist as e: - raise self.failureException('Expected no cascade from user deletion') from e + raise self.failureException("Expected no cascade from user deletion") from e # if here, did not cascade self.assertIsNone(retrieved_obj.created_by) # null, as expected from SET_NULL diff --git a/coldfront/core/research_output/urls.py b/coldfront/core/research_output/urls.py index db78e0870e..c3236dbb83 100644 --- a/coldfront/core/research_output/urls.py +++ b/coldfront/core/research_output/urls.py @@ -1,8 +1,20 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.research_output.views as research_output_views urlpatterns = [ - path('add-research-output//', research_output_views.ResearchOutputCreateView.as_view(), name='add-research-output'), - path('project//delete-research-outputs', research_output_views.ResearchOutputDeleteResearchOutputsView.as_view(), name='research-output-delete-research-outputs'), + path( + "add-research-output//", + research_output_views.ResearchOutputCreateView.as_view(), + name="add-research-output", + ), + path( + "project//delete-research-outputs", + research_output_views.ResearchOutputDeleteResearchOutputsView.as_view(), + name="research-output-delete-research-outputs", + ), ] diff --git a/coldfront/core/research_output/views.py b/coldfront/core/research_output/views.py index dd421afe77..2c7df3cbab 100644 --- a/coldfront/core/research_output/views.py +++ b/coldfront/core/research_output/views.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin from django.core.exceptions import PermissionDenied @@ -10,32 +14,31 @@ from coldfront.core.research_output.forms import ResearchOutputForm from coldfront.core.research_output.models import ResearchOutput from coldfront.core.utils.mixins.views import ( - UserActiveManagerOrHigherMixin, ChangesOnlyOnActiveProjectMixin, ProjectInContextMixin, SnakeCaseTemplateNameMixin, + UserActiveManagerOrHigherMixin, ) class ResearchOutputCreateView( - UserActiveManagerOrHigherMixin, - ChangesOnlyOnActiveProjectMixin, - SuccessMessageMixin, - SnakeCaseTemplateNameMixin, - ProjectInContextMixin, - CreateView): - + UserActiveManagerOrHigherMixin, + ChangesOnlyOnActiveProjectMixin, + SuccessMessageMixin, + SnakeCaseTemplateNameMixin, + ProjectInContextMixin, + CreateView, +): # directly using the exclude option isn't possible with CreateView; use such a form instead form_class = ResearchOutputForm model = ResearchOutput - template_name_suffix = '_create' + template_name_suffix = "_create" - success_message = 'Research Output added successfully.' + success_message = "Research Output added successfully." def form_valid(self, form): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) obj = form.save(commit=False) obj.created_by = self.request.user @@ -46,42 +49,40 @@ def form_valid(self, form): return super().form_valid(form) def get_success_url(self): - return reverse('project-detail', kwargs={'pk': self.kwargs.get('project_pk')}) + return reverse("project-detail", kwargs={"pk": self.kwargs.get("project_pk")}) class ResearchOutputDeleteResearchOutputsView( - UserActiveManagerOrHigherMixin, - ChangesOnlyOnActiveProjectMixin, - SnakeCaseTemplateNameMixin, - ProjectInContextMixin, - ListView): - + UserActiveManagerOrHigherMixin, + ChangesOnlyOnActiveProjectMixin, + SnakeCaseTemplateNameMixin, + ProjectInContextMixin, + ListView, +): model = ResearchOutput # only included to utilize SnakeCaseTemplateNameMixin - template_name_suffix = '_delete_research_outputs' + template_name_suffix = "_delete_research_outputs" def get_queryset(self): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) - return ResearchOutput.objects.filter(project=project_obj).order_by('-created') + return ResearchOutput.objects.filter(project=project_obj).order_by("-created") def post(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) def get_normalized_posted_pks(): posted_pks = set(request.POST.keys()) - posted_pks.remove('csrfmiddlewaretoken') + posted_pks.remove("csrfmiddlewaretoken") return {int(x) for x in posted_pks} project_research_outputs = self.get_queryset() - project_research_output_pks = set(project_research_outputs.values_list('pk', flat=True)) + project_research_output_pks = set(project_research_outputs.values_list("pk", flat=True)) posted_research_output_pks = get_normalized_posted_pks() # make sure we're told to delete something, else error to same page if not posted_research_output_pks: - messages.error(request, 'Please select some research outputs to delete, or go back to project.') + messages.error(request, "Please select some research outputs to delete, or go back to project.") return HttpResponseRedirect(request.path_info) # make sure the user plays nice @@ -90,10 +91,10 @@ def get_normalized_posted_pks(): num_deletions, _ = project_research_outputs.filter(pk__in=posted_research_output_pks).delete() - msg = 'Deleted {} research output{} from project.'.format( + msg = "Deleted {} research output{} from project.".format( num_deletions, - '' if num_deletions == 1 else 's', + "" if num_deletions == 1 else "s", ) messages.success(request, msg) - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) diff --git a/coldfront/core/resource/__init__.py b/coldfront/core/resource/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/resource/__init__.py +++ b/coldfront/core/resource/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/resource/admin.py b/coldfront/core/resource/admin.py index 11a1f3cf88..34c40c2a6d 100644 --- a/coldfront/core/resource/admin.py +++ b/coldfront/core/resource/admin.py @@ -1,29 +1,61 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin -from coldfront.core.resource.models import (AttributeType, Resource, - ResourceAttribute, - ResourceAttributeType, - ResourceType) +from coldfront.core.resource.models import ( + AttributeType, + Resource, + ResourceAttribute, + ResourceAttributeType, + ResourceType, +) @admin.register(AttributeType) class AttributeTypeAdmin(admin.ModelAdmin): - list_display = ('name', 'created', 'modified', ) - search_fields = ('name', ) + list_display = ( + "name", + "created", + "modified", + ) + search_fields = ("name",) @admin.register(ResourceType) class ResourceTypeAdmin(admin.ModelAdmin): - list_display = ('name', 'description', 'created', 'modified', ) - search_fields = ('name', 'description',) + list_display = ( + "name", + "description", + "created", + "modified", + ) + search_fields = ( + "name", + "description", + ) @admin.register(ResourceAttributeType) class ResourceAttributeTypeAdmin(SimpleHistoryAdmin): - list_display = ('pk', 'name', 'attribute_type_name', 'is_required', 'is_unique_per_resource', 'is_value_unique', 'created', 'modified', ) - search_fields = ('name', 'attribute_type__name', 'resource_type__name',) - list_filter = ('attribute_type__name', 'name', 'is_required', 'is_unique_per_resource', 'is_value_unique') + list_display = ( + "pk", + "name", + "attribute_type_name", + "is_required", + "is_unique_per_resource", + "is_value_unique", + "created", + "modified", + ) + search_fields = ( + "name", + "attribute_type__name", + "resource_type__name", + ) + list_filter = ("attribute_type__name", "name", "is_required", "is_unique_per_resource", "is_value_unique") def attribute_type_name(self, obj): return obj.attribute_type.name @@ -31,7 +63,10 @@ def attribute_type_name(self, obj): class ResourceAttributeInline(admin.TabularInline): model = ResourceAttribute - fields_change = ('resource_attribute_type', 'value',) + fields_change = ( + "resource_attribute_type", + "value", + ) extra = 0 def get_fields(self, request, obj): @@ -41,18 +76,44 @@ def get_fields(self, request, obj): return self.fields_change - @admin.register(Resource) class ResourceAdmin(SimpleHistoryAdmin): # readonly_fields_change = ('resource_type', ) - fields_change = ('resource_type', 'parent_resource', 'is_allocatable', 'name', 'description', 'is_available', - 'is_public', 'requires_payment', 'allowed_groups', 'allowed_users', 'linked_resources') - list_display = ('pk', 'name', 'description', 'parent_resource', 'is_allocatable', 'resource_type_name', - 'is_available', 'is_public', 'created', 'modified', ) - search_fields = ('name', 'description', 'resource_type__name') - list_filter = ('resource_type__name', 'is_allocatable', 'is_available', 'is_public', 'requires_payment' ) - inlines = [ResourceAttributeInline, ] - filter_horizontal = ['allowed_groups', 'allowed_users', 'linked_resources', ] + fields_change = ( + "resource_type", + "parent_resource", + "is_allocatable", + "name", + "description", + "is_available", + "is_public", + "requires_payment", + "allowed_groups", + "allowed_users", + "linked_resources", + ) + list_display = ( + "pk", + "name", + "description", + "parent_resource", + "is_allocatable", + "resource_type_name", + "is_available", + "is_public", + "created", + "modified", + ) + search_fields = ("name", "description", "resource_type__name") + list_filter = ("resource_type__name", "is_allocatable", "is_available", "is_public", "requires_payment") + inlines = [ + ResourceAttributeInline, + ] + filter_horizontal = [ + "allowed_groups", + "allowed_users", + "linked_resources", + ] save_as = True def resource_type_name(self, obj): @@ -67,9 +128,16 @@ def get_fields(self, request, obj): @admin.register(ResourceAttribute) class ResourceAttributeAdmin(SimpleHistoryAdmin): - list_display = ('pk', 'resource_name', 'value', 'resource_attribute_type_name', 'created', 'modified', ) - search_fields = ('resource__name', 'resource_attribute_type__name', 'value') - list_filter = ('resource_attribute_type__name', ) + list_display = ( + "pk", + "resource_name", + "value", + "resource_attribute_type_name", + "created", + "modified", + ) + search_fields = ("resource__name", "resource_attribute_type__name", "value") + list_filter = ("resource_attribute_type__name",) def resource_name(self, obj): return obj.resource.name diff --git a/coldfront/core/resource/apps.py b/coldfront/core/resource/apps.py index 5c46681e00..e5dddec9bd 100644 --- a/coldfront/core/resource/apps.py +++ b/coldfront/core/resource/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class ResourceConfig(AppConfig): - name = 'coldfront.core.resource' + name = "coldfront.core.resource" diff --git a/coldfront/core/resource/forms.py b/coldfront/core/resource/forms.py index c8366b7d96..6a711f35b0 100644 --- a/coldfront/core/resource/forms.py +++ b/coldfront/core/resource/forms.py @@ -1,33 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms +from django.db.models.functions import Lower from coldfront.core.resource.models import ResourceAttribute -from django.db.models.functions import Lower + class ResourceSearchForm(forms.Form): - """ Search form for the Resource list page. - """ - model = forms.CharField( - label='Model', max_length=100, required=False) - serialNumber = forms.CharField( - label='Serial Number', max_length=100, required=False) - vendor = forms.CharField( - label='Vendor', max_length=100, required=False) + """Search form for the Resource list page.""" + + model = forms.CharField(label="Model", max_length=100, required=False) + serialNumber = forms.CharField(label="Serial Number", max_length=100, required=False) + vendor = forms.CharField(label="Vendor", max_length=100, required=False) installDate = forms.DateField( - label='Install Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + label="Install Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) serviceStart = forms.DateField( - label='Service Start', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) - serviceEnd = forms.DateField( - label='Service End', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + label="Service Start", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) + serviceEnd = forms.DateField( + label="Service End", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) warrantyExpirationDate = forms.DateField( - label='Warranty Expiration Date', - widget=forms.DateInput(attrs={'class': 'datepicker'}), - required=False) + label="Warranty Expiration Date", widget=forms.DateInput(attrs={"class": "datepicker"}), required=False + ) show_allocatable_resources = forms.BooleanField(initial=False, required=False) @@ -39,13 +37,16 @@ class ResourceAttributeDeleteForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['pk'].widget = forms.HiddenInput() + self.fields["pk"].widget = forms.HiddenInput() class ResourceAttributeCreateForm(forms.ModelForm): class Meta: model = ResourceAttribute - fields = '__all__' + fields = "__all__" + def __init__(self, *args, **kwargs): - super(ResourceAttributeCreateForm, self).__init__(*args, **kwargs) - self.fields['resource_attribute_type'].queryset = self.fields['resource_attribute_type'].queryset.order_by(Lower('name')) \ No newline at end of file + super(ResourceAttributeCreateForm, self).__init__(*args, **kwargs) + self.fields["resource_attribute_type"].queryset = self.fields["resource_attribute_type"].queryset.order_by( + Lower("name") + ) diff --git a/coldfront/core/resource/management/__init__.py b/coldfront/core/resource/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/resource/management/__init__.py +++ b/coldfront/core/resource/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/resource/management/commands/__init__.py b/coldfront/core/resource/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/resource/management/commands/__init__.py +++ b/coldfront/core/resource/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/resource/management/commands/add_resource_defaults.py b/coldfront/core/resource/management/commands/add_resource_defaults.py index c1734f6e25..c93753564e 100644 --- a/coldfront/core/resource/management/commands/add_resource_defaults.py +++ b/coldfront/core/resource/management/commands/add_resource_defaults.py @@ -1,54 +1,62 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.core.management.base import BaseCommand -from coldfront.core.resource.models import (AttributeType, - ResourceAttributeType, - ResourceType) +from coldfront.core.resource.models import AttributeType, ResourceAttributeType, ResourceType class Command(BaseCommand): - help = 'Add default resource related choices' + help = "Add default resource related choices" def handle(self, *args, **options): - - for attribute_type in ('Active/Inactive', 'Date', 'Int', - 'Public/Private', 'Text', 'Yes/No', 'Attribute Expanded Text'): + for attribute_type in ( + "Active/Inactive", + "Date", + "Int", + "Public/Private", + "Text", + "Yes/No", + "Attribute Expanded Text", + ): AttributeType.objects.get_or_create(name=attribute_type) for resource_attribute_type, attribute_type in ( - ('Core Count', 'Int'), - ('expiry_time', 'Int'), - ('fee_applies', 'Yes/No'), - ('Node Count', 'Int'), - ('Owner', 'Text'), - ('quantity_default_value', 'Int'), - ('quantity_label', 'Text'), - ('eula', 'Text'), - ('OnDemand','Yes/No'), - ('ServiceEnd', 'Date'), - ('ServiceStart', 'Date'), - ('slurm_cluster', 'Text'), - ('slurm_specs', 'Attribute Expanded Text'), - ('slurm_specs_attriblist', 'Text'), - ('Status', 'Public/Private'), - ('Vendor', 'Text'), - ('Model', 'Text'), - ('SerialNumber', 'Text'), - ('RackUnits', 'Int'), - ('InstallDate', 'Date'), - ('WarrantyExpirationDate', 'Date'), - ('allocation_limit', 'Int'), + ("Core Count", "Int"), + ("expiry_time", "Int"), + ("fee_applies", "Yes/No"), + ("Node Count", "Int"), + ("Owner", "Text"), + ("quantity_default_value", "Int"), + ("quantity_label", "Text"), + ("eula", "Text"), + ("OnDemand", "Yes/No"), + ("ServiceEnd", "Date"), + ("ServiceStart", "Date"), + ("slurm_cluster", "Text"), + ("slurm_specs", "Attribute Expanded Text"), + ("slurm_specs_attriblist", "Text"), + ("Status", "Public/Private"), + ("Vendor", "Text"), + ("Model", "Text"), + ("SerialNumber", "Text"), + ("RackUnits", "Int"), + ("InstallDate", "Date"), + ("WarrantyExpirationDate", "Date"), + ("allocation_limit", "Int"), ): ResourceAttributeType.objects.get_or_create( - name=resource_attribute_type, attribute_type=AttributeType.objects.get(name=attribute_type)) + name=resource_attribute_type, attribute_type=AttributeType.objects.get(name=attribute_type) + ) for resource_type, description in ( - ('Cloud', 'Cloud Computing'), - ('Cluster', 'Cluster servers'), - ('Cluster Partition', 'Cluster Partition '), - ('Compute Node', 'Compute Node'), - ('Server', 'Extra servers providing various services'), - ('Software License', 'Software license purchased by users'), - ('Storage', 'NAS storage'), + ("Cloud", "Cloud Computing"), + ("Cluster", "Cluster servers"), + ("Cluster Partition", "Cluster Partition "), + ("Compute Node", "Compute Node"), + ("Server", "Extra servers providing various services"), + ("Software License", "Software license purchased by users"), + ("Storage", "NAS storage"), ): - ResourceType.objects.get_or_create( - name=resource_type, description=description) + ResourceType.objects.get_or_create(name=resource_type, description=description) diff --git a/coldfront/core/resource/migrations/0001_initial.py b/coldfront/core/resource/migrations/0001_initial.py index 8d489b7cf2..fe09953007 100644 --- a/coldfront/core/resource/migrations/0001_initial.py +++ b/coldfront/core/resource/migrations/0001_initial.py @@ -1,192 +1,398 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone import model_utils.fields import simple_history.models +from django.conf import settings +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('auth', '0011_update_proxy_permissions'), + ("auth", "0011_update_proxy_permissions"), ] operations = [ migrations.CreateModel( - name='AttributeType', + name="AttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128, unique=True)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128, unique=True)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ResourceType', + name="ResourceType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128, unique=True)), - ('description', models.CharField(max_length=255)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128, unique=True)), + ("description", models.CharField(max_length=255)), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='ResourceAttributeType', + name="ResourceAttributeType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128)), - ('is_required', models.BooleanField(default=False)), - ('is_unique_per_resource', models.BooleanField(default=False)), - ('is_value_unique', models.BooleanField(default=False)), - ('attribute_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource.AttributeType')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128)), + ("is_required", models.BooleanField(default=False)), + ("is_unique_per_resource", models.BooleanField(default=False)), + ("is_value_unique", models.BooleanField(default=False)), + ( + "attribute_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="resource.AttributeType"), + ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='Resource', + name="Resource", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128, unique=True)), - ('description', models.TextField()), - ('is_available', models.BooleanField(default=True)), - ('is_public', models.BooleanField(default=True)), - ('is_allocatable', models.BooleanField(default=True)), - ('requires_payment', models.BooleanField(default=False)), - ('allowed_groups', models.ManyToManyField(blank=True, to='auth.Group')), - ('allowed_users', models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), - ('linked_resources', models.ManyToManyField(blank=True, related_name='_resource_linked_resources_+', to='resource.Resource')), - ('parent_resource', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='resource.Resource')), - ('resource_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource.ResourceType')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128, unique=True)), + ("description", models.TextField()), + ("is_available", models.BooleanField(default=True)), + ("is_public", models.BooleanField(default=True)), + ("is_allocatable", models.BooleanField(default=True)), + ("requires_payment", models.BooleanField(default=False)), + ("allowed_groups", models.ManyToManyField(blank=True, to="auth.Group")), + ("allowed_users", models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL)), + ( + "linked_resources", + models.ManyToManyField( + blank=True, related_name="_resource_linked_resources_+", to="resource.Resource" + ), + ), + ( + "parent_resource", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="resource.Resource" + ), + ), + ( + "resource_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="resource.ResourceType"), + ), ], options={ - 'ordering': ['name'], + "ordering": ["name"], }, ), migrations.CreateModel( - name='HistoricalResourceType', + name="HistoricalResourceType", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(db_index=True, max_length=128)), - ('description', models.CharField(max_length=255)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(db_index=True, max_length=128)), + ("description", models.CharField(max_length=255)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical resource type', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical resource type", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalResourceAttributeType', + name="HistoricalResourceAttributeType", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(max_length=128)), - ('is_required', models.BooleanField(default=False)), - ('is_unique_per_resource', models.BooleanField(default=False)), - ('is_value_unique', models.BooleanField(default=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.AttributeType')), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(max_length=128)), + ("is_required", models.BooleanField(default=False)), + ("is_unique_per_resource", models.BooleanField(default=False)), + ("is_value_unique", models.BooleanField(default=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.AttributeType", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'verbose_name': 'historical resource attribute type', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical resource attribute type", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalResourceAttribute', + name="HistoricalResourceAttribute", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=512)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('resource', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.Resource')), - ('resource_attribute_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.ResourceAttributeType')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=512)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "resource", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.Resource", + ), + ), + ( + "resource_attribute_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.ResourceAttributeType", + ), + ), ], options={ - 'verbose_name': 'historical resource attribute', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical resource attribute", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='HistoricalResource', + name="HistoricalResource", fields=[ - ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('name', models.CharField(db_index=True, max_length=128)), - ('description', models.TextField()), - ('is_available', models.BooleanField(default=True)), - ('is_public', models.BooleanField(default=True)), - ('is_allocatable', models.BooleanField(default=True)), - ('requires_payment', models.BooleanField(default=False)), - ('history_id', models.AutoField(primary_key=True, serialize=False)), - ('history_date', models.DateTimeField()), - ('history_change_reason', models.CharField(max_length=100, null=True)), - ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), - ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('parent_resource', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.Resource')), - ('resource_type', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='resource.ResourceType')), + ("id", models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("name", models.CharField(db_index=True, max_length=128)), + ("description", models.TextField()), + ("is_available", models.BooleanField(default=True)), + ("is_public", models.BooleanField(default=True)), + ("is_allocatable", models.BooleanField(default=True)), + ("requires_payment", models.BooleanField(default=False)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField()), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "parent_resource", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.Resource", + ), + ), + ( + "resource_type", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="resource.ResourceType", + ), + ), ], options={ - 'verbose_name': 'historical resource', - 'ordering': ('-history_date', '-history_id'), - 'get_latest_by': 'history_date', + "verbose_name": "historical resource", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( - name='ResourceAttribute', + name="ResourceAttribute", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), - ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), - ('value', models.CharField(max_length=512)), - ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource.Resource')), - ('resource_attribute_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='resource.ResourceAttributeType')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, editable=False, verbose_name="created" + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, editable=False, verbose_name="modified" + ), + ), + ("value", models.CharField(max_length=512)), + ("resource", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="resource.Resource")), + ( + "resource_attribute_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="resource.ResourceAttributeType"), + ), ], options={ - 'unique_together': {('resource_attribute_type', 'resource')}, + "unique_together": {("resource_attribute_type", "resource")}, }, ), ] diff --git a/coldfront/core/resource/migrations/0002_auto_20191017_1141.py b/coldfront/core/resource/migrations/0002_auto_20191017_1141.py index 2967786f55..d86ebd2255 100644 --- a/coldfront/core/resource/migrations/0002_auto_20191017_1141.py +++ b/coldfront/core/resource/migrations/0002_auto_20191017_1141.py @@ -1,23 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.4 on 2019-10-17 15:41 from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('resource', '0001_initial'), + ("resource", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='historicalresourceattribute', - name='value', + model_name="historicalresourceattribute", + name="value", field=models.TextField(), ), migrations.AlterField( - model_name='resourceattribute', - name='value', + model_name="resourceattribute", + name="value", field=models.TextField(), ), ] diff --git a/coldfront/core/resource/migrations/__init__.py b/coldfront/core/resource/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/resource/migrations/__init__.py +++ b/coldfront/core/resource/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/resource/models.py b/coldfront/core/resource/models.py index dd1be130dd..d2a5531022 100644 --- a/coldfront/core/resource/models.py +++ b/coldfront/core/resource/models.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from datetime import datetime from django.contrib.auth.models import Group, User @@ -5,11 +9,13 @@ from django.db import models from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords + import coldfront.core.attribute_expansion as attribute_expansion + class AttributeType(TimeStampedModel): - """ An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. - + """An attribute type indicates the data type of the attribute. Examples include Date, Float, Int, Text, and Yes/No. + Attributes: name (str): name of attribute data type """ @@ -20,17 +26,23 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class ResourceType(TimeStampedModel): - """ A resource type class links a resource and its value. - + """A resource type class links a resource and its value. + Attributes: name (str): name of resource type description (str): description of resource type """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class ResourceTypeManager(models.Manager): def get_by_natural_key(self, name): @@ -43,23 +55,21 @@ def get_by_natural_key(self, name): @property def active_count(self): - """ + """ Returns: int: the number of active resources of that type """ - return ResourceAttribute.objects.filter( - resource__resource_type__name=self.name, value="Active").count() + return ResourceAttribute.objects.filter(resource__resource_type__name=self.name, value="Active").count() @property def inactive_count(self): - """ + """ Returns: int: the number of inactive resources of that type """ - return ResourceAttribute.objects.filter( - resource__resource_type__name=self.name, value="Inactive").count() + return ResourceAttribute.objects.filter(resource__resource_type__name=self.name, value="Inactive").count() def __str__(self): return self.name @@ -67,9 +77,10 @@ def __str__(self): def natural_key(self): return [self.name] + class ResourceAttributeType(TimeStampedModel): - """ A resource attribute type indicates the type of the attribute. Examples include slurm_specs and slurm_cluster. - + """A resource attribute type indicates the type of the attribute. Examples include slurm_specs and slurm_cluster. + Attributes: attribute_type (AttributeType): indicates the AttributeType of the attribute name (str): name of resource attribute type @@ -90,31 +101,36 @@ def __str__(self): return self.name class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] + class Resource(TimeStampedModel): - """ A resource is something a center maintains and provides access to for the community. Examples include Budgetstorage, Server, and Software License. - + """A resource is something a center maintains and provides access to for the community. Examples include Budgetstorage, Server, and Software License. + Attributes: parent_resource (Resource): used for the Cluster Partition resource type as these partitions fall under a main cluster resource_type (ResourceType): the type of resource (Cluster, Storage, etc.) - name (str): name of resource - description (str): description of what the resource does and is used for + name (str): name of resource + description (str): description of what the resource does and is used for is_available (bool): indicates whether or not the resource is available for users to request an allocation for is_public (bool): indicates whether or not users can see the resource requires_payment (bool): indicates whether or not users have to pay to use this resource allowed_groups (Group): uses the Django Group model to allow certain user groups to request the resource allowed_users (User): links Django Users that are allowed to request the resource to the resource """ + class Meta: - ordering = ['name', ] + ordering = [ + "name", + ] class ResourceManager(models.Manager): def get_by_natural_key(self, name): return self.get(name=name) - parent_resource = models.ForeignKey( - 'self', on_delete=models.CASCADE, blank=True, null=True) + parent_resource = models.ForeignKey("self", on_delete=models.CASCADE, blank=True, null=True) resource_type = models.ForeignKey(ResourceType, on_delete=models.CASCADE) name = models.CharField(max_length=128, unique=True) description = models.TextField() @@ -124,7 +140,7 @@ def get_by_natural_key(self, name): requires_payment = models.BooleanField(default=False) allowed_groups = models.ManyToManyField(Group, blank=True) allowed_users = models.ManyToManyField(User, blank=True) - linked_resources = models.ManyToManyField('self', blank=True) + linked_resources = models.ManyToManyField("self", blank=True) history = HistoricalRecords() objects = ResourceManager() @@ -138,11 +154,9 @@ def get_missing_resource_attributes(self, required=False): """ if required: - resource_attributes = ResourceAttributeType.objects.filter( - resource_type=self.resource_type, required=True) + resource_attributes = ResourceAttributeType.objects.filter(resource_type=self.resource_type, required=True) else: - resource_attributes = ResourceAttributeType.objects.filter( - resource_type=self.resource_type) + resource_attributes = ResourceAttributeType.objects.filter(resource_type=self.resource_type) missing_resource_attributes = [] @@ -153,15 +167,14 @@ def get_missing_resource_attributes(self, required=False): @property def status(self): - """ + """ Returns: str: the status of the resource """ return ResourceAttribute.objects.get(resource=self, resource_attribute_type__attribute="Status").value - def get_attribute(self, name, expand=True, typed=True, - extra_allocations=[]): + def get_attribute(self, name, expand=True, typed=True, extra_allocations=[]): """ Params: name (str): name of the resource attribute type @@ -173,12 +186,10 @@ def get_attribute(self, name, expand=True, typed=True, str: the value of the first attribute found for this resource with the specified name """ - attr = self.resourceattribute_set.filter( - resource_attribute_type__name=name).first() + attr = self.resourceattribute_set.filter(resource_attribute_type__name=name).first() if attr: if expand: - return attr.expanded_value( - typed=typed, extra_allocations=extra_allocations) + return attr.expanded_value(typed=typed, extra_allocations=extra_allocations) else: if typed: return attr.typed_value() @@ -186,8 +197,7 @@ def get_attribute(self, name, expand=True, typed=True, return attr.value return None - def get_attribute_list(self, name, expand=True, typed=True, - extra_allocations=[]): + def get_attribute_list(self, name, expand=True, typed=True, extra_allocations=[]): """ Params: name (str): name of the resource @@ -199,11 +209,9 @@ def get_attribute_list(self, name, expand=True, typed=True, list: the list of values of the attributes found with specified name """ - attr = self.resourceattribute_set.filter( - resource_attribute_type__name=name).all() + attr = self.resourceattribute_set.filter(resource_attribute_type__name=name).all() if expand: - return [a.expanded_value(extra_allocations=extra_allocations, - typed=typed) for a in attr] + return [a.expanded_value(extra_allocations=extra_allocations, typed=typed) for a in attr] else: if typed: return [a.typed_value() for a in attr] @@ -216,55 +224,50 @@ def get_ondemand_status(self): str: If the resource has OnDemand status or not """ - ondemand = self.resourceattribute_set.filter( - resource_attribute_type__name='OnDemand').first() + ondemand = self.resourceattribute_set.filter(resource_attribute_type__name="OnDemand").first() if ondemand: return ondemand.value return None - + def __str__(self): - return '%s (%s)' % (self.name, self.resource_type.name) + return "%s (%s)" % (self.name, self.resource_type.name) def natural_key(self): return [self.name] + class ResourceAttribute(TimeStampedModel): - """ A resource attribute class links a resource attribute type and a resource. - + """A resource attribute class links a resource attribute type and a resource. + Attributes: resource_attribute_type (ResourceAttributeType): resource attribute type to link resource (Resource): resource to link value (str): value of the resource attribute """ - resource_attribute_type = models.ForeignKey( - ResourceAttributeType, on_delete=models.CASCADE) + resource_attribute_type = models.ForeignKey(ResourceAttributeType, on_delete=models.CASCADE) resource = models.ForeignKey(Resource, on_delete=models.CASCADE) value = models.TextField() history = HistoricalRecords() def clean(self): - """ Validates the resource and raises errors if the resource is invalid. """ + """Validates the resource and raises errors if the resource is invalid.""" expected_value_type = self.resource_attribute_type.attribute_type.name.strip() if expected_value_type == "Int" and not self.value.isdigit(): - raise ValidationError( - 'Invalid Value "%s". Value must be an integer.' % (self.value)) + raise ValidationError('Invalid Value "%s". Value must be an integer.' % (self.value)) elif expected_value_type == "Active/Inactive" and self.value not in ["Active", "Inactive"]: - raise ValidationError( - 'Invalid Value "%s". Allowed inputs are "Active" or "Inactive".' % (self.value)) + raise ValidationError('Invalid Value "%s". Allowed inputs are "Active" or "Inactive".' % (self.value)) elif expected_value_type == "Public/Private" and self.value not in ["Public", "Private"]: - raise ValidationError( - 'Invalid Value "%s". Allowed inputs are "Public" or "Private".' % (self.value)) + raise ValidationError('Invalid Value "%s". Allowed inputs are "Public" or "Private".' % (self.value)) elif expected_value_type == "Date": try: datetime.strptime(self.value.strip(), "%m/%d/%Y") except ValueError: - raise ValidationError( - 'Invalid Value "%s". Date must be in format MM/DD/YYYY' % (self.value)) + raise ValidationError('Invalid Value "%s". Date must be in format MM/DD/YYYY' % (self.value)) def __str__(self): - return '%s: %s (%s)' % (self.resource_attribute_type, self.value, self.resource) + return "%s: %s (%s)" % (self.resource_attribute_type, self.value, self.resource) def typed_value(self): """ @@ -274,8 +277,7 @@ def typed_value(self): raw_value = self.value atype_name = self.resource_attribute_type.attribute_type.name - return attribute_expansion.convert_type( - value=raw_value, type_name=atype_name) + return attribute_expansion.convert_type(value=raw_value, type_name=atype_name) def expanded_value(self, typed=True, extra_allocations=[]): """ @@ -286,41 +288,40 @@ def expanded_value(self, typed=True, extra_allocations=[]): Returns: int, float, str: the value of the attribute after attribute expansion - For attributes with attribute type of 'Attribute Expanded Text' we look for an attribute with same name suffixed with '_attriblist' (this should be ResourceAttribute of the Resource associated with the attribute). If the attriblist attribute is found, we use it to generate a dictionary to use to expand the attribute value, and the expanded value is returned. + For attributes with attribute type of 'Attribute Expanded Text' we look for an attribute with same name suffixed with '_attriblist' (this should be ResourceAttribute of the Resource associated with the attribute). If the attriblist attribute is found, we use it to generate a dictionary to use to expand the attribute value, and the expanded value is returned. If the expansion fails, or if no attriblist attribute is found, or if the attribute type is not 'Attribute Expanded Text', we just return the raw value. """ - + raw_value = self.value if typed: # Try to convert to python type as per AttributeType raw_value = self.typed_value() - if not attribute_expansion.is_expandable_type( - self.resource_attribute_type.attribute_type): + if not attribute_expansion.is_expandable_type(self.resource_attribute_type.attribute_type): # We are not an expandable type, return raw value return raw_value allocs = extra_allocations - resources = [ self.resource ] + resources = [self.resource] attrib_name = self.resource_attribute_type.name attriblist = attribute_expansion.get_attriblist_str( - attribute_name = attrib_name, - resources = resources, - allocations = allocs) + attribute_name=attrib_name, resources=resources, allocations=allocs + ) if not attriblist: # We do not have an attriblist, return raw value return raw_value expanded = attribute_expansion.expand_attribute( - raw_value = raw_value, - attribute_name = attrib_name, - attriblist_string = attriblist, - resources = resources, - allocations = allocs) + raw_value=raw_value, + attribute_name=attrib_name, + attriblist_string=attriblist, + resources=resources, + allocations=allocs, + ) return expanded class Meta: - unique_together = ('resource_attribute_type', 'resource') + unique_together = ("resource_attribute_type", "resource") diff --git a/coldfront/core/resource/tests.py b/coldfront/core/resource/tests.py index 7ce503c2dd..576ead011d 100644 --- a/coldfront/core/resource/tests.py +++ b/coldfront/core/resource/tests.py @@ -1,3 +1,5 @@ -from django.test import TestCase +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your tests here. diff --git a/coldfront/core/resource/urls.py b/coldfront/core/resource/urls.py index f8da148065..1dd0c3da2c 100644 --- a/coldfront/core/resource/urls.py +++ b/coldfront/core/resource/urls.py @@ -1,14 +1,22 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path import coldfront.core.resource.views as resource_views urlpatterns = [ - path('', resource_views.ResourceListView.as_view(), - name='resource-list'), - path('/', resource_views.ResourceDetailView.as_view(), - name='resource-detail'), - path('/resourceattribute/add', - resource_views.ResourceAttributeCreateView.as_view(), name='resource-attribute-add'), - path('/resourceattribute/delete', - resource_views.ResourceAttributeDeleteView.as_view(), name='resource-attribute-delete'), -] \ No newline at end of file + path("", resource_views.ResourceListView.as_view(), name="resource-list"), + path("/", resource_views.ResourceDetailView.as_view(), name="resource-detail"), + path( + "/resourceattribute/add", + resource_views.ResourceAttributeCreateView.as_view(), + name="resource-attribute-add", + ), + path( + "/resourceattribute/delete", + resource_views.ResourceAttributeDeleteView.as_view(), + name="resource-attribute-delete", + ), +] diff --git a/coldfront/core/resource/views.py b/coldfront/core/resource/views.py index b37ce43cec..7f9e4aca3a 100644 --- a/coldfront/core/resource/views.py +++ b/coldfront/core/resource/views.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin @@ -5,61 +9,63 @@ from django.db.models import Q from django.db.models.functions import Lower from django.forms import formset_factory -from django.http import HttpResponseRedirect, JsonResponse +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse -from django.views.generic import TemplateView, ListView +from django.views.generic import ListView, TemplateView from django.views.generic.edit import CreateView -from coldfront.config.core import ALLOCATION_EULA_ENABLE -from coldfront.core.resource.forms import ResourceAttributeCreateForm, ResourceSearchForm, ResourceAttributeDeleteForm +from coldfront.config.core import ALLOCATION_EULA_ENABLE +from coldfront.core.resource.forms import ResourceAttributeCreateForm, ResourceAttributeDeleteForm, ResourceSearchForm from coldfront.core.resource.models import Resource, ResourceAttribute + class ResourceEULAView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Resource - template_name = 'resource_eula.html' - context_object_name = 'resource' + template_name = "resource_eula.html" + context_object_name = "resource" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" return True - + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - attributes = [attribute for attribute in resource_obj.resourceattribute_set.all( - ).order_by('resource_attribute_type__name')] + attributes = [ + attribute + for attribute in resource_obj.resourceattribute_set.all().order_by("resource_attribute_type__name") + ] - context['resource'] = resource_obj - context['attributes'] = attributes + context["resource"] = resource_obj + context["attributes"] = attributes return context + class ResourceDetailView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): model = Resource - template_name = 'resource_detail.html' - context_object_name = 'resource' + template_name = "resource_detail.html" + context_object_name = "resource" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" return True def get_child_resources(self, resource_obj): - child_resources = [resource for resource in resource_obj.resource_set.all( - ).order_by(Lower("name"))] + child_resources = [resource for resource in resource_obj.resource_set.all().order_by(Lower("name"))] child_resources = [ - - {'object': resource, - 'WarrantyExpirationDate': resource.get_attribute('WarrantyExpirationDate'), - 'ServiceEnd': resource.get_attribute('ServiceEnd'), - 'Vendor': resource.get_attribute('Vendor'), - 'SerialNumber': resource.get_attribute('SerialNumber'), - 'Model': resource.get_attribute('Model'), - } - + { + "object": resource, + "WarrantyExpirationDate": resource.get_attribute("WarrantyExpirationDate"), + "ServiceEnd": resource.get_attribute("ServiceEnd"), + "Vendor": resource.get_attribute("Vendor"), + "SerialNumber": resource.get_attribute("SerialNumber"), + "Model": resource.get_attribute("Model"), + } for resource in child_resources ] @@ -67,152 +73,140 @@ def get_child_resources(self, resource_obj): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - attributes = [attribute for attribute in resource_obj.resourceattribute_set.all( - ).order_by('resource_attribute_type__name')] + attributes = [ + attribute + for attribute in resource_obj.resourceattribute_set.all().order_by("resource_attribute_type__name") + ] child_resources = self.get_child_resources(resource_obj) - context['resource'] = resource_obj - context['attributes'] = attributes - context['child_resources'] = child_resources + context["resource"] = resource_obj + context["attributes"] = attributes + context["child_resources"] = child_resources return context + class ResourceAttributeCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): model = ResourceAttribute form_class = ResourceAttributeCreateForm # fields = '__all__' - template_name = 'resource_resourceattribute_create.html' + template_name = "resource_resourceattribute_create.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True else: - messages.error( - self.request, 'You do not have permission to add resource attributes.') + messages.error(self.request, "You do not have permission to add resource attributes.") def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - context['resource'] = resource_obj + context["resource"] = resource_obj return context def get_initial(self): initial = super().get_initial() - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - initial['resource'] = resource_obj + initial["resource"] = resource_obj return initial def get_form(self, form_class=None): """Return an instance of the form to be used in this view.""" form = super().get_form(form_class) - form.fields['resource'].widget = forms.HiddenInput() + form.fields["resource"].widget = forms.HiddenInput() return form def get_success_url(self): - return reverse('resource-detail', kwargs={'pk': self.kwargs.get('pk')}) + return reverse("resource-detail", kwargs={"pk": self.kwargs.get("pk")}) class ResourceAttributeDeleteView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'resource_resourceattribute_delete.html' + template_name = "resource_resourceattribute_delete.html" def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True else: - messages.error( - self.request, 'You do not have permission to delete resource attributes.') + messages.error(self.request, "You do not have permission to delete resource attributes.") def get_resource_attributes_to_delete(self, resource_obj): - - resource_attributes_to_delete = ResourceAttribute.objects.filter( - resource=resource_obj) + resource_attributes_to_delete = ResourceAttribute.objects.filter(resource=resource_obj) resource_attributes_to_delete = [ - - {'pk': attribute.pk, - 'name': attribute.resource_attribute_type.name, - 'value': attribute.value, - } - + { + "pk": attribute.pk, + "name": attribute.resource_attribute_type.name, + "value": attribute.value, + } for attribute in resource_attributes_to_delete ] return resource_attributes_to_delete def get(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - resource_attributes_to_delete = self.get_resource_attributes_to_delete( - resource_obj) + resource_attributes_to_delete = self.get_resource_attributes_to_delete(resource_obj) context = {} if resource_attributes_to_delete: - formset = formset_factory(ResourceAttributeDeleteForm, max_num=len( - resource_attributes_to_delete)) - formset = formset( - initial=resource_attributes_to_delete, prefix='attributeform') - context['formset'] = formset - context['resource'] = resource_obj + formset = formset_factory(ResourceAttributeDeleteForm, max_num=len(resource_attributes_to_delete)) + formset = formset(initial=resource_attributes_to_delete, prefix="attributeform") + context["formset"] = formset + context["resource"] = resource_obj return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - pk = self.kwargs.get('pk') + pk = self.kwargs.get("pk") resource_obj = get_object_or_404(Resource, pk=pk) - resource_attributes_to_delete = self.get_resource_attributes_to_delete( - resource_obj) + resource_attributes_to_delete = self.get_resource_attributes_to_delete(resource_obj) - formset = formset_factory(ResourceAttributeDeleteForm, max_num=len( - resource_attributes_to_delete)) - formset = formset( - request.POST, initial=resource_attributes_to_delete, prefix='attributeform') + formset = formset_factory(ResourceAttributeDeleteForm, max_num=len(resource_attributes_to_delete)) + formset = formset(request.POST, initial=resource_attributes_to_delete, prefix="attributeform") attributes_deleted_count = 0 if formset.is_valid(): for form in formset: form_data = form.cleaned_data - if form_data['selected']: - + if form_data["selected"]: attributes_deleted_count += 1 - resource_attribute = ResourceAttribute.objects.get( - pk=form_data['pk']) + resource_attribute = ResourceAttribute.objects.get(pk=form_data["pk"]) resource_attribute.delete() - messages.success(request, 'Deleted {} attributes from resource.'.format( - attributes_deleted_count)) + messages.success(request, "Deleted {} attributes from resource.".format(attributes_deleted_count)) else: for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse('resource-detail', kwargs={'pk': pk})) + return HttpResponseRedirect(reverse("resource-detail", kwargs={"pk": pk})) -class ResourceListView(LoginRequiredMixin, ListView): +class ResourceListView(LoginRequiredMixin, ListView): model = Resource - template_name = 'resource_list.html' - context_object_name = 'resource_list' + template_name = "resource_list.html" + context_object_name = "resource_list" paginate_by = 25 def get_queryset(self): - - order_by = self.request.GET.get('order_by', 'id') - direction = self.request.GET.get('direction', 'asc') + order_by = self.request.GET.get("order_by", "id") + direction = self.request.GET.get("direction", "asc") if order_by != "name": - if direction == 'asc': - direction = '' - if direction == 'des': - direction = '-' + if direction == "asc": + direction = "" + if direction == "des": + direction = "-" order_by = direction + order_by resource_search_form = ResourceSearchForm(self.request.GET) @@ -220,56 +214,56 @@ def get_queryset(self): if resource_search_form.is_valid(): data = resource_search_form.cleaned_data if order_by == "name": - direction = self.request.GET.get('direction') + direction = self.request.GET.get("direction") if direction == "asc": resources = Resource.objects.all().order_by(Lower("name")) elif direction == "des": - resources = (Resource.objects.all().order_by(Lower("name")).reverse()) + resources = Resource.objects.all().order_by(Lower("name")).reverse() else: resources = Resource.objects.all().order_by(order_by) else: resources = Resource.objects.all().order_by(order_by) - if data.get('show_allocatable_resources'): + if data.get("show_allocatable_resources"): resources = resources.filter(is_allocatable=True) - if data.get('model'): + if data.get("model"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='Model') & - Q(resourceattribute__value=data.get('model')) + Q(resourceattribute__resource_attribute_type__name="Model") + & Q(resourceattribute__value=data.get("model")) ) - if data.get('serialNumber'): + if data.get("serialNumber"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='SerialNumber') & - Q(resourceattribute__value=data.get('serialNumber')) + Q(resourceattribute__resource_attribute_type__name="SerialNumber") + & Q(resourceattribute__value=data.get("serialNumber")) ) - if data.get('installDate'): + if data.get("installDate"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='InstallDate') & - Q(resourceattribute__value=data.get('installDate').strftime('%m/%d/%Y')) + Q(resourceattribute__resource_attribute_type__name="InstallDate") + & Q(resourceattribute__value=data.get("installDate").strftime("%m/%d/%Y")) ) - if data.get('serviceStart'): + if data.get("serviceStart"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type_name='ServiceStart') & - Q(resourceattribute__value=data.get('serviceStart').strftime('%m/%d/%Y')) + Q(resourceattribute__resource_attribute_type_name="ServiceStart") + & Q(resourceattribute__value=data.get("serviceStart").strftime("%m/%d/%Y")) ) - if data.get('serviceEnd'): + if data.get("serviceEnd"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='ServiceEnd') & - Q(resourceattribute__value=data.get('serviceEnd').strftime('%m/%d/%Y')) + Q(resourceattribute__resource_attribute_type__name="ServiceEnd") + & Q(resourceattribute__value=data.get("serviceEnd").strftime("%m/%d/%Y")) ) - if data.get('warrantyExpirationDate'): + if data.get("warrantyExpirationDate"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='WarrantyExpirationDate') & - Q(resourceattribute__value=data.get('warrantyExpirationDate').strftime('%m/%d/%Y')) + Q(resourceattribute__resource_attribute_type__name="WarrantyExpirationDate") + & Q(resourceattribute__value=data.get("warrantyExpirationDate").strftime("%m/%d/%Y")) ) - if data.get('vendor'): + if data.get("vendor"): resources = resources.filter( - Q(resourceattribute__resource_attribute_type__name='Vendor') & - Q(resourceattribute__value=data.get('vendor')) + Q(resourceattribute__resource_attribute_type__name="Vendor") + & Q(resourceattribute__value=data.get("vendor")) ) else: if order_by == "name": - direction = self.request.GET.get('direction') + direction = self.request.GET.get("direction") if direction == "asc": resources = Resource.objects.all().order_by(Lower("name")) elif direction == "des": @@ -281,47 +275,45 @@ def get_queryset(self): return resources.distinct() def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) resources_count = self.get_queryset().count() - context['resources_count'] = resources_count + context["resources_count"] = resources_count resource_search_form = ResourceSearchForm(self.request.GET) if resource_search_form.is_valid(): - context['resource_search_form'] = resource_search_form + context["resource_search_form"] = resource_search_form data = resource_search_form.cleaned_data - filter_parameters = '' + filter_parameters = "" for key, value in data.items(): if value: if isinstance(value, list): for ele in value: - filter_parameters += '{}={}&'.format(key, ele) + filter_parameters += "{}={}&".format(key, ele) else: - filter_parameters += '{}={}&'.format(key, value) - context['resource_search_form'] = resource_search_form + filter_parameters += "{}={}&".format(key, value) + context["resource_search_form"] = resource_search_form else: filter_parameters = None - context['resource_search_form'] = ResourceSearchForm() + context["resource_search_form"] = ResourceSearchForm() - order_by = self.request.GET.get('order_by') + order_by = self.request.GET.get("order_by") if order_by: - direction = self.request.GET.get('direction') - filter_parameters_with_order_by = filter_parameters + \ - 'order_by=%s&direction=%s&' % (order_by, direction) + direction = self.request.GET.get("direction") + filter_parameters_with_order_by = filter_parameters + "order_by=%s&direction=%s&" % (order_by, direction) else: filter_parameters_with_order_by = filter_parameters if filter_parameters: - context['expand_accordion'] = 'show' + context["expand_accordion"] = "show" - context['filter_parameters'] = filter_parameters - context['filter_parameters_with_order_by'] = filter_parameters_with_order_by - context['ALLOCATION_EULA_ENABLE'] = ALLOCATION_EULA_ENABLE + context["filter_parameters"] = filter_parameters + context["filter_parameters_with_order_by"] = filter_parameters_with_order_by + context["ALLOCATION_EULA_ENABLE"] = ALLOCATION_EULA_ENABLE - resource_list = context.get('resource_list') + resource_list = context.get("resource_list") paginator = Paginator(resource_list, self.paginate_by) - page = self.request.GET.get('page') + page = self.request.GET.get("page") try: resource_list = paginator.page(page) @@ -330,4 +322,3 @@ def get_context_data(self, **kwargs): except EmptyPage: resource_list = paginator.page(paginator.num_pages) return context - diff --git a/coldfront/core/test_helpers/decorators.py b/coldfront/core/test_helpers/decorators.py index 71b53d2353..41ec96259f 100644 --- a/coldfront/core/test_helpers/decorators.py +++ b/coldfront/core/test_helpers/decorators.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import functools import os import unittest @@ -7,9 +11,9 @@ def _skipUnlessEnvDefined(varname, reason=None): skip = varname not in os.environ if skip and reason is None: - reason = 'Automatically skipped. {} is not defined'.format(varname) + reason = "Automatically skipped. {} is not defined".format(varname) return functools.partial(unittest.skipIf, skip, reason) -makes_remote_requests = _skipUnlessEnvDefined('TESTS_ALLOW_REMOTE_REQUESTS') +makes_remote_requests = _skipUnlessEnvDefined("TESTS_ALLOW_REMOTE_REQUESTS") diff --git a/coldfront/core/test_helpers/factories.py b/coldfront/core/test_helpers/factories.py index 53298bd749..b852110095 100644 --- a/coldfront/core/test_helpers/factories.py +++ b/coldfront/core/test_helpers/factories.py @@ -1,99 +1,118 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import factory from django.contrib.auth.models import User from factory import SubFactory -from factory.fuzzy import FuzzyChoice from factory.django import DjangoModelFactory +from factory.fuzzy import FuzzyChoice from faker import Faker from faker.providers import BaseProvider, DynamicProvider -from coldfront.core.field_of_science.models import FieldOfScience -from coldfront.core.resource.models import ResourceType, Resource -from coldfront.core.project.models import ( - Project, - ProjectUser, - ProjectAttribute, - ProjectAttributeType, - ProjectUserRoleChoice, - ProjectUserStatusChoice, - ProjectStatusChoice, - AttributeType as PAttributeType, -) from coldfront.core.allocation.models import ( Allocation, - AllocationUser, - AllocationUserNote, AllocationAttribute, - AllocationStatusChoice, + AllocationAttributeChangeRequest, AllocationAttributeType, + AllocationAttributeUsage, AllocationChangeRequest, AllocationChangeStatusChoice, - AllocationAttributeUsage, + AllocationStatusChoice, + AllocationUser, + AllocationUserNote, AllocationUserStatusChoice, - AllocationAttributeChangeRequest, +) +from coldfront.core.allocation.models import ( AttributeType as AAttributeType, ) +from coldfront.core.field_of_science.models import FieldOfScience from coldfront.core.grant.models import GrantFundingAgency, GrantStatusChoice +from coldfront.core.project.models import ( + AttributeType as PAttributeType, +) +from coldfront.core.project.models import ( + Project, + ProjectAttribute, + ProjectAttributeType, + ProjectStatusChoice, + ProjectUser, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) from coldfront.core.publication.models import PublicationSource - +from coldfront.core.resource.models import Resource, ResourceType +from coldfront.core.user.models import UserProfile ### Default values and Faker provider setup ### -project_status_choice_names = ['New', 'Active', 'Archived'] -project_user_role_choice_names = ['User', 'Manager'] -field_of_science_names = ['Physics', 'Chemistry', 'Economics', 'Biology', 'Sociology'] -attr_types = ['Date', 'Int', 'Float', 'Text', 'Boolean'] +project_status_choice_names = ["New", "Active", "Archived"] +project_user_role_choice_names = ["User", "Manager"] +field_of_science_names = ["Physics", "Chemistry", "Economics", "Biology", "Sociology"] +attr_types = ["Date", "Int", "Float", "Text", "Boolean"] fake = Faker() + class ColdfrontProvider(BaseProvider): def project_title(self): - return f'{fake.last_name()}_lab'.lower() + return f"{fake.last_name()}_lab".lower() def resource_name(self): - return fake.word().lower()+ '/' + fake.word().lower() + return fake.word().lower() + "/" + fake.word().lower() def username(self): first_name = fake.first_name() last_name = fake.last_name() - return f'{first_name}{last_name}'.lower() + return f"{first_name}{last_name}".lower() -field_of_science_provider = DynamicProvider( - provider_name="fieldofscience", elements=field_of_science_names -) + +field_of_science_provider = DynamicProvider(provider_name="fieldofscience", elements=field_of_science_names) attr_type_provider = DynamicProvider(provider_name="attr_types", elements=attr_types) for provider in [ColdfrontProvider, field_of_science_provider, attr_type_provider]: factory.Faker.add_provider(provider) - ### User factories ### + class UserFactory(DjangoModelFactory): class Meta: model = User - django_get_or_create = ('username',) - first_name = factory.Faker('first_name') - last_name = factory.Faker('last_name') + django_get_or_create = ("username",) + + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") # username = factory.Faker('username') - username = factory.LazyAttribute(lambda o: f'{o.first_name}{o.last_name}') - email = factory.LazyAttribute(lambda o: '%s@example.com' % o.username) + username = factory.LazyAttribute(lambda o: f"{o.first_name}{o.last_name}") + email = factory.LazyAttribute(lambda o: "%s@example.com" % o.username) + + +class UserProfileFactory(DjangoModelFactory): + class Meta: + model = UserProfile + django_get_or_create = ("user",) + + is_pi = False + user = SubFactory(UserFactory) ### Field of Science factories ### + class FieldOfScienceFactory(DjangoModelFactory): class Meta: model = FieldOfScience - django_get_or_create = ('description',) + django_get_or_create = ("description",) # description = FuzzyChoice(field_of_science_names) - description = factory.Faker('fieldofscience') - + description = factory.Faker("fieldofscience") ### Grant factories ### + class GrantFundingAgencyFactory(DjangoModelFactory): class Meta: model = GrantFundingAgency @@ -104,15 +123,17 @@ class Meta: model = GrantStatusChoice - ### Project factories ### + class ProjectStatusChoiceFactory(DjangoModelFactory): """Factory for ProjectStatusChoice model""" + class Meta: model = ProjectStatusChoice # ensure that names are unique - django_get_or_create = ('name',) + django_get_or_create = ("name",) + # randomly generate names from list of default values name = FuzzyChoice(project_status_choice_names) @@ -120,11 +141,11 @@ class Meta: class ProjectFactory(DjangoModelFactory): class Meta: model = Project - django_get_or_create = ('title',) + django_get_or_create = ("title",) pi = SubFactory(UserFactory) - title = factory.Faker('project_title') - description = factory.Faker('sentence') + title = factory.Faker("project_title") + description = factory.Faker("sentence") field_of_science = SubFactory(FieldOfScienceFactory) status = SubFactory(ProjectStatusChoiceFactory) force_review = False @@ -134,21 +155,23 @@ class Meta: class ProjectUserRoleChoiceFactory(DjangoModelFactory): class Meta: model = ProjectUserRoleChoice - django_get_or_create = ('name',) - name = 'User' + django_get_or_create = ("name",) + + name = "User" class ProjectUserStatusChoiceFactory(DjangoModelFactory): class Meta: model = ProjectUserStatusChoice - django_get_or_create = ('name',) - name = 'Active' + django_get_or_create = ("name",) + + name = "Active" class ProjectUserFactory(DjangoModelFactory): class Meta: model = ProjectUser - django_get_or_create = ('project', 'user') + django_get_or_create = ("project", "user") project = SubFactory(ProjectFactory) user = SubFactory(UserFactory) @@ -156,102 +179,113 @@ class Meta: status = SubFactory(ProjectUserStatusChoiceFactory) - ### Project Attribute factories ### + class PAttributeTypeFactory(DjangoModelFactory): class Meta: model = PAttributeType # django_get_or_create = ('name',) - name = factory.Faker('attr_type') + + name = factory.Faker("attr_type") class ProjectAttributeTypeFactory(DjangoModelFactory): class Meta: model = ProjectAttributeType - name = 'Test attribute type' + + name = "Test attribute type" attribute_type = SubFactory(PAttributeTypeFactory) class ProjectAttributeFactory(DjangoModelFactory): class Meta: model = ProjectAttribute + proj_attr_type = SubFactory(ProjectAttributeTypeFactory) - value = 'Test attribute value' + value = "Test attribute value" project = SubFactory(ProjectFactory) - ### Publication factories ### + class PublicationSourceFactory(DjangoModelFactory): class Meta: model = PublicationSource - name = 'doi' - url = 'https://doi.org/' - + name = "doi" + url = "https://doi.org/" ### Resource factories ### + class ResourceTypeFactory(DjangoModelFactory): class Meta: model = ResourceType - django_get_or_create = ('name',) - name = 'Storage' + django_get_or_create = ("name",) + + name = "Storage" + class ResourceFactory(DjangoModelFactory): class Meta: model = Resource - django_get_or_create = ('name',) - name = factory.Faker('resource_name') + django_get_or_create = ("name",) - description = factory.Faker('sentence') - resource_type = SubFactory(ResourceTypeFactory) + name = factory.Faker("resource_name") + description = factory.Faker("sentence") + resource_type = SubFactory(ResourceTypeFactory) ### Allocation factories ### + class AllocationStatusChoiceFactory(DjangoModelFactory): class Meta: model = AllocationStatusChoice - django_get_or_create = ('name',) - name = 'Active' + django_get_or_create = ("name",) + + name = "Active" class AllocationFactory(DjangoModelFactory): class Meta: model = Allocation - django_get_or_create = ('project',) - justification = factory.Faker('sentence') + django_get_or_create = ("project",) + + justification = factory.Faker("sentence") status = SubFactory(AllocationStatusChoiceFactory) project = SubFactory(ProjectFactory) is_changeable = True - ### Allocation Attribute factories ### + class AAttributeTypeFactory(DjangoModelFactory): class Meta: model = AAttributeType - django_get_or_create = ('name',) - name='Int' + django_get_or_create = ("name",) + + name = "Int" class AllocationAttributeTypeFactory(DjangoModelFactory): class Meta: model = AllocationAttributeType - django_get_or_create = ('name',) - name = 'Test attribute type' + django_get_or_create = ("name",) + + name = "Test attribute type" attribute_type = SubFactory(AAttributeTypeFactory) class AllocationAttributeFactory(DjangoModelFactory): class Meta: model = AllocationAttribute + allocation_attribute_type = SubFactory(AllocationAttributeTypeFactory) value = 2048 allocation = SubFactory(AllocationFactory) @@ -260,19 +294,21 @@ class Meta: class AllocationAttributeUsageFactory(DjangoModelFactory): class Meta: model = AllocationAttributeUsage - django_get_or_create = ('allocation_attribute',) + django_get_or_create = ("allocation_attribute",) + allocation_attribute = SubFactory(AllocationAttributeFactory) value = 1024 - ### Allocation Change Request factories ### + class AllocationChangeStatusChoiceFactory(DjangoModelFactory): class Meta: model = AllocationChangeStatusChoice - django_get_or_create = ('name',) - name = 'Pending' + django_get_or_create = ("name",) + + name = "Pending" class AllocationChangeRequestFactory(DjangoModelFactory): @@ -281,7 +317,7 @@ class Meta: allocation = SubFactory(AllocationFactory) status = SubFactory(AllocationChangeStatusChoiceFactory) - justification = factory.Faker('sentence') + justification = factory.Faker("sentence") class AllocationAttributeChangeRequestFactory(DjangoModelFactory): @@ -293,20 +329,22 @@ class Meta: new_value = 1000 - ### Allocation User factories ### + class AllocationUserStatusChoiceFactory(DjangoModelFactory): class Meta: model = AllocationUserStatusChoice - django_get_or_create = ('name',) - name = 'Active' + django_get_or_create = ("name",) + + name = "Active" class AllocationUserFactory(DjangoModelFactory): class Meta: model = AllocationUser - django_get_or_create = ('allocation','user') + django_get_or_create = ("allocation", "user") + allocation = SubFactory(AllocationFactory) user = SubFactory(UserFactory) status = SubFactory(AllocationUserStatusChoiceFactory) @@ -315,7 +353,8 @@ class Meta: class AllocationUserNoteFactory(DjangoModelFactory): class Meta: model = AllocationUserNote - django_get_or_create = ('allocation') + django_get_or_create = "allocation" + allocation = SubFactory(AllocationFactory) author = SubFactory(AllocationUserFactory) - note = factory.Faker('sentence') + note = factory.Faker("sentence") diff --git a/coldfront/core/test_helpers/utils.py b/coldfront/core/test_helpers/utils.py index f03c25f126..dbe4363bef 100644 --- a/coldfront/core/test_helpers/utils.py +++ b/coldfront/core/test_helpers/utils.py @@ -1,20 +1,28 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + """utility functions for unit and integration testing""" + def login_and_get_page(client, user, page): """force login and return get response for page""" client.force_login(user, backend="django.contrib.auth.backends.ModelBackend") return client.get(page) + def page_contains_for_user(test_case, user, url, text): """Check that page contains text for user""" response = login_and_get_page(test_case.client, user, url) test_case.assertContains(response, text) + def page_does_not_contain_for_user(test_case, user, url, text): """Check that page contains text for user""" response = login_and_get_page(test_case.client, user, url) test_case.assertNotContains(response, text) + def test_logged_out_redirect_to_login(test_case, page): """ Confirm that attempting to access page while not logged in triggers a 302 @@ -29,7 +37,8 @@ def test_logged_out_redirect_to_login(test_case, page): # log out, in case already logged in test_case.client.logout() response = test_case.client.get(page) - test_case.assertRedirects(response, f'/user/login?next={page}') + test_case.assertRedirects(response, f"/user/login?next={page}") + def test_redirect(test_case, page): """ @@ -51,6 +60,7 @@ def test_redirect(test_case, page): test_case.assertEqual(response.status_code, 302) return response.url + def test_user_cannot_access(test_case, user, page): """Confirm that accessing the page as the designated user returns a 403 response code. @@ -65,6 +75,7 @@ def test_user_cannot_access(test_case, user, page): response = login_and_get_page(test_case.client, user, page) test_case.assertEqual(response.status_code, 403) + def test_user_can_access(test_case, user, page): """Confirm that accessing the page as the designated user returns a 200 response code. diff --git a/coldfront/core/user/__init__.py b/coldfront/core/user/__init__.py index cbe968a216..2782e42058 100644 --- a/coldfront/core/user/__init__.py +++ b/coldfront/core/user/__init__.py @@ -1 +1,5 @@ -default_app_config = 'coldfront.core.user.apps.UserConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +default_app_config = "coldfront.core.user.apps.UserConfig" diff --git a/coldfront/core/user/admin.py b/coldfront/core/user/admin.py index 40755b6272..8cb672b964 100644 --- a/coldfront/core/user/admin.py +++ b/coldfront/core/user/admin.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib import admin from coldfront.core.user.models import UserProfile @@ -5,9 +9,14 @@ @admin.register(UserProfile) class UserProfileAdmin(admin.ModelAdmin): - list_display = ('username', 'first_name', 'last_name', 'is_pi',) - list_filter = ('is_pi',) - search_fields = ['user__username', 'user__first_name', 'user__last_name'] + list_display = ( + "username", + "first_name", + "last_name", + "is_pi", + ) + list_filter = ("is_pi",) + search_fields = ["user__username", "user__first_name", "user__last_name"] def username(self, obj): return obj.user.username diff --git a/coldfront/core/user/apps.py b/coldfront/core/user/apps.py index a024fa2db6..fbb15f8781 100644 --- a/coldfront/core/user/apps.py +++ b/coldfront/core/user/apps.py @@ -1,8 +1,14 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib + from django.apps import AppConfig class UserConfig(AppConfig): - name = 'coldfront.core.user' + name = "coldfront.core.user" def ready(self): - import coldfront.core.user.signals + importlib.import_module("coldfront.core.user.signals") diff --git a/coldfront/core/user/forms.py b/coldfront/core/user/forms.py index 39bdbbe360..3198031bb4 100644 --- a/coldfront/core/user/forms.py +++ b/coldfront/core/user/forms.py @@ -1,12 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import forms from django.utils.html import mark_safe class UserSearchForm(forms.Form): - CHOICES = [('username_only', 'Exact Username Only'), - # ('all_fields', mark_safe('All Fields ')), - ('all_fields', mark_safe('All Fields This option will be ignored if multiple usernames are entered in the search user text area.')), - ] - q = forms.CharField(label='Search String', min_length=2, widget=forms.Textarea(attrs={'rows': 4}), - help_text='Copy paste usernames separated by space or newline for multiple username searches!') - search_by = forms.ChoiceField(choices=CHOICES, widget=forms.RadioSelect(), initial='username_only') + CHOICES = [ + ("username_only", "Exact Username Only"), + # ('all_fields', mark_safe('All Fields ')), + ( + "all_fields", + mark_safe( + 'All Fields This option will be ignored if multiple usernames are entered in the search user text area.' + ), + ), + ] + q = forms.CharField( + label="Search String", + min_length=2, + widget=forms.Textarea(attrs={"rows": 4}), + help_text="Copy paste usernames separated by space or newline for multiple username searches!", + ) + search_by = forms.ChoiceField(choices=CHOICES, widget=forms.RadioSelect(), initial="username_only") diff --git a/coldfront/core/user/migrations/0001_initial.py b/coldfront/core/user/migrations/0001_initial.py index 1ebe6c61a3..7cfe198ec9 100644 --- a/coldfront/core/user/migrations/0001_initial.py +++ b/coldfront/core/user/migrations/0001_initial.py @@ -1,12 +1,15 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # Generated by Django 2.2.3 on 2019-07-18 18:51 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - initial = True dependencies = [ @@ -15,11 +18,14 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='UserProfile', + name="UserProfile", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('is_pi', models.BooleanField(default=False)), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("is_pi", models.BooleanField(default=False)), + ( + "user", + models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), ], ), ] diff --git a/coldfront/core/user/migrations/__init__.py b/coldfront/core/user/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/user/migrations/__init__.py +++ b/coldfront/core/user/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/user/models.py b/coldfront/core/user/models.py index ca1b0a07e1..e5264730a5 100644 --- a/coldfront/core/user/models.py +++ b/coldfront/core/user/models.py @@ -1,14 +1,18 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib.auth.models import User from django.db import models class UserProfile(models.Model): - """ Displays a user's profile. A user can be a principal investigator (PI), manager, administrator, staff member, billing staff member, or center director. + """Displays a user's profile. A user can be a principal investigator (PI), manager, administrator, staff member, billing staff member, or center director. Attributes: is_pi (bool): indicates whether or not the user is a PI - user (User): represents the Django User model + user (User): represents the Django User model """ user = models.OneToOneField(User, on_delete=models.CASCADE) - is_pi = models.BooleanField(default=False) \ No newline at end of file + is_pi = models.BooleanField(default=False) diff --git a/coldfront/core/user/signals.py b/coldfront/core/user/signals.py index 9f8d881dd8..87e18e1fca 100644 --- a/coldfront/core/user/signals.py +++ b/coldfront/core/user/signals.py @@ -1,5 +1,8 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.contrib.auth.models import User -from django.contrib.auth.signals import user_logged_in from django.db.models.signals import post_save from django.dispatch import receiver diff --git a/coldfront/core/user/tests.py b/coldfront/core/user/tests.py index c0c42bdc22..ccedfbefa8 100644 --- a/coldfront/core/user/tests.py +++ b/coldfront/core/user/tests.py @@ -1,27 +1,24 @@ -from coldfront.core.test_helpers.factories import UserFactory -from django.test import TestCase +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later -from coldfront.core.test_helpers.factories import ( - UserFactory, -) +from django.test import TestCase +from coldfront.core.test_helpers.factories import UserFactory from coldfront.core.user.models import UserProfile + class TestUserProfile(TestCase): class Data: """Collection of test data, separated for readability""" def __init__(self): - user = UserFactory(username='submitter') - - self.initial_fields = { - 'user': user, - 'is_pi': True, - 'id': user.id - } - + user = UserFactory(username="submitter") + + self.initial_fields = {"user": user, "is_pi": True, "id": user.id} + self.unsaved_object = UserProfile(**self.initial_fields) - + def setUp(self): self.data = self.Data() @@ -51,4 +48,4 @@ def test_user_on_delete(self): # expecting CASCADE with self.assertRaises(UserProfile.DoesNotExist): UserProfile.objects.get(pk=profile_obj.pk) - self.assertEqual(0, len(UserProfile.objects.all())) \ No newline at end of file + self.assertEqual(0, len(UserProfile.objects.all())) diff --git a/coldfront/core/user/urls.py b/coldfront/core/user/urls.py index b5b3fd0cf2..03b604beaf 100644 --- a/coldfront/core/user/urls.py +++ b/coldfront/core/user/urls.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.conf import settings from django.contrib.auth.views import LoginView, LogoutView -from django.urls import path, reverse_lazy +from django.urls import path import coldfront.core.user.views as user_views @@ -8,20 +12,24 @@ urlpatterns = [ - path('login', - LoginView.as_view( - template_name='user/login.html', - extra_context={'EXTRA_APPS': EXTRA_APPS}, - redirect_authenticated_user=True), - name='login' - ), - path('logout', LogoutView.as_view(), name='logout'), - path('user-profile/', user_views.UserProfile.as_view(), name='user-profile'), - path('user-profile/', user_views.UserProfile.as_view(), name='user-profile'), - path('user-projects-managers/', user_views.UserProjectsManagersView.as_view(), name='user-projects-managers'), - path('user-projects-managers/', user_views.UserProjectsManagersView.as_view(), name='user-projects-managers'), - path('user-upgrade/', user_views.UserUpgradeAccount.as_view(), name='user-upgrade'), - path('user-search-home/', user_views.UserSearchHome.as_view(), name='user-search-home'), - path('user-search-results/', user_views.UserSearchResults.as_view(), name='user-search-results'), - path('user-list-allocations/', user_views.UserListAllocations.as_view(), name='user-list-allocations'), + path( + "login", + LoginView.as_view( + template_name="user/login.html", extra_context={"EXTRA_APPS": EXTRA_APPS}, redirect_authenticated_user=True + ), + name="login", + ), + path("logout", LogoutView.as_view(), name="logout"), + path("user-profile/", user_views.UserProfile.as_view(), name="user-profile"), + path("user-profile/", user_views.UserProfile.as_view(), name="user-profile"), + path("user-projects-managers/", user_views.UserProjectsManagersView.as_view(), name="user-projects-managers"), + path( + "user-projects-managers/", + user_views.UserProjectsManagersView.as_view(), + name="user-projects-managers", + ), + path("user-upgrade/", user_views.UserUpgradeAccount.as_view(), name="user-upgrade"), + path("user-search-home/", user_views.UserSearchHome.as_view(), name="user-search-home"), + path("user-search-results/", user_views.UserSearchResults.as_view(), name="user-search-results"), + path("user-list-allocations/", user_views.UserListAllocations.as_view(), name="user-list-allocations"), ] diff --git a/coldfront/core/user/utils.py b/coldfront/core/user/utils.py index 8bd31290b7..0b9da4a235 100644 --- a/coldfront/core/user/utils.py +++ b/coldfront/core/user/utils.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import abc import logging @@ -9,22 +13,20 @@ logger = logging.getLogger(__name__) -class UserSearch(abc.ABC): +class UserSearch(abc.ABC): def __init__(self, user_search_string, search_by): self.user_search_string = user_search_string self.search_by = search_by @abc.abstractmethod - def search_a_user(self, user_search_string=None, search_by='all_fields'): + def search_a_user(self, user_search_string=None, search_by="all_fields"): pass def search(self): if len(self.user_search_string.split()) > 1: - search_by = 'username_only' + search_by = "username_only" matches = [] - number_of_usernames_found = 0 - users_not_found = [] user_search_string = sorted(list(set(self.user_search_string.split()))) for username in user_search_string: @@ -38,19 +40,23 @@ def search(self): class LocalUserSearch(UserSearch): - search_source = 'local' + search_source = "local" - def search_a_user(self, user_search_string=None, search_by='all_fields'): + def search_a_user(self, user_search_string=None, search_by="all_fields"): size_limit = 50 - if user_search_string and search_by == 'all_fields': - entries = User.objects.filter( - Q(username__icontains=user_search_string) | - Q(first_name__icontains=user_search_string) | - Q(last_name__icontains=user_search_string) | - Q(email__icontains=user_search_string) - ).filter(Q(is_active=True)).distinct()[:size_limit] - - elif user_search_string and search_by == 'username_only': + if user_search_string and search_by == "all_fields": + entries = ( + User.objects.filter( + Q(username__icontains=user_search_string) + | Q(first_name__icontains=user_search_string) + | Q(last_name__icontains=user_search_string) + | Q(email__icontains=user_search_string) + ) + .filter(Q(is_active=True)) + .distinct()[:size_limit] + ) + + elif user_search_string and search_by == "username_only": entries = User.objects.filter(username=user_search_string, is_active=True) else: entries = User.objects.all()[:size_limit] @@ -59,11 +65,11 @@ def search_a_user(self, user_search_string=None, search_by='all_fields'): for idx, user in enumerate(entries, 1): if user: user_dict = { - 'last_name': user.last_name, - 'first_name': user.first_name, - 'username': user.username, - 'email': user.email, - 'source': self.search_source, + "last_name": user.last_name, + "first_name": user.first_name, + "username": user.username, + "email": user.email, + "source": self.search_source, } users.append(user_dict) @@ -72,28 +78,25 @@ def search_a_user(self, user_search_string=None, search_by='all_fields'): class CombinedUserSearch: - def __init__(self, user_search_string, search_by, usernames_names_to_exclude=[]): - self.USER_SEARCH_CLASSES = import_from_settings('ADDITIONAL_USER_SEARCH_CLASSES', []) - self.USER_SEARCH_CLASSES.insert(0, 'coldfront.core.user.utils.LocalUserSearch') + self.USER_SEARCH_CLASSES = import_from_settings("ADDITIONAL_USER_SEARCH_CLASSES", []) + self.USER_SEARCH_CLASSES.insert(0, "coldfront.core.user.utils.LocalUserSearch") self.user_search_string = user_search_string self.search_by = search_by self.usernames_names_to_exclude = usernames_names_to_exclude def search(self): - matches = [] usernames_not_found = [] usernames_found = [] - for search_class in self.USER_SEARCH_CLASSES: cls = import_string(search_class) search_class_obj = cls(self.user_search_string, self.search_by) users = search_class_obj.search() for user in users: - username = user.get('username') + username = user.get("username") if username not in usernames_found and username not in self.usernames_names_to_exclude: usernames_found.append(username) matches.append(user) @@ -101,16 +104,18 @@ def search(self): if len(self.user_search_string.split()) > 1: number_of_usernames_searched = len(self.user_search_string.split()) number_of_usernames_found = len(usernames_found) - usernames_not_found = list(set(self.user_search_string.split()) - set(usernames_found) - set(self.usernames_names_to_exclude)) + usernames_not_found = list( + set(self.user_search_string.split()) - set(usernames_found) - set(self.usernames_names_to_exclude) + ) else: number_of_usernames_searched = None number_of_usernames_found = None usernames_not_found = None context = { - 'matches': matches, - 'number_of_usernames_searched': number_of_usernames_searched, - 'number_of_usernames_found': number_of_usernames_found, - 'usernames_not_found': usernames_not_found + "matches": matches, + "number_of_usernames_searched": number_of_usernames_searched, + "number_of_usernames_found": number_of_usernames_found, + "usernames_not_found": usernames_not_found, } return context diff --git a/coldfront/core/user/views.py b/coldfront/core/user/views.py index 50e9316d97..8c0f8e7aa6 100644 --- a/coldfront/core/user/views.py +++ b/coldfront/core/user/views.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from django.contrib import messages @@ -21,16 +25,15 @@ from coldfront.core.utils.mail import send_email_template logger = logging.getLogger(__name__) -EMAIL_ENABLED = import_from_settings('EMAIL_ENABLED', False) +EMAIL_ENABLED = import_from_settings("EMAIL_ENABLED", False) if EMAIL_ENABLED: - EMAIL_SENDER = import_from_settings('EMAIL_SENDER') - EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings( - 'EMAIL_TICKET_SYSTEM_ADDRESS') + EMAIL_SENDER = import_from_settings("EMAIL_SENDER") + EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings("EMAIL_TICKET_SYSTEM_ADDRESS") -@method_decorator(login_required, name='dispatch') +@method_decorator(login_required, name="dispatch") class UserProfile(TemplateView): - template_name = 'user/user_profile.html' + template_name = "user/user_profile.html" def dispatch(self, request, *args, viewed_username=None, **kwargs): # viewing another user profile requires permissions @@ -46,7 +49,7 @@ def dispatch(self, request, *args, viewed_username=None, **kwargs): messages.error(request, "You aren't allowed to view other user profiles!") # if they used their own username, no need to provide an error - just redirect - return HttpResponseRedirect(reverse('user-profile')) + return HttpResponseRedirect(reverse("user-profile")) return super().dispatch(request, *args, viewed_username=viewed_username, **kwargs) @@ -58,16 +61,15 @@ def get_context_data(self, viewed_username=None, **kwargs): else: viewed_user = self.request.user - group_list = ', '.join( - [group.name for group in viewed_user.groups.all()]) - context['group_list'] = group_list - context['viewed_user'] = viewed_user + group_list = ", ".join([group.name for group in viewed_user.groups.all()]) + context["group_list"] = group_list + context["viewed_user"] = viewed_user return context -@method_decorator(login_required, name='dispatch') +@method_decorator(login_required, name="dispatch") class UserProjectsManagersView(ListView): - template_name = 'user/user_projects_managers.html' + template_name = "user/user_projects_managers.html" def dispatch(self, request, *args, viewed_username=None, **kwargs): # viewing another user requires permissions @@ -83,7 +85,7 @@ def dispatch(self, request, *args, viewed_username=None, **kwargs): messages.error(request, "You aren't allowed to view projects for other users!") # if they used their own username, no need to provide an error - just redirect - return HttpResponseRedirect(reverse('user-projects-managers')) + return HttpResponseRedirect(reverse("user-projects-managers")) # get_queryset does not get kwargs, so we need to store it off here if viewed_username: @@ -97,77 +99,90 @@ def get_queryset(self, *args, **kwargs): viewed_user = self.viewed_user ongoing_projectuser_statuses = ( - 'Active', - 'Pending - Add', - 'Pending - Remove', + "Active", + "Pending - Add", + "Pending - Remove", ) ongoing_project_statuses = ( - 'New', - 'Active', + "New", + "Active", ) - qs = ProjectUser.objects.filter( - user=viewed_user, - status__name__in=ongoing_projectuser_statuses, - project__status__name__in=ongoing_project_statuses, - ).select_related( - 'status', - 'role', - 'project', - 'project__status', - 'project__field_of_science', - 'project__pi', - ).only( - 'status__name', - 'role__name', - 'project__title', - 'project__status__name', - 'project__field_of_science__description', - 'project__pi__username', - 'project__pi__first_name', - 'project__pi__last_name', - 'project__pi__email', - ).annotate( - is_project_pi=ExpressionWrapper( - Q(user=F('project__pi')), - output_field=BooleanField(), - ), - is_project_manager=ExpressionWrapper( - Q(role__name='Manager'), - output_field=BooleanField(), - ), - ).order_by( - '-is_project_pi', - '-is_project_manager', - Lower('project__pi__username').asc(), - Lower('project__title').asc(), - # unlikely things will get to this point unless there's almost-duplicate projects - '-project__pk', # more performant stand-in for '-project__created' - ).prefetch_related( - Prefetch( - lookup='project__projectuser_set', - queryset=ProjectUser.objects.filter( - role__name='Manager', - status__name__in=ongoing_projectuser_statuses, - ).exclude( - user__pk__in=[ - F('project__pi__pk'), # we assume pi is 'Manager' or can act like one - no need to list twice - viewed_user.pk, # we display elsewhere if the user is a manager of this project - ], - ).select_related( - 'status', - 'user', - ).only( - 'status__name', - 'user__username', - 'user__first_name', - 'user__last_name', - 'user__email', - ).order_by( - 'user__username', + qs = ( + ProjectUser.objects.filter( + user=viewed_user, + status__name__in=ongoing_projectuser_statuses, + project__status__name__in=ongoing_project_statuses, + ) + .select_related( + "status", + "role", + "project", + "project__status", + "project__field_of_science", + "project__pi", + ) + .only( + "status__name", + "role__name", + "project__title", + "project__status__name", + "project__field_of_science__description", + "project__pi__username", + "project__pi__first_name", + "project__pi__last_name", + "project__pi__email", + ) + .annotate( + is_project_pi=ExpressionWrapper( + Q(user=F("project__pi")), + output_field=BooleanField(), + ), + is_project_manager=ExpressionWrapper( + Q(role__name="Manager"), + output_field=BooleanField(), ), - to_attr='project_managers', - ), + ) + .order_by( + "-is_project_pi", + "-is_project_manager", + Lower("project__pi__username").asc(), + Lower("project__title").asc(), + # unlikely things will get to this point unless there's almost-duplicate projects + "-project__pk", # more performant stand-in for '-project__created' + ) + .prefetch_related( + Prefetch( + lookup="project__projectuser_set", + queryset=ProjectUser.objects.filter( + role__name="Manager", + status__name__in=ongoing_projectuser_statuses, + ) + .exclude( + user__pk__in=[ + F( + "project__pi__pk" + ), # we assume pi is 'Manager' or can act like one - no need to list twice + viewed_user.pk, # we display elsewhere if the user is a manager of this project + ], + ) + .select_related( + "status", + "user", + ) + .only( + "status__name", + "user__username", + "user__first_name", + "user__last_name", + "user__email", + ) + .order_by( + "user__username", + ), + to_attr="project_managers", + ), + ) ) return qs @@ -175,54 +190,53 @@ def get_queryset(self, *args, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['viewed_user'] = self.viewed_user + context["viewed_user"] = self.viewed_user if self.request.user == self.viewed_user: - context['user_pronounish'] = 'You' - context['user_verbform_be'] = 'are' + context["user_pronounish"] = "You" + context["user_verbform_be"] = "are" else: - context['user_pronounish'] = 'This user' - context['user_verbform_be'] = 'is' + context["user_pronounish"] = "This user" + context["user_verbform_be"] = "is" return context class UserUpgradeAccount(LoginRequiredMixin, UserPassesTestMixin, View): - def test_func(self): return True def dispatch(self, request, *args, **kwargs): if request.user.is_superuser: - messages.error(request, 'You are already a super user') - return HttpResponseRedirect(reverse('user-profile')) + messages.error(request, "You are already a super user") + return HttpResponseRedirect(reverse("user-profile")) if request.user.userprofile.is_pi: - messages.error(request, 'Your account has already been upgraded') - return HttpResponseRedirect(reverse('user-profile')) + messages.error(request, "Your account has already been upgraded") + return HttpResponseRedirect(reverse("user-profile")) return super().dispatch(request, *args, **kwargs) def post(self, request): if EMAIL_ENABLED: send_email_template( - 'Upgrade Account Request', - 'email/upgrade_account_request.txt', - {'user': request.user}, + "Upgrade Account Request", + "email/upgrade_account_request.txt", + {"user": request.user}, EMAIL_SENDER, - [EMAIL_TICKET_SYSTEM_ADDRESS] + [EMAIL_TICKET_SYSTEM_ADDRESS], ) - messages.success(request, 'Your request has been sent') - return HttpResponseRedirect(reverse('user-profile')) + messages.success(request, "Your request has been sent") + return HttpResponseRedirect(reverse("user-profile")) class UserSearchHome(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'user/user_search_home.html' + template_name = "user/user_search_home.html" def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['user_search_form'] = UserSearchForm() + context["user_search_form"] = UserSearchForm() return context def test_func(self): @@ -230,16 +244,15 @@ def test_func(self): class UserSearchResults(LoginRequiredMixin, UserPassesTestMixin, View): - template_name = 'user/user_search_results.html' + template_name = "user/user_search_results.html" raise_exception = True def post(self, request): - user_search_string = request.POST.get('q') + user_search_string = request.POST.get("q") - search_by = request.POST.get('search_by') + search_by = request.POST.get("search_by") - cobmined_user_search_obj = CombinedUserSearch( - user_search_string, search_by) + cobmined_user_search_obj = CombinedUserSearch(user_search_string, search_by) context = cobmined_user_search_obj.search() return render(request, self.template_name, context) @@ -249,7 +262,7 @@ def test_func(self): class UserListAllocations(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - template_name = 'user/user_list_allocations.html' + template_name = "user/user_list_allocations.html" def test_func(self): return self.request.user.is_superuser or self.request.user.userprofile.is_pi @@ -260,13 +273,15 @@ def get_context_data(self, *args, **kwargs): user_dict = {} for project in Project.objects.filter(pi=self.request.user): - for allocation in project.allocation_set.filter(status__name='Active'): - for allocation_user in allocation.allocationuser_set.filter(status__name='Active').order_by('user__username'): + for allocation in project.allocation_set.filter(status__name="Active"): + for allocation_user in allocation.allocationuser_set.filter(status__name="Active").order_by( + "user__username" + ): if allocation_user.user not in user_dict: user_dict[allocation_user.user] = [] user_dict[allocation_user.user].append(allocation) - context['user_dict'] = user_dict + context["user_dict"] = user_dict return context diff --git a/coldfront/core/utils/__init__.py b/coldfront/core/utils/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/__init__.py +++ b/coldfront/core/utils/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/admin.py b/coldfront/core/utils/admin.py index 8c38f3f3da..97070bc06b 100644 --- a/coldfront/core/utils/admin.py +++ b/coldfront/core/utils/admin.py @@ -1,3 +1,5 @@ -from django.contrib import admin +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Register your models here. diff --git a/coldfront/core/utils/apps.py b/coldfront/core/utils/apps.py index 16ceeb1a3a..68d0c7f8b7 100644 --- a/coldfront/core/utils/apps.py +++ b/coldfront/core/utils/apps.py @@ -1,6 +1,10 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class UtilsConfig(AppConfig): - name = 'coldfront.core.utils' - verbose_name = 'Coldfront Utils' + name = "coldfront.core.utils" + verbose_name = "Coldfront Utils" diff --git a/coldfront/core/utils/common.py b/coldfront/core/utils/common.py index f1c05bc1e2..a1c67e343f 100644 --- a/coldfront/core/utils/common.py +++ b/coldfront/core/utils/common.py @@ -1,4 +1,7 @@ -import datetime +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + # import the logging library import logging @@ -21,11 +24,11 @@ def import_from_settings(attr, *args): return getattr(settings, attr, args[0]) return getattr(settings, attr) except AttributeError: - raise ImproperlyConfigured('Setting {0} not found'.format(attr)) + raise ImproperlyConfigured("Setting {0} not found".format(attr)) def get_domain_url(request): - return request.build_absolute_uri().replace(request.get_full_path(), '') + return request.build_absolute_uri().replace(request.get_full_path(), "") class Echo: @@ -39,11 +42,9 @@ def write(self, value): def su_login_callback(user): - """Only superusers are allowed to login as other users - """ + """Only superusers are allowed to login as other users""" if user.is_active and user.is_superuser: return True - logger.warn( - 'User {} requested to login as another user but does not have permissions', user) + logger.warn("User {} requested to login as another user but does not have permissions", user) return False diff --git a/coldfront/core/utils/fixtures/__init__.py b/coldfront/core/utils/fixtures/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/fixtures/__init__.py +++ b/coldfront/core/utils/fixtures/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/mail.py b/coldfront/core/utils/mail.py index 5305908da2..6e4d442ed1 100644 --- a/coldfront/core/utils/mail.py +++ b/coldfront/core/utils/mail.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from smtplib import SMTPException @@ -9,33 +13,33 @@ from coldfront.core.utils.common import import_from_settings logger = logging.getLogger(__name__) -EMAIL_ENABLED = import_from_settings('EMAIL_ENABLED', False) -EMAIL_SUBJECT_PREFIX = import_from_settings('EMAIL_SUBJECT_PREFIX') -EMAIL_DEVELOPMENT_EMAIL_LIST = import_from_settings('EMAIL_DEVELOPMENT_EMAIL_LIST') -EMAIL_SENDER = import_from_settings('EMAIL_SENDER') -EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings('EMAIL_TICKET_SYSTEM_ADDRESS') -EMAIL_OPT_OUT_INSTRUCTION_URL = import_from_settings('EMAIL_OPT_OUT_INSTRUCTION_URL') -EMAIL_SIGNATURE = import_from_settings('EMAIL_SIGNATURE') -EMAIL_CENTER_NAME = import_from_settings('CENTER_NAME') -CENTER_BASE_URL = import_from_settings('CENTER_BASE_URL') +EMAIL_ENABLED = import_from_settings("EMAIL_ENABLED", False) +EMAIL_SUBJECT_PREFIX = import_from_settings("EMAIL_SUBJECT_PREFIX") +EMAIL_DEVELOPMENT_EMAIL_LIST = import_from_settings("EMAIL_DEVELOPMENT_EMAIL_LIST") +EMAIL_SENDER = import_from_settings("EMAIL_SENDER") +EMAIL_TICKET_SYSTEM_ADDRESS = import_from_settings("EMAIL_TICKET_SYSTEM_ADDRESS") +EMAIL_OPT_OUT_INSTRUCTION_URL = import_from_settings("EMAIL_OPT_OUT_INSTRUCTION_URL") +EMAIL_SIGNATURE = import_from_settings("EMAIL_SIGNATURE") +EMAIL_CENTER_NAME = import_from_settings("CENTER_NAME") +CENTER_BASE_URL = import_from_settings("CENTER_BASE_URL") + def send_email(subject, body, sender, receiver_list, cc=[]): - """Helper function for sending emails - """ + """Helper function for sending emails""" if not EMAIL_ENABLED: return if len(receiver_list) == 0: - logger.error('Failed to send email missing receiver_list') + logger.error("Failed to send email missing receiver_list") return if len(sender) == 0: - logger.error('Failed to send email missing sender address') + logger.error("Failed to send email missing sender address") return if len(EMAIL_SUBJECT_PREFIX) > 0: - subject = EMAIL_SUBJECT_PREFIX + ' ' + subject + subject = EMAIL_SUBJECT_PREFIX + " " + subject if settings.DEBUG: receiver_list = EMAIL_DEVELOPMENT_EMAIL_LIST @@ -45,127 +49,118 @@ def send_email(subject, body, sender, receiver_list, cc=[]): try: if cc: - email = EmailMessage( - subject, - body, - sender, - receiver_list, - cc=cc) + email = EmailMessage(subject, body, sender, receiver_list, cc=cc) email.send(fail_silently=False) else: - send_mail(subject, body, sender, - receiver_list, fail_silently=False) - except SMTPException as e: - logger.error('Failed to send email from %s to %s with subject %s', - sender, ','.join(receiver_list), subject) + send_mail(subject, body, sender, receiver_list, fail_silently=False) + except SMTPException: + logger.error("Failed to send email from %s to %s with subject %s", sender, ",".join(receiver_list), subject) -def send_email_template(subject, template_name, template_context, sender, receiver_list, cc = []): - """Helper function for sending emails from a template - """ +def send_email_template(subject, template_name, template_context, sender, receiver_list, cc=[]): + """Helper function for sending emails from a template""" if not EMAIL_ENABLED: return body = render_to_string(template_name, template_context) - return send_email(subject, body, sender, receiver_list, cc = cc) + return send_email(subject, body, sender, receiver_list, cc=cc) + def email_template_context(): - """Basic email template context used as base for all templates - """ + """Basic email template context used as base for all templates""" return { - 'center_name': EMAIL_CENTER_NAME, - 'signature': EMAIL_SIGNATURE, - 'opt_out_instruction_url': EMAIL_OPT_OUT_INSTRUCTION_URL + "center_name": EMAIL_CENTER_NAME, + "signature": EMAIL_SIGNATURE, + "opt_out_instruction_url": EMAIL_OPT_OUT_INSTRUCTION_URL, } -def build_link(url_path, domain_url=''): + +def build_link(url_path, domain_url=""): if not domain_url: domain_url = CENTER_BASE_URL - return f'{domain_url}{url_path}' + return f"{domain_url}{url_path}" + def send_admin_email_template(subject, template_name, template_context): - """Helper function for sending admin emails using a template - """ - send_email_template(subject, template_name, template_context, EMAIL_SENDER, [EMAIL_TICKET_SYSTEM_ADDRESS, ]) + """Helper function for sending admin emails using a template""" + send_email_template( + subject, + template_name, + template_context, + EMAIL_SENDER, + [ + EMAIL_TICKET_SYSTEM_ADDRESS, + ], + ) -def send_allocation_admin_email(allocation_obj, subject, template_name, url_path='', domain_url=''): - """Send allocation admin emails - """ + +def send_allocation_admin_email(allocation_obj, subject, template_name, url_path="", domain_url=""): + """Send allocation admin emails""" if not url_path: - url_path = reverse('allocation-request-list') + url_path = reverse("allocation-request-list") url = build_link(url_path, domain_url=domain_url) - pi_name = f'{allocation_obj.project.pi.first_name} {allocation_obj.project.pi.last_name} ({allocation_obj.project.pi.username})' + pi_name = f"{allocation_obj.project.pi.first_name} {allocation_obj.project.pi.last_name} ({allocation_obj.project.pi.username})" resource_name = allocation_obj.get_parent_resource ctx = email_template_context() - ctx['pi'] = pi_name - ctx['resource'] = resource_name - ctx['url'] = url + ctx["pi"] = pi_name + ctx["resource"] = resource_name + ctx["url"] = url send_admin_email_template( - f'{subject}: {pi_name} - {resource_name}', + f"{subject}: {pi_name} - {resource_name}", template_name, ctx, ) -def send_allocation_customer_email(allocation_obj, subject, template_name, url_path='', domain_url=''): - """Send allocation customer emails - """ + +def send_allocation_customer_email(allocation_obj, subject, template_name, url_path="", domain_url=""): + """Send allocation customer emails""" if not url_path: - url_path = reverse('allocation-detail', kwargs={'pk': allocation_obj.pk}) + url_path = reverse("allocation-detail", kwargs={"pk": allocation_obj.pk}) url = build_link(url_path, domain_url=domain_url) ctx = email_template_context() - ctx['resource'] = allocation_obj.get_parent_resource - ctx['url'] = url + ctx["resource"] = allocation_obj.get_parent_resource + ctx["url"] = url - allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=['Removed', 'Error']) + allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed", "Error"]) email_receiver_list = [] for allocation_user in allocation_users: - if allocation_user.allocation.project.projectuser_set.get( - user=allocation_user.user).enable_notifications: + if allocation_user.allocation.project.projectuser_set.get(user=allocation_user.user).enable_notifications: email_receiver_list.append(allocation_user.user.email) - send_email_template( - subject, - template_name, - ctx, - EMAIL_SENDER, - email_receiver_list - ) - -def send_allocation_eula_customer_email(allocation_user, subject, template_name, url_path='', domain_url='', cc_managers=False, include_eula=False): - """Send allocation customer emails - """ - + send_email_template(subject, template_name, ctx, EMAIL_SENDER, email_receiver_list) + + +def send_allocation_eula_customer_email( + allocation_user, subject, template_name, url_path="", domain_url="", cc_managers=False, include_eula=False +): + """Send allocation customer emails""" + allocation_obj = allocation_user.allocation if not url_path: - url_path = reverse('allocation-review-eula', kwargs={'pk': allocation_obj.pk}) + url_path = reverse("allocation-review-eula", kwargs={"pk": allocation_obj.pk}) url = build_link(url_path, domain_url=domain_url) ctx = email_template_context() - ctx['resource'] = allocation_obj.get_parent_resource - ctx['url'] = url - ctx['allocation_user'] = "{} {} ({})".format(allocation_user.user.first_name, allocation_user.user.last_name, allocation_user.user.username) + ctx["resource"] = allocation_obj.get_parent_resource + ctx["url"] = url + ctx["allocation_user"] = "{} {} ({})".format( + allocation_user.user.first_name, allocation_user.user.last_name, allocation_user.user.username + ) if include_eula: - ctx['eula'] = allocation_obj.get_eula() - + ctx["eula"] = allocation_obj.get_eula() + email_receiver_list = [allocation_user.user.email] email_cc_list = [] if cc_managers: project_obj = allocation_obj.project - managers = project_obj.projectuser_set.filter(role__name='Manager', status__name='Active') + managers = project_obj.projectuser_set.filter(role__name="Manager", status__name="Active") for manager in managers: if manager.enable_notifications: email_cc_list.append(manager.user.email) - send_email_template( - subject, - template_name, - ctx, - EMAIL_SENDER, - email_receiver_list, - cc=email_cc_list - ) + send_email_template(subject, template_name, ctx, EMAIL_SENDER, email_receiver_list, cc=email_cc_list) diff --git a/coldfront/core/utils/management/__init__.py b/coldfront/core/utils/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/management/__init__.py +++ b/coldfront/core/utils/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/management/commands/__init__.py b/coldfront/core/utils/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/management/commands/__init__.py +++ b/coldfront/core/utils/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/management/commands/add_scheduled_tasks.py b/coldfront/core/utils/management/commands/add_scheduled_tasks.py index d9e91799b5..9da270fb4b 100644 --- a/coldfront/core/utils/management/commands/add_scheduled_tasks.py +++ b/coldfront/core/utils/management/commands/add_scheduled_tasks.py @@ -1,32 +1,31 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import os from django.conf import settings -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from django.utils import timezone -from coldfront.config.email import EMAIL_ALLOCATION_EULA_REMINDERS from django_q.models import Schedule from django_q.tasks import schedule + +from coldfront.config.email import EMAIL_ALLOCATION_EULA_REMINDERS from coldfront.core.utils.common import import_from_settings -ALLOCATION_EULA_ENABLE = import_from_settings('ALLOCATION_EULA_ENABLE', False) +ALLOCATION_EULA_ENABLE = import_from_settings("ALLOCATION_EULA_ENABLE", False) base_dir = settings.BASE_DIR -class Command(BaseCommand): +class Command(BaseCommand): def handle(self, *args, **options): - date = timezone.now() + datetime.timedelta(days=1) date = date.replace(hour=0, minute=0, second=0, microsecond=0) - schedule('coldfront.core.allocation.tasks.update_statuses', - schedule_type=Schedule.DAILY, - next_run=date) - - schedule('coldfront.core.allocation.tasks.send_expiry_emails', - schedule_type=Schedule.DAILY, - next_run=date) - + schedule("coldfront.core.allocation.tasks.update_statuses", schedule_type=Schedule.DAILY, next_run=date) + + schedule("coldfront.core.allocation.tasks.send_expiry_emails", schedule_type=Schedule.DAILY, next_run=date) + if ALLOCATION_EULA_ENABLE and EMAIL_ALLOCATION_EULA_REMINDERS: - schedule('coldfront.core.allocation.tasks.send_eula_reminders', - schedule_type=Schedule.WEEKLY, - next_run=date) + schedule( + "coldfront.core.allocation.tasks.send_eula_reminders", schedule_type=Schedule.WEEKLY, next_run=date + ) diff --git a/coldfront/core/utils/management/commands/initial_setup.py b/coldfront/core/utils/management/commands/initial_setup.py index 421c2712c2..dac9e2a7b5 100644 --- a/coldfront/core/utils/management/commands/initial_setup.py +++ b/coldfront/core/utils/management/commands/initial_setup.py @@ -1,4 +1,6 @@ -import os +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later from django.conf import settings from django.core.management import call_command @@ -8,31 +10,35 @@ class Command(BaseCommand): - help = 'Run setup script to initialize the Coldfront database' + help = "Run setup script to initialize the Coldfront database" def add_arguments(self, parser): - parser.add_argument("-f", "--force_overwrite", help="Force intial_setup script to run with no warning.", action="store_true") + parser.add_argument( + "-f", "--force_overwrite", help="Force intial_setup script to run with no warning.", action="store_true" + ) def handle(self, *args, **options): - if options['force_overwrite']: - run_setup() + if options["force_overwrite"]: + run_setup() + + else: + print( + """WARNING: Running this command initializes the ColdFront database and may modify/delete data in your existing ColdFront database. This command is typically only run once.""" + ) + user_response = input("Do you want to proceed?(yes):") + if user_response == "yes": + run_setup() else: - print("""WARNING: Running this command initializes the ColdFront database and may modify/delete data in your existing ColdFront database. This command is typically only run once.""") - user_response = input("Do you want to proceed?(yes):") - - if user_response == "yes": - run_setup() - else: - print("Please enter 'yes' if you wish to run intital setup.") + print("Please enter 'yes' if you wish to run intital setup.") -def run_setup(): - call_command('migrate') - call_command('import_field_of_science_data') - call_command('add_default_grant_options') - call_command('add_default_project_choices') - call_command('add_resource_defaults') - call_command('add_allocation_defaults') - call_command('add_default_publication_sources') - call_command('add_scheduled_tasks') +def run_setup(): + call_command("migrate") + call_command("import_field_of_science_data") + call_command("add_default_grant_options") + call_command("add_default_project_choices") + call_command("add_resource_defaults") + call_command("add_allocation_defaults") + call_command("add_default_publication_sources") + call_command("add_scheduled_tasks") diff --git a/coldfront/core/utils/management/commands/load_test_data.py b/coldfront/core/utils/management/commands/load_test_data.py index c21f03649e..26923877ab 100644 --- a/coldfront/core/utils/management/commands/load_test_data.py +++ b/coldfront/core/utils/management/commands/load_test_data.py @@ -1,175 +1,184 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime -import os from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import User -from django.core.management import call_command from django.core.management.base import BaseCommand -from coldfront.core.allocation.models import (Allocation, AllocationAttribute, - AllocationAttributeType, - AllocationStatusChoice, - AllocationUser, - AllocationUserStatusChoice) +from coldfront.core.allocation.models import ( + Allocation, + AllocationAttribute, + AllocationAttributeType, + AllocationStatusChoice, + AllocationUser, + AllocationUserStatusChoice, +) from coldfront.core.field_of_science.models import FieldOfScience -from coldfront.core.grant.models import (Grant, GrantFundingAgency, - GrantStatusChoice) -from coldfront.core.project.models import (Project, ProjectStatusChoice, - ProjectUser, ProjectUserRoleChoice, - ProjectUserStatusChoice, ProjectAttribute - , ProjectAttributeType, AttributeType) +from coldfront.core.grant.models import Grant, GrantFundingAgency, GrantStatusChoice +from coldfront.core.project.models import ( + AttributeType, + Project, + ProjectAttribute, + ProjectAttributeType, + ProjectStatusChoice, + ProjectUser, + ProjectUserRoleChoice, + ProjectUserStatusChoice, +) from coldfront.core.publication.models import Publication, PublicationSource -from coldfront.core.resource.models import (Resource, ResourceAttribute, - ResourceAttributeType, - ResourceType) -from coldfront.core.user.models import UserProfile +from coldfront.core.resource.models import Resource, ResourceAttribute, ResourceAttributeType, ResourceType base_dir = settings.BASE_DIR # first, last -Users = ['Carl Gray', # PI#1 - 'Stephanie Foster', # PI#2 - 'Charles Simmons', # Director - 'Andrea Stewart', - 'Alice Rivera', - 'Frank Hernandez', - 'Justin James', - 'Randy Perry', - 'Carol Lee', - 'Susan Hughes', - 'Jose Martin', - 'Joe Roberts', - 'Howard Nelson', - 'Patricia Moore', - 'Jessica Alexander', - 'Jesse Russell', - 'Shirley Price', - 'Julie Phillips', - 'Kathy Jenkins', - 'James Hill', - 'Tammy Howard', - 'Lisa Coleman', - 'Denise Adams', - 'Shawn Williams', - 'Ernest Reed', - 'Larry Ramirez', - 'Kathleen Garcia', - 'Jennifer Jones', - 'Irene Anderson', - 'Beverly Mitchell', - 'Peter Patterson', - 'Eugene Griffin', - 'Jimmy Lewis', - 'Margaret Turner', - 'Julia Peterson', - 'Amanda Johnson', - 'Christina Morris', - 'Cynthia Carter', - 'Wayne Murphy', - 'Ronald Sanders', - 'Lillian Bell', - 'Harold Lopez', - 'Roger Wilson', - 'Jane Edwards', - 'Billy Perez', - 'Jane Butler', - 'John Smith', - 'John Long', - 'Jane Martinez', - 'John Cooper', ] +Users = [ + "Carl Gray", # PI#1 + "Stephanie Foster", # PI#2 + "Charles Simmons", # Director + "Andrea Stewart", + "Alice Rivera", + "Frank Hernandez", + "Justin James", + "Randy Perry", + "Carol Lee", + "Susan Hughes", + "Jose Martin", + "Joe Roberts", + "Howard Nelson", + "Patricia Moore", + "Jessica Alexander", + "Jesse Russell", + "Shirley Price", + "Julie Phillips", + "Kathy Jenkins", + "James Hill", + "Tammy Howard", + "Lisa Coleman", + "Denise Adams", + "Shawn Williams", + "Ernest Reed", + "Larry Ramirez", + "Kathleen Garcia", + "Jennifer Jones", + "Irene Anderson", + "Beverly Mitchell", + "Peter Patterson", + "Eugene Griffin", + "Jimmy Lewis", + "Margaret Turner", + "Julia Peterson", + "Amanda Johnson", + "Christina Morris", + "Cynthia Carter", + "Wayne Murphy", + "Ronald Sanders", + "Lillian Bell", + "Harold Lopez", + "Roger Wilson", + "Jane Edwards", + "Billy Perez", + "Jane Butler", + "John Smith", + "John Long", + "Jane Martinez", + "John Cooper", +] dois = [ - '10.1016/j.nuclphysb.2014.08.011', - '10.1103/PhysRevB.81.014411', - '10.1103/PhysRevB.82.014421', - '10.1103/PhysRevB.83.014401', - '10.1103/PhysRevB.84.014503', - '10.1103/PhysRevB.85.014111', - '10.1103/PhysRevB.92.014205', - '10.1103/PhysRevB.91.140409', + "10.1016/j.nuclphysb.2014.08.011", + "10.1103/PhysRevB.81.014411", + "10.1103/PhysRevB.82.014421", + "10.1103/PhysRevB.83.014401", + "10.1103/PhysRevB.84.014503", + "10.1103/PhysRevB.85.014111", + "10.1103/PhysRevB.92.014205", + "10.1103/PhysRevB.91.140409", ] # resource_type, parent_resource, name, description, is_available, is_public, is_allocatable resources = [ - # Clusters - ('Cluster', None, 'University HPC', - 'University Academic Cluster', True, True, True), - ('Cluster', None, 'Chemistry', 'Chemistry Cluster', True, False, False), - ('Cluster', None, 'Physics', 'Physics Cluster', True, False, False), - ('Cluster', None, 'Industry', 'Industry Cluster', True, False, False), - ('Cluster', None, 'University Metered HPC', 'SU metered Cluster', - True, True, True), - + ("Cluster", None, "University HPC", "University Academic Cluster", True, True, True), + ("Cluster", None, "Chemistry", "Chemistry Cluster", True, False, False), + ("Cluster", None, "Physics", "Physics Cluster", True, False, False), + ("Cluster", None, "Industry", "Industry Cluster", True, False, False), + ("Cluster", None, "University Metered HPC", "SU metered Cluster", True, True, True), # Cluster Partitions scavengers - ('Cluster Partition', 'Chemistry', 'Chemistry-scavenger', - 'Scavenger partition on Chemistry cluster', True, False, False), - ('Cluster Partition', 'Physics', 'Physics-scavenger', - 'Scavenger partition on Physics cluster', True, False, False), - ('Cluster Partition', 'Industry', 'Industry-scavenger', - 'Scavenger partition on Industry cluster', True, False, False), - + ( + "Cluster Partition", + "Chemistry", + "Chemistry-scavenger", + "Scavenger partition on Chemistry cluster", + True, + False, + False, + ), + ("Cluster Partition", "Physics", "Physics-scavenger", "Scavenger partition on Physics cluster", True, False, False), + ( + "Cluster Partition", + "Industry", + "Industry-scavenger", + "Scavenger partition on Industry cluster", + True, + False, + False, + ), # Cluster Partitions Users - ('Cluster Partition', 'Chemistry', 'Chemistry-cgray', - "Carl Gray's nodes", True, False, True), - ('Cluster Partition', 'Physics', 'Physics-sfoster', - "Stephanie Foster's nodes", True, False, True), - + ("Cluster Partition", "Chemistry", "Chemistry-cgray", "Carl Gray's nodes", True, False, True), + ("Cluster Partition", "Physics", "Physics-sfoster", "Stephanie Foster's nodes", True, False, True), # Servers - ('Server', None, 'server-cgray', - "Server for Carl Gray's research lab", True, False, True), - ('Server', None, 'server-sfoster', - "Server for Stephanie Foster's research lab", True, False, True), - + ("Server", None, "server-cgray", "Server for Carl Gray's research lab", True, False, True), + ("Server", None, "server-sfoster", "Server for Stephanie Foster's research lab", True, False, True), # Storage - ('Storage', None, 'Budgetstorage', - 'Low-tier storage option - NOT BACKED UP', True, True, True), - ('Storage', None, 'ProjectStorage', - 'Enterprise-level storage - BACKED UP DAILY', True, True, True), - + ("Storage", None, "Budgetstorage", "Low-tier storage option - NOT BACKED UP", True, True, True), + ("Storage", None, "ProjectStorage", "Enterprise-level storage - BACKED UP DAILY", True, True, True), # Cloud - ('Cloud', None, 'University Cloud', - 'University Research Cloud', True, True, True), - ('Storage', 'University Cloud', 'University Cloud Storage', - 'Storage available to cloud instances', True, True, True), - + ("Cloud", None, "University Cloud", "University Research Cloud", True, True, True), + ( + "Storage", + "University Cloud", + "University Cloud Storage", + "Storage available to cloud instances", + True, + True, + True, + ), ] class Command(BaseCommand): - def handle(self, *args, **options): - for user in Users: first_name, last_name = user.split() - username = first_name[0].lower()+last_name.lower().strip() - email = username + '@example.com' + username = first_name[0].lower() + last_name.lower().strip() + email = username + "@example.com" User.objects.get_or_create( first_name=first_name.strip(), last_name=last_name.strip(), username=username.strip(), - email=email.strip() + email=email.strip(), ) - admin_user, _ = User.objects.get_or_create(username='admin') + admin_user, _ = User.objects.get_or_create(username="admin") admin_user.is_superuser = True admin_user.is_staff = True admin_user.save() for user in User.objects.all(): - user.set_password('test1234') + user.set_password("test1234") user.save() for resource in resources: - resource_type, parent_resource, name, description, is_available, is_public, is_allocatable = resource resource_type_obj = ResourceType.objects.get(name=resource_type) - if parent_resource != None: - parent_resource_obj = Resource.objects.get( - name=parent_resource) + if parent_resource is not None: + parent_resource_obj = Resource.objects.get(name=parent_resource) else: parent_resource_obj = None @@ -180,62 +189,63 @@ def handle(self, *args, **options): description=description, is_available=is_available, is_public=is_public, - is_allocatable=is_allocatable + is_allocatable=is_allocatable, ) - resource_obj = Resource.objects.get(name='server-cgray') - resource_obj.allowed_users.add(User.objects.get(username='cgray')) - resource_obj = Resource.objects.get(name='server-sfoster') - resource_obj.allowed_users.add(User.objects.get(username='sfoster')) + resource_obj = Resource.objects.get(name="server-cgray") + resource_obj.allowed_users.add(User.objects.get(username="cgray")) + resource_obj = Resource.objects.get(name="server-sfoster") + resource_obj.allowed_users.add(User.objects.get(username="sfoster")) - pi1 = User.objects.get(username='cgray') + pi1 = User.objects.get(username="cgray") pi1.userprofile.is_pi = True pi1.save() project_obj, _ = Project.objects.get_or_create( pi=pi1, - title='Angular momentum in QGP holography', - description='We want to estimate the quark chemical potential of a rotating sample of plasma.', - field_of_science=FieldOfScience.objects.get( - description='Chemistry'), - status=ProjectStatusChoice.objects.get(name='Active'), - force_review=True + title="Angular momentum in QGP holography", + description="We want to estimate the quark chemical potential of a rotating sample of plasma.", + field_of_science=FieldOfScience.objects.get(description="Chemistry"), + status=ProjectStatusChoice.objects.get(name="Active"), + force_review=True, ) - AttributeType.objects.get_or_create( - name='Int' - ) + AttributeType.objects.get_or_create(name="Int") ProjectAttributeType.objects.get_or_create( - attribute_type=AttributeType.objects.get(name='Text'), - name='Project ID', + attribute_type=AttributeType.objects.get(name="Text"), + name="Project ID", is_private=False, ) ProjectAttributeType.objects.get_or_create( - attribute_type=AttributeType.objects.get(name='Int'), - name='Account Number', + attribute_type=AttributeType.objects.get(name="Int"), + name="Account Number", is_private=True, ) ProjectAttribute.objects.get_or_create( - proj_attr_type=ProjectAttributeType.objects.get(name='Project ID'), + proj_attr_type=ProjectAttributeType.objects.get(name="Project ID"), project=project_obj, value=1242021, ) ProjectAttribute.objects.get_or_create( - proj_attr_type=ProjectAttributeType.objects.get(name='Account Number'), + proj_attr_type=ProjectAttributeType.objects.get(name="Account Number"), project=project_obj, value=1756522, ) - univ_hpc = Resource.objects.get(name='University HPC') - for scavanger in ('Chemistry-scavenger', 'Physics-scavenger', 'Industry-scavenger', ): + univ_hpc = Resource.objects.get(name="University HPC") + for scavanger in ( + "Chemistry-scavenger", + "Physics-scavenger", + "Industry-scavenger", + ): resource_obj = Resource.objects.get(name=scavanger) univ_hpc.linked_resources.add(resource_obj) univ_hpc.save() - publication_source = PublicationSource.objects.get(name='doi') + publication_source = PublicationSource.objects.get(name="doi") # for title, author, year, unique_id, source in ( # ('Angular momentum in QGP holography', 'Brett McInnes', # 2014, '10.1016/j.nuclphysb.2014.08.011', 'doi'), @@ -289,8 +299,8 @@ def handle(self, *args, **options): project_user_obj, _ = ProjectUser.objects.get_or_create( user=pi1, project=project_obj, - role=ProjectUserRoleChoice.objects.get(name='Manager'), - status=ProjectUserStatusChoice.objects.get(name='Active') + role=ProjectUserRoleChoice.objects.get(name="Manager"), + status=ProjectUserStatusChoice.objects.get(name="Active"), ) start_date = datetime.datetime.now() @@ -299,188 +309,164 @@ def handle(self, *args, **options): # Add PI cluster allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, is_changeable=True, - justification='I need access to my nodes.' + justification="I need access to my nodes.", ) - allocation_obj.resources.add( - Resource.objects.get(name='Chemistry-cgray')) + allocation_obj.resources.add(Resource.objects.get(name="Chemistry-cgray")) allocation_obj.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_account_name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_account_name") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='cgray') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="cgray" + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_user_specs') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_user_specs") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='Fairshare=parent') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="Fairshare=parent" + ) - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi1, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi1, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Add university cluster allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=datetime.datetime.now() + relativedelta(days=10), is_changeable=True, - justification='I need access to university cluster.' + justification="I need access to university cluster.", ) - allocation_obj.resources.add( - Resource.objects.get(name='University HPC')) + allocation_obj.resources.add(Resource.objects.get(name="University HPC")) allocation_obj.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_specs') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_specs") AllocationAttribute.objects.get_or_create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value='Fairshare=100:QOS+=supporters') + value="Fairshare=100:QOS+=supporters", + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_user_specs') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_user_specs") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='Fairshare=parent') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="Fairshare=parent" + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_account_name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_account_name") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='cgray') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="cgray" + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='SupportersQOS') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="SupportersQOS") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='Yes') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="Yes" + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='SupportersQOSExpireDate') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="SupportersQOSExpireDate") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='2022-01-01') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="2022-01-01" + ) - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi1, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi1, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Add project storage allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, quantity=10, is_changeable=True, - justification='I need extra storage.' + justification="I need extra storage.", ) - allocation_obj.resources.add( - Resource.objects.get(name='Budgetstorage')) + allocation_obj.resources.add(Resource.objects.get(name="Budgetstorage")) allocation_obj.save() - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi1, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi1, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Add metered allocation allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, is_changeable=True, - justification='I need compute time on metered cluster.' + justification="I need compute time on metered cluster.", ) - allocation_obj.resources.add( - Resource.objects.get(name='University Metered HPC')) + allocation_obj.resources.add(Resource.objects.get(name="University Metered HPC")) allocation_obj.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='slurm_account_name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_account_name") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='cgray-metered') - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Core Usage (Hours)') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="cgray-metered" + ) + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Core Usage (Hours)") AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value='150000') - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi1, - status=AllocationUserStatusChoice.objects.get(name='Active') + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value="150000" + ) + AllocationUser.objects.create( + allocation=allocation_obj, user=pi1, status=AllocationUserStatusChoice.objects.get(name="Active") ) - - pi2 = User.objects.get(username='sfoster') + pi2 = User.objects.get(username="sfoster") pi2.userprofile.is_pi = True pi2.save() project_obj, _ = Project.objects.get_or_create( pi=pi2, - title='Measuring critical behavior of quantum Hall transitions', - description='This purpose of this project is to measure the critical behavior of quantum Hall transitions.', - field_of_science=FieldOfScience.objects.get(description='Physics'), - status=ProjectStatusChoice.objects.get(name='Active') + title="Measuring critical behavior of quantum Hall transitions", + description="This purpose of this project is to measure the critical behavior of quantum Hall transitions.", + field_of_science=FieldOfScience.objects.get(description="Physics"), + status=ProjectStatusChoice.objects.get(name="Active"), ) project_user_obj, _ = ProjectUser.objects.get_or_create( user=pi2, project=project_obj, - role=ProjectUserRoleChoice.objects.get(name='Manager'), - status=ProjectUserStatusChoice.objects.get(name='Active') + role=ProjectUserRoleChoice.objects.get(name="Manager"), + status=ProjectUserStatusChoice.objects.get(name="Active"), ) for title, author, year, journal, unique_id, source in ( - ('Lattice constants from semilocal density functionals with zero-point phonon correction', - "Pan Hao and Yuan Fang and Jianwei Sun and G\'abor I. Csonka and Pier H. T. Philipsen and John P. Perdew", - 2012, - 'Physical Review B', - '10.1103/PhysRevB.85.014111', - 'doi'), - ('Anisotropic magnetocapacitance in ferromagnetic-plate capacitors', - "J. A. Haigh and C. Ciccarelli and A. C. Betz and A. Irvine and V. Nov\'ak and T. Jungwirth and J. Wunderlich", - 2015, - 'Physical Review B', - '10.1103/PhysRevB.91.140409', - 'doi' - ), - ('Interaction effects in topological superconducting wires supporting Majorana fermions', - 'E. M. Stoudenmire and Jason Alicea and Oleg A. Starykh and Matthew P.A. Fisher', - 2011, - 'Physical Review B', - '10.1103/PhysRevB.84.014503', - 'doi' - ), - ('Logarithmic correlations in quantum Hall plateau transitions', - 'Romain Vasseur', - 2015, - 'Physical Review B', - '10.1103/PhysRevB.92.014205', - 'doi' - ), + ( + "Lattice constants from semilocal density functionals with zero-point phonon correction", + "Pan Hao and Yuan Fang and Jianwei Sun and G'abor I. Csonka and Pier H. T. Philipsen and John P. Perdew", + 2012, + "Physical Review B", + "10.1103/PhysRevB.85.014111", + "doi", + ), + ( + "Anisotropic magnetocapacitance in ferromagnetic-plate capacitors", + "J. A. Haigh and C. Ciccarelli and A. C. Betz and A. Irvine and V. Nov'ak and T. Jungwirth and J. Wunderlich", + 2015, + "Physical Review B", + "10.1103/PhysRevB.91.140409", + "doi", + ), + ( + "Interaction effects in topological superconducting wires supporting Majorana fermions", + "E. M. Stoudenmire and Jason Alicea and Oleg A. Starykh and Matthew P.A. Fisher", + 2011, + "Physical Review B", + "10.1103/PhysRevB.84.014503", + "doi", + ), + ( + "Logarithmic correlations in quantum Hall plateau transitions", + "Romain Vasseur", + 2015, + "Physical Review B", + "10.1103/PhysRevB.92.014205", + "doi", + ), ): Publication.objects.get_or_create( project=project_obj, @@ -489,7 +475,7 @@ def handle(self, *args, **options): year=year, journal=journal, unique_id=unique_id, - source=publication_source + source=publication_source, ) start_date = datetime.datetime.now() @@ -497,157 +483,204 @@ def handle(self, *args, **options): Grant.objects.get_or_create( project=project_obj, - title='Quantum Halls', - grant_number='12345', - role='PI', - grant_pi_full_name='Stephanie Foster', - funding_agency=GrantFundingAgency.objects.get( - name='Department of Defense (DoD)'), + title="Quantum Halls", + grant_number="12345", + role="PI", + grant_pi_full_name="Stephanie Foster", + funding_agency=GrantFundingAgency.objects.get(name="Department of Defense (DoD)"), grant_start=start_date, grant_end=end_date, percent_credit=20.0, direct_funding=200000.0, total_amount_awarded=1000000.0, - status=GrantStatusChoice.objects.get(name='Active') + status=GrantStatusChoice.objects.get(name="Active"), ) # Add university cloud allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, is_changeable=True, - justification='Need to host my own site.' + justification="Need to host my own site.", ) - allocation_obj.resources.add( - Resource.objects.get(name='University Cloud')) + allocation_obj.resources.add(Resource.objects.get(name="University Cloud")) allocation_obj.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Cloud Account Name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Cloud Account Name") AllocationAttribute.objects.get_or_create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value='sfoster-openstack') + value="sfoster-openstack", + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Core Usage (Hours)') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Core Usage (Hours)") allocation_attribute_obj, _ = AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value=1000) + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value=1000 + ) allocation_attribute_obj.allocationattributeusage.value = 200 allocation_attribute_obj.allocationattributeusage.save() - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi2, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi2, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Add university cloud storage allocation_obj, _ = Allocation.objects.get_or_create( project=project_obj, - status=AllocationStatusChoice.objects.get(name='Active'), + status=AllocationStatusChoice.objects.get(name="Active"), start_date=start_date, end_date=end_date, is_changeable=True, - justification='Need extra storage for webserver.' + justification="Need extra storage for webserver.", ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Cloud Account Name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Cloud Account Name") AllocationAttribute.objects.get_or_create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value='sfoster-openstack') + value="sfoster-openstack", + ) - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Cloud Storage Quota (TB)') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Cloud Storage Quota (TB)") allocation_attribute_obj, _ = AllocationAttribute.objects.get_or_create( - allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value=20) + allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, value=20 + ) allocation_attribute_obj.allocationattributeusage.value = 10 allocation_attribute_obj.allocationattributeusage.save() - allocation_attribute_type_obj = AllocationAttributeType.objects.get( - name='Cloud Account Name') + allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="Cloud Account Name") AllocationAttribute.objects.get_or_create( allocation_attribute_type=allocation_attribute_type_obj, allocation=allocation_obj, - value='sfoster-openstack') + value="sfoster-openstack", + ) - allocation_obj.resources.add( - Resource.objects.get(name='University Cloud Storage')) + allocation_obj.resources.add(Resource.objects.get(name="University Cloud Storage")) allocation_obj.save() - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, - user=pi2, - status=AllocationUserStatusChoice.objects.get(name='Active') + AllocationUser.objects.create( + allocation=allocation_obj, user=pi2, status=AllocationUserStatusChoice.objects.get(name="Active") ) # Set attributes for resources - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_default_value'), resource=Resource.objects.get(name='University Cloud Storage'), value=1) - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_default_value'), resource=Resource.objects.get(name='University Cloud'), value=1) - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_default_value'), resource=Resource.objects.get(name='ProjectStorage'), value=1) - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_default_value'), resource=Resource.objects.get(name='Budgetstorage'), value=10) - - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_label'), resource=Resource.objects.get(name='University Cloud Storage'), value='Enter storage in 1TB increments') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_label'), resource=Resource.objects.get(name='University Cloud'), value='Enter number of compute allocations to purchase') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_label'), resource=Resource.objects.get(name='ProjectStorage'), value='Enter storage in 1TB increments') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='quantity_label'), resource=Resource.objects.get(name='Budgetstorage'), value='Enter storage in 10TB increments (minimum purchase is 10TB)') - - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='Chemistry'), value='chemistry') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='Physics'), value='physics') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='Industry'), value='industry') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='University HPC'), value='university-hpc') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_cluster'), resource=Resource.objects.get(name='University Metered HPC'), - value='metered-hpc') - - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Chemistry-scavenger'), value='QOS+=scavenger:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Physics-scavenger'), value='QOS+=scavenger:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Industry-scavenger'), value='QOS+=scavenger:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Chemistry-cgray'), value='QOS+=cgray:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='Physics-sfoster'), value='QOS+=sfoster:Fairshare=100') - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs'), resource=Resource.objects.get(name='University Metered HPC'), - value='GrpTRESMins=cpu={cpumin}') - - #slurm_specs_attrib_list for University Metered HPC - attriblist_list = [ '#Set cpumin from Core Usage attribute', - 'cpumin := :Core Usage (Hours)', - '#Default to 1 SU', - 'cpumin |= 1', - '#Convert to cpumin', - 'cpumin *= 60' + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_default_value"), + resource=Resource.objects.get(name="University Cloud Storage"), + value=1, + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_default_value"), + resource=Resource.objects.get(name="University Cloud"), + value=1, + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_default_value"), + resource=Resource.objects.get(name="ProjectStorage"), + value=1, + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_default_value"), + resource=Resource.objects.get(name="Budgetstorage"), + value=10, + ) + + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_label"), + resource=Resource.objects.get(name="University Cloud Storage"), + value="Enter storage in 1TB increments", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_label"), + resource=Resource.objects.get(name="University Cloud"), + value="Enter number of compute allocations to purchase", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_label"), + resource=Resource.objects.get(name="ProjectStorage"), + value="Enter storage in 1TB increments", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="quantity_label"), + resource=Resource.objects.get(name="Budgetstorage"), + value="Enter storage in 10TB increments (minimum purchase is 10TB)", + ) + + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="Chemistry"), + value="chemistry", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="Physics"), + value="physics", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="Industry"), + value="industry", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="University HPC"), + value="university-hpc", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_cluster"), + resource=Resource.objects.get(name="University Metered HPC"), + value="metered-hpc", + ) + + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Chemistry-scavenger"), + value="QOS+=scavenger:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Physics-scavenger"), + value="QOS+=scavenger:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Industry-scavenger"), + value="QOS+=scavenger:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Chemistry-cgray"), + value="QOS+=cgray:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="Physics-sfoster"), + value="QOS+=sfoster:Fairshare=100", + ) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs"), + resource=Resource.objects.get(name="University Metered HPC"), + value="GrpTRESMins=cpu={cpumin}", + ) + + # slurm_specs_attrib_list for University Metered HPC + attriblist_list = [ + "#Set cpumin from Core Usage attribute", + "cpumin := :Core Usage (Hours)", + "#Default to 1 SU", + "cpumin |= 1", + "#Convert to cpumin", + "cpumin *= 60", ] - ResourceAttribute.objects.get_or_create(resource_attribute_type=ResourceAttributeType.objects.get( - name='slurm_specs_attriblist'), resource=Resource.objects.get(name='University Metered HPC'), - value="\n".join(attriblist_list)) + ResourceAttribute.objects.get_or_create( + resource_attribute_type=ResourceAttributeType.objects.get(name="slurm_specs_attriblist"), + resource=Resource.objects.get(name="University Metered HPC"), + value="\n".join(attriblist_list), + ) # call_command('loaddata', 'test_data.json') diff --git a/coldfront/core/utils/management/commands/show_users_in_project_but_not_in_allocation.py b/coldfront/core/utils/management/commands/show_users_in_project_but_not_in_allocation.py index 573a181f27..8ba531c67f 100644 --- a/coldfront/core/utils/management/commands/show_users_in_project_but_not_in_allocation.py +++ b/coldfront/core/utils/management/commands/show_users_in_project_but_not_in_allocation.py @@ -1,7 +1,8 @@ -import os +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later from django.conf import settings -from django.core.management import call_command from django.core.management.base import BaseCommand from coldfront.core.project.models import Project @@ -10,19 +11,19 @@ class Command(BaseCommand): - def handle(self, *args, **options): - for project in Project.objects.filter(status__name__in=['Active', 'New']): - users_in_project = list(project.projectuser_set.filter( - status__name='Active').values_list('user__username', flat=True)) + for project in Project.objects.filter(status__name__in=["Active", "New"]): + users_in_project = list( + project.projectuser_set.filter(status__name="Active").values_list("user__username", flat=True) + ) users_in_allocation = [] - for allocation in project.allocation_set.filter(status__name__in=('Active', - 'New', 'Paid', 'Payment Pending', - 'Payment Requested', 'Renewal Requested')): - - users_in_allocation.extend(allocation.allocationuser_set.filter( - status__name='Active').values_list('user__username', flat=True)) - - extra_users = list(set(users_in_project)-set(users_in_allocation)) + for allocation in project.allocation_set.filter( + status__name__in=("Active", "New", "Paid", "Payment Pending", "Payment Requested", "Renewal Requested") + ): + users_in_allocation.extend( + allocation.allocationuser_set.filter(status__name="Active").values_list("user__username", flat=True) + ) + + extra_users = list(set(users_in_project) - set(users_in_allocation)) if extra_users: print(project.id, project.title, project.pi, extra_users) diff --git a/coldfront/core/utils/migrations/__init__.py b/coldfront/core/utils/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/migrations/__init__.py +++ b/coldfront/core/utils/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/mixins/__init__.py b/coldfront/core/utils/mixins/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/mixins/__init__.py +++ b/coldfront/core/utils/mixins/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/mixins/views.py b/coldfront/core/utils/mixins/views.py index f9c5f6df6f..105749241c 100644 --- a/coldfront/core/utils/mixins/views.py +++ b/coldfront/core/utils/mixins/views.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import re from django.contrib import messages @@ -23,46 +27,47 @@ def to_snake(string): # it should work in the majority of cases, even allowing us to change app/class/etc. names # but cases like DOIDisplay (or similar, using multiple caps in a row) would fail - return string[0].lower() + re.sub('([A-Z])', r'_\1', string[1:]).lower() + return string[0].lower() + re.sub("([A-Z])", r"_\1", string[1:]).lower() app_label = self.model._meta.app_label model_name = self.model.__name__ - return ['{}/{}{}.html'.format(app_label, to_snake(model_name), self.template_name_suffix)] + return ["{}/{}{}.html".format(app_label, to_snake(model_name), self.template_name_suffix)] class ProjectInContextMixin: def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['project'] = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + context["project"] = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) return context class ChangesOnlyOnActiveProjectMixin: def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) - if project_obj.status.name not in ['Active', 'New', ]: - messages.error( - request, 'You cannot modify an archived project.') - return HttpResponseRedirect(reverse('project-detail', kwargs={'pk': project_obj.pk})) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + if project_obj.status.name not in [ + "Active", + "New", + ]: + messages.error(request, "You cannot modify an archived project.") + return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) else: return super().dispatch(request, *args, **kwargs) class UserActiveManagerOrHigherMixin(LoginRequiredMixin, UserPassesTestMixin): def test_func(self): - """ UserPassesTestMixin Tests""" + """UserPassesTestMixin Tests""" if self.request.user.is_superuser: return True - project_obj = get_object_or_404( - Project, pk=self.kwargs.get('project_pk')) + project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) if project_obj.pi == self.request.user: return True - if project_obj.projectuser_set.filter(user=self.request.user, role__name='Manager', status__name='Active').exists(): + if project_obj.projectuser_set.filter( + user=self.request.user, role__name="Manager", status__name="Active" + ).exists(): return True diff --git a/coldfront/core/utils/models.py b/coldfront/core/utils/models.py index 71a8362390..73294d7dba 100644 --- a/coldfront/core/utils/models.py +++ b/coldfront/core/utils/models.py @@ -1,3 +1,5 @@ -from django.db import models +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your models here. diff --git a/coldfront/core/utils/templatetags/__init__.py b/coldfront/core/utils/templatetags/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/core/utils/templatetags/__init__.py +++ b/coldfront/core/utils/templatetags/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/core/utils/templatetags/common_tags.py b/coldfront/core/utils/templatetags/common_tags.py index 4b0247c99f..2e74c82bcb 100644 --- a/coldfront/core/utils/templatetags/common_tags.py +++ b/coldfront/core/utils/templatetags/common_tags.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django import template from django.conf import settings from django.utils.safestring import mark_safe @@ -9,26 +13,26 @@ @register.simple_tag def settings_value(name): allowed_names = [ - 'LOGIN_FAIL_MESSAGE', - 'ACCOUNT_CREATION_TEXT', - 'CENTER_NAME', - 'CENTER_HELP_URL', - 'EMAIL_PROJECT_REVIEW_CONTACT', + "LOGIN_FAIL_MESSAGE", + "ACCOUNT_CREATION_TEXT", + "CENTER_NAME", + "CENTER_HELP_URL", + "EMAIL_PROJECT_REVIEW_CONTACT", ] - return mark_safe(getattr(settings, name, '') if name in allowed_names else '') + return mark_safe(getattr(settings, name, "") if name in allowed_names else "") @register.filter def get_icon(expand_accordion): - if expand_accordion == 'show': - return 'fa-minus' + if expand_accordion == "show": + return "fa-minus" else: - return 'fa-plus' + return "fa-plus" @register.filter def convert_boolean_to_icon(boolean): - if boolean == False: + if boolean is False: return mark_safe('') else: return mark_safe('') @@ -38,9 +42,9 @@ def convert_boolean_to_icon(boolean): def convert_status_to_icon(project): if project.last_project_review: status = project.last_project_review.status.name - if status == 'Pending': + if status == "Pending": return mark_safe('

') - elif status == 'Completed': + elif status == "Completed": return mark_safe('

') elif project.needs_review and not project.last_project_review: return mark_safe('

') @@ -48,9 +52,7 @@ def convert_status_to_icon(project): return mark_safe('

') - - -@register.filter('get_value_from_dict') +@register.filter("get_value_from_dict") def get_value_from_dict(dict_data, key): """ usage example {{ your_dict|get_value_from_dict:your_key }} @@ -58,8 +60,9 @@ def get_value_from_dict(dict_data, key): if key: return dict_data.get(key) -@register.filter('get_value_by_index') -def get_value_from_dict(array, index): + +@register.filter("get_value_by_index") +def get_value_by_index(array, index): """ usage example {{ your_list|get_value_by_index:your_index }} """ diff --git a/coldfront/core/utils/tests.py b/coldfront/core/utils/tests.py index 7ce503c2dd..576ead011d 100644 --- a/coldfront/core/utils/tests.py +++ b/coldfront/core/utils/tests.py @@ -1,3 +1,5 @@ -from django.test import TestCase +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your tests here. diff --git a/coldfront/core/utils/validate.py b/coldfront/core/utils/validate.py index 5597a14086..0f9e57c8f9 100644 --- a/coldfront/core/utils/validate.py +++ b/coldfront/core/utils/validate.py @@ -1,11 +1,14 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime + from django.core.exceptions import ValidationError -from django.core.validators import MinValueValidator -import formencode -from formencode import validators, Invalid +from formencode import validators -class AttributeValidator: +class AttributeValidator: def __init__(self, value): self.value = value @@ -13,29 +16,27 @@ def validate_int(self): try: validate = validators.Int() validate.to_python(self.value) - except: - raise ValidationError( - f'Invalid Value {self.value}. Value must be an int.') + except Exception: + raise ValidationError(f"Invalid Value {self.value}. Value must be an int.") def validate_float(self): try: validate = validators.Number() validate.to_python(self.value) - except: - raise ValidationError( - f'Invalid Value {self.value}. Value must be an float.') + except Exception: + raise ValidationError(f"Invalid Value {self.value}. Value must be an float.") def validate_yes_no(self): try: - validate = validators.OneOf(['Yes','No']) + validate = validators.OneOf(["Yes", "No"]) validate.to_python(self.value) - except: - raise ValidationError( - f'Invalid Value {self.value}. Value must be an Yes/No value.') + except Exception: + raise ValidationError(f"Invalid Value {self.value}. Value must be an Yes/No value.") def validate_date(self): try: datetime.datetime.strptime(self.value.strip(), "%Y-%m-%d") - except: + except Exception: raise ValidationError( - f'Invalid Value {self.value}. Date must be in format YYYY-MM-DD and date must be today or later.') \ No newline at end of file + f"Invalid Value {self.value}. Date must be in format YYYY-MM-DD and date must be today or later." + ) diff --git a/coldfront/core/utils/views.py b/coldfront/core/utils/views.py index 91ea44a218..2fa8704650 100644 --- a/coldfront/core/utils/views.py +++ b/coldfront/core/utils/views.py @@ -1,3 +1,5 @@ -from django.shortcuts import render +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your views here. diff --git a/coldfront/plugins/__init__.py b/coldfront/plugins/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/__init__.py +++ b/coldfront/plugins/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/api/__init__.py b/coldfront/plugins/api/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/api/__init__.py +++ b/coldfront/plugins/api/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/api/apps.py b/coldfront/plugins/api/apps.py index f0a74afed8..3033b5d556 100644 --- a/coldfront/plugins/api/apps.py +++ b/coldfront/plugins/api/apps.py @@ -1,18 +1,22 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os from django.apps import AppConfig + from coldfront.core.utils.common import import_from_settings + class ApiConfig(AppConfig): - name = 'coldfront.plugins.api' + name = "coldfront.plugins.api" def ready(self): # Dynamically add the api plugin templates directory to TEMPLATES['DIRS'] - BASE_DIR = import_from_settings('BASE_DIR') - TEMPLATES = import_from_settings('TEMPLATES') - api_templates_dir = os.path.join( - BASE_DIR, 'coldfront/plugins/api/templates' - ) + BASE_DIR = import_from_settings("BASE_DIR") + TEMPLATES = import_from_settings("TEMPLATES") + api_templates_dir = os.path.join(BASE_DIR, "coldfront/plugins/api/templates") for template_setting in TEMPLATES: - if api_templates_dir not in template_setting['DIRS']: - template_setting['DIRS'] = [api_templates_dir] + template_setting['DIRS'] + if api_templates_dir not in template_setting["DIRS"]: + template_setting["DIRS"] = [api_templates_dir] + template_setting["DIRS"] diff --git a/coldfront/plugins/api/serializers.py b/coldfront/plugins/api/serializers.py index 3b91f45b48..6664696805 100644 --- a/coldfront/plugins/api/serializers.py +++ b/coldfront/plugins/api/serializers.py @@ -1,56 +1,57 @@ -from datetime import timedelta +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later from django.contrib.auth import get_user_model from rest_framework import serializers -from coldfront.core.resource.models import Resource -from coldfront.core.project.models import Project, ProjectUser from coldfront.core.allocation.models import Allocation, AllocationChangeRequest +from coldfront.core.project.models import Project, ProjectUser +from coldfront.core.resource.models import Resource class UserSerializer(serializers.ModelSerializer): - class Meta: model = get_user_model() fields = ( - 'id', - 'username', - 'first_name', - 'last_name', - 'is_active', - 'is_superuser', - 'is_staff', - 'date_joined', + "id", + "username", + "first_name", + "last_name", + "is_active", + "is_superuser", + "is_staff", + "date_joined", ) class ResourceSerializer(serializers.ModelSerializer): - resource_type = serializers.SlugRelatedField(slug_field='name', read_only=True) + resource_type = serializers.SlugRelatedField(slug_field="name", read_only=True) class Meta: model = Resource - fields = ('id', 'resource_type', 'name', 'description', 'is_allocatable') + fields = ("id", "resource_type", "name", "description", "is_allocatable") class AllocationSerializer(serializers.ModelSerializer): - resource = serializers.ReadOnlyField(source='get_resources_as_string') - project = serializers.SlugRelatedField(slug_field='title', read_only=True) - status = serializers.SlugRelatedField(slug_field='name', read_only=True) + resource = serializers.ReadOnlyField(source="get_resources_as_string") + project = serializers.SlugRelatedField(slug_field="title", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) class Meta: model = Allocation fields = ( - 'id', - 'project', - 'resource', - 'status', + "id", + "project", + "resource", + "status", ) class AllocationRequestSerializer(serializers.ModelSerializer): - project = serializers.SlugRelatedField(slug_field='title', read_only=True) - resource = serializers.ReadOnlyField(source='get_resources_as_string', read_only=True) - status = serializers.SlugRelatedField(slug_field='name', read_only=True) + project = serializers.SlugRelatedField(slug_field="title", read_only=True) + resource = serializers.ReadOnlyField(source="get_resources_as_string", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) fulfilled_date = serializers.DateTimeField(read_only=True) created_by = serializers.SerializerMethodField(read_only=True) fulfilled_by = serializers.SerializerMethodField(read_only=True) @@ -59,15 +60,15 @@ class AllocationRequestSerializer(serializers.ModelSerializer): class Meta: model = Allocation fields = ( - 'id', - 'project', - 'resource', - 'status', - 'created', - 'created_by', - 'fulfilled_date', - 'fulfilled_by', - 'time_to_fulfillment', + "id", + "project", + "resource", + "status", + "created", + "created_by", + "fulfilled_date", + "fulfilled_by", + "time_to_fulfillment", ) def get_created_by(self, obj): @@ -78,7 +79,7 @@ def get_created_by(self, obj): return historical_record.history_user.username def get_fulfilled_by(self, obj): - historical_records = obj.history.filter(status__name='Active') + historical_records = obj.history.filter(status__name="Active") if historical_records: user = historical_records.earliest().history_user if user: @@ -88,7 +89,7 @@ def get_fulfilled_by(self, obj): class AllocationChangeRequestSerializer(serializers.ModelSerializer): allocation = AllocationSerializer(read_only=True) - status = serializers.SlugRelatedField(slug_field='name', read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) created_by = serializers.SerializerMethodField(read_only=True) fulfilled_date = serializers.DateTimeField(read_only=True) fulfilled_by = serializers.SerializerMethodField(read_only=True) @@ -97,15 +98,15 @@ class AllocationChangeRequestSerializer(serializers.ModelSerializer): class Meta: model = AllocationChangeRequest fields = ( - 'id', - 'allocation', - 'justification', - 'status', - 'created', - 'created_by', - 'fulfilled_date', - 'fulfilled_by', - 'time_to_fulfillment', + "id", + "allocation", + "justification", + "status", + "created", + "created_by", + "fulfilled_date", + "fulfilled_by", + "time_to_fulfillment", ) def get_created_by(self, obj): @@ -116,7 +117,7 @@ def get_created_by(self, obj): return historical_record.history_user.username def get_fulfilled_by(self, obj): - if not obj.status.name == 'Approved': + if not obj.status.name == "Approved": return None historical_record = obj.history.latest() fulfiller = historical_record.history_user if historical_record else None @@ -126,42 +127,42 @@ def get_fulfilled_by(self, obj): class ProjAllocationSerializer(serializers.ModelSerializer): - resource = serializers.ReadOnlyField(source='get_resources_as_string') - status = serializers.SlugRelatedField(slug_field='name', read_only=True) + resource = serializers.ReadOnlyField(source="get_resources_as_string") + status = serializers.SlugRelatedField(slug_field="name", read_only=True) class Meta: model = Allocation - fields = ('id', 'resource', 'status') + fields = ("id", "resource", "status") class ProjectUserSerializer(serializers.ModelSerializer): - user = serializers.SlugRelatedField(slug_field='username', read_only=True) - status = serializers.SlugRelatedField(slug_field='name', read_only=True) - role = serializers.SlugRelatedField(slug_field='name', read_only=True) + user = serializers.SlugRelatedField(slug_field="username", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) + role = serializers.SlugRelatedField(slug_field="name", read_only=True) class Meta: model = ProjectUser - fields = ('user', 'role', 'status') + fields = ("user", "role", "status") class ProjectSerializer(serializers.ModelSerializer): - pi = serializers.SlugRelatedField(slug_field='username', read_only=True) - status = serializers.SlugRelatedField(slug_field='name', read_only=True) + pi = serializers.SlugRelatedField(slug_field="username", read_only=True) + status = serializers.SlugRelatedField(slug_field="name", read_only=True) project_users = serializers.SerializerMethodField() allocations = serializers.SerializerMethodField() class Meta: model = Project - fields = ('id', 'title', 'pi', 'status', 'project_users', 'allocations') + fields = ("id", "title", "pi", "status", "project_users", "allocations") def get_project_users(self, obj): - request = self.context.get('request', None) - if request and request.query_params.get('project_users') in ['true','True']: + request = self.context.get("request", None) + if request and request.query_params.get("project_users") in ["true", "True"]: return ProjectUserSerializer(obj.projectuser_set, many=True, read_only=True).data return None def get_allocations(self, obj): - request = self.context.get('request', None) - if request and request.query_params.get('allocations') in ['true','True']: + request = self.context.get("request", None) + if request and request.query_params.get("allocations") in ["true", "True"]: return ProjAllocationSerializer(obj.allocation_set, many=True, read_only=True).data return None diff --git a/coldfront/plugins/api/tests.py b/coldfront/plugins/api/tests.py index 993efec4b3..c82de56b17 100644 --- a/coldfront/plugins/api/tests.py +++ b/coldfront/plugins/api/tests.py @@ -1,5 +1,13 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import unittest + from rest_framework import status from rest_framework.test import APITestCase + +from coldfront.config.env import ENV from coldfront.core.allocation.models import Allocation from coldfront.core.project.models import Project from coldfront.core.test_helpers.factories import ( @@ -11,6 +19,7 @@ ) +@unittest.skipUnless(ENV.bool("PLUGIN_API", default=False), "Only run API tests if enabled") class ColdfrontAPI(APITestCase): """Tests for the Coldfront REST API""" @@ -19,19 +28,19 @@ def setUpTestData(self): """Test Data setup for ColdFront REST API tests.""" self.admin_user = UserFactory(is_staff=True, is_superuser=True) - project = ProjectFactory(status=ProjectStatusChoiceFactory(name='Active')) + project = ProjectFactory(status=ProjectStatusChoiceFactory(name="Active")) allocation = AllocationFactory(project=project) - allocation.resources.add(ResourceFactory(name='test')) + allocation.resources.add(ResourceFactory(name="test")) self.pi_user = project.pi for i in range(0, 10): - project = ProjectFactory(status=ProjectStatusChoiceFactory(name='Active')) + project = ProjectFactory(status=ProjectStatusChoiceFactory(name="Active")) allocation = AllocationFactory(project=project) - allocation.resources.add(ResourceFactory(name='test')) + allocation.resources.add(ResourceFactory(name="test")) def test_requires_login(self): """Test that the API requires authentication""" - response = self.client.get('/api/') + response = self.client.get("/api/") self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) def test_allocation_request_api_permissions(self): @@ -39,11 +48,11 @@ def test_allocation_request_api_permissions(self): allocations, and that accessing it as a user is forbidden""" # login as admin self.client.force_login(self.admin_user) - response = self.client.get('/api/allocation-requests/', format='json') + response = self.client.get("/api/allocation-requests/", format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.client.force_login(self.pi_user) - response = self.client.get('/api/allocation-requests/', format='json') + response = self.client.get("/api/allocation-requests/", format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_allocation_api_permissions(self): @@ -52,12 +61,12 @@ def test_allocation_api_permissions(self): for that user""" # login as admin self.client.force_login(self.admin_user) - response = self.client.get('/api/allocations/', format='json') + response = self.client.get("/api/allocations/", format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), Allocation.objects.all().count()) self.client.force_login(self.pi_user) - response = self.client.get('/api/allocations/', format='json') + response = self.client.get("/api/allocations/", format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) @@ -68,12 +77,12 @@ def test_project_api_permissions(self): """ # login as admin self.client.force_login(self.admin_user) - response = self.client.get('/api/projects/', format='json') + response = self.client.get("/api/projects/", format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), Project.objects.all().count()) self.client.force_login(self.pi_user) - response = self.client.get('/api/projects/', format='json') + response = self.client.get("/api/projects/", format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) @@ -82,9 +91,9 @@ def test_user_api_permissions(self): allocations, and that accessing it as a user is forbidden""" # login as admin self.client.force_login(self.admin_user) - response = self.client.get('/api/users/', format='json') + response = self.client.get("/api/users/", format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.client.force_login(self.pi_user) - response = self.client.get('/api/users/', format='json') + response = self.client.get("/api/users/", format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/coldfront/plugins/api/urls.py b/coldfront/plugins/api/urls.py index c1831e7dc8..3d14e7fbdc 100644 --- a/coldfront/plugins/api/urls.py +++ b/coldfront/plugins/api/urls.py @@ -1,17 +1,24 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import include, path from rest_framework import routers + from coldfront.plugins.api import views router = routers.DefaultRouter() -router.register(r'allocations', views.AllocationViewSet, basename='allocations') -router.register(r'allocation-requests', views.AllocationRequestViewSet, basename='allocation-requests') -router.register(r'allocation-change-requests', views.AllocationChangeRequestViewSet, basename='allocation-change-requests') -router.register(r'projects', views.ProjectViewSet, basename='projects') -router.register(r'resources', views.ResourceViewSet, basename='resources') -router.register(r'users', views.UserViewSet, basename='users') +router.register(r"allocations", views.AllocationViewSet, basename="allocations") +router.register(r"allocation-requests", views.AllocationRequestViewSet, basename="allocation-requests") +router.register( + r"allocation-change-requests", views.AllocationChangeRequestViewSet, basename="allocation-change-requests" +) +router.register(r"projects", views.ProjectViewSet, basename="projects") +router.register(r"resources", views.ResourceViewSet, basename="resources") +router.register(r"users", views.UserViewSet, basename="users") urlpatterns = [ - path('', include(router.urls)), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), - path('regenerate-token/', views.regenerate_token, name='regenerate_token'), + path("", include(router.urls)), + path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), + path("regenerate-token/", views.regenerate_token, name="regenerate_token"), ] diff --git a/coldfront/plugins/api/views.py b/coldfront/plugins/api/views.py index 0679fc7b85..83f83d3170 100644 --- a/coldfront/plugins/api/views.py +++ b/coldfront/plugins/api/views.py @@ -1,15 +1,19 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from datetime import timedelta from django.contrib.auth import get_user_model -from django.db.models import OuterRef, Subquery, Q, F, ExpressionWrapper, fields +from django.db.models import ExpressionWrapper, F, OuterRef, Q, Subquery, fields from django.db.models.functions import Cast from django_filters import rest_framework as filters from rest_framework import viewsets +from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response -from rest_framework.authtoken.models import Token -from rest_framework.permissions import IsAuthenticated, IsAdminUser from simple_history.utils import get_history_model_for_model from coldfront.core.allocation.models import Allocation, AllocationChangeRequest @@ -19,11 +23,12 @@ logger = logging.getLogger(__name__) -@api_view(['POST']) + +@api_view(["POST"]) @permission_classes([IsAuthenticated]) def regenerate_token(request): old_token = None - if hasattr(request.user, 'auth_token'): + if hasattr(request.user, "auth_token"): old_token = request.user.auth_token.key[-6:] # Last 6 chars for logging # Delete existing token Token.objects.filter(user=request.user).delete() @@ -35,9 +40,9 @@ def regenerate_token(request): "API token regenerated for user %s (uid: %s). Old token ending: %s", request.user.username, request.user.id, - old_token or 'None' + old_token or "None", ) - return Response({'token': token.key}) + return Response({"token": token.key}) class ResourceViewSet(viewsets.ReadOnlyModelViewSet): @@ -50,46 +55,43 @@ class AllocationViewSet(viewsets.ReadOnlyModelViewSet): # permission_classes = (permissions.IsAuthenticatedOrReadOnly,) def get_queryset(self): - allocations = Allocation.objects.prefetch_related( - 'project', 'project__pi', 'status' - ) + allocations = Allocation.objects.prefetch_related("project", "project__pi", "status") - if not (self.request.user.is_superuser or self.request.user.has_perm( - 'allocation.can_view_all_allocations' - )): + if not (self.request.user.is_superuser or self.request.user.has_perm("allocation.can_view_all_allocations")): allocations = allocations.filter( - Q(project__status__name__in=['New', 'Active']) & - ( + Q(project__status__name__in=["New", "Active"]) + & ( ( - Q(project__projectuser__role__name__contains='Manager') + Q(project__projectuser__role__name__contains="Manager") & Q(project__projectuser__user=self.request.user) ) | Q(project__pi=self.request.user) ) ).distinct() - allocations = allocations.order_by('project') + allocations = allocations.order_by("project") return allocations class AllocationRequestFilter(filters.FilterSet): - '''Filters for AllocationChangeRequestViewSet. + """Filters for AllocationChangeRequestViewSet. created_before is the date the request was created before. created_after is the date the request was created after. - ''' + """ + created = filters.DateFromToRangeFilter() - fulfilled = filters.BooleanFilter(method='filter_fulfilled', label='Fulfilled') - fulfilled_date = filters.DateFromToRangeFilter(label='Date fulfilled') - time_to_fulfillment = filters.NumericRangeFilter(method='filter_time_to_fulfillment', label='Time to fulfillment') + fulfilled = filters.BooleanFilter(method="filter_fulfilled", label="Fulfilled") + fulfilled_date = filters.DateFromToRangeFilter(label="Date fulfilled") + time_to_fulfillment = filters.NumericRangeFilter(method="filter_time_to_fulfillment", label="Time to fulfillment") class Meta: model = Allocation fields = [ - 'created', - 'fulfilled', - 'fulfilled_date', - 'time_to_fulfillment', + "created", + "fulfilled", + "fulfilled_date", + "time_to_fulfillment", ] def filter_fulfilled(self, queryset, name, value): @@ -100,18 +102,14 @@ def filter_fulfilled(self, queryset, name, value): def filter_time_to_fulfillment(self, queryset, name, value): if value.start is not None: - queryset = queryset.filter( - time_to_fulfillment__gte=timedelta(days=int(value.start)) - ) + queryset = queryset.filter(time_to_fulfillment__gte=timedelta(days=int(value.start))) if value.stop is not None: - queryset = queryset.filter( - time_to_fulfillment__lte=timedelta(days=int(value.stop)) - ) + queryset = queryset.filter(time_to_fulfillment__lte=timedelta(days=int(value.stop))) return queryset class AllocationRequestViewSet(viewsets.ReadOnlyModelViewSet): - '''Report view on allocations requested through Coldfront. + """Report view on allocations requested through Coldfront. Data: - id: allocation id - project: project name @@ -131,7 +129,8 @@ class AllocationRequestViewSet(viewsets.ReadOnlyModelViewSet): - fulfilled_date_before/fulfilled_date_after (structure date as 'YYYY-MM-DD') - time_to_fulfillment_max/time_to_fulfillment_min (integer) Set to the maximum/minimum number of days between request creation and time_to_fulfillment. - ''' + """ + serializer_class = serializers.AllocationRequestSerializer filter_backends = (filters.DjangoFilterBackend,) filterset_class = AllocationRequestFilter @@ -141,71 +140,70 @@ def get_queryset(self): HistoricalAllocation = get_history_model_for_model(Allocation) # Subquery to get the earliest historical record for each allocation - earliest_history = HistoricalAllocation.objects.filter( - id=OuterRef('pk') - ).order_by('history_date').values('status__name')[:1] + earliest_history = ( + HistoricalAllocation.objects.filter(id=OuterRef("pk")).order_by("history_date").values("status__name")[:1] + ) - fulfilled_date = HistoricalAllocation.objects.filter( - id=OuterRef('pk'), status__name='Active' - ).order_by('history_date').values('modified')[:1] + fulfilled_date = ( + HistoricalAllocation.objects.filter(id=OuterRef("pk"), status__name="Active") + .order_by("history_date") + .values("modified")[:1] + ) # Annotate allocations with the status_id of their earliest historical record - allocations = Allocation.objects.annotate( - earliest_status_name=Subquery(earliest_history) - ).filter(earliest_status_name='New').order_by('created') - - allocations = allocations.annotate( - fulfilled_date=Subquery(fulfilled_date) + allocations = ( + Allocation.objects.annotate(earliest_status_name=Subquery(earliest_history)) + .filter(earliest_status_name="New") + .order_by("created") ) + allocations = allocations.annotate(fulfilled_date=Subquery(fulfilled_date)) + allocations = allocations.annotate( time_to_fulfillment=ExpressionWrapper( - (Cast(Subquery(fulfilled_date), fields.DateTimeField()) - F('created')), - output_field=fields.DurationField() + (Cast(Subquery(fulfilled_date), fields.DateTimeField()) - F("created")), + output_field=fields.DurationField(), ) ) return allocations class AllocationChangeRequestFilter(filters.FilterSet): - '''Filters for AllocationChangeRequestViewSet. + """Filters for AllocationChangeRequestViewSet. created_before is the date the request was created before. created_after is the date the request was created after. - ''' + """ + created = filters.DateFromToRangeFilter() - fulfilled = filters.BooleanFilter(method='filter_fulfilled', label='Fulfilled') - fulfilled_date = filters.DateFromToRangeFilter(label='Date fulfilled') - time_to_fulfillment = filters.NumericRangeFilter(method='filter_time_to_fulfillment', label='Time to fulfillment') + fulfilled = filters.BooleanFilter(method="filter_fulfilled", label="Fulfilled") + fulfilled_date = filters.DateFromToRangeFilter(label="Date fulfilled") + time_to_fulfillment = filters.NumericRangeFilter(method="filter_time_to_fulfillment", label="Time to fulfillment") class Meta: model = AllocationChangeRequest fields = [ - 'created', - 'fulfilled', - 'fulfilled_date', - 'time_to_fulfillment', + "created", + "fulfilled", + "fulfilled_date", + "time_to_fulfillment", ] def filter_fulfilled(self, queryset, name, value): if value: - return queryset.filter(status__name='Approved') + return queryset.filter(status__name="Approved") else: - return queryset.filter(status__name__in=['Pending', 'Denied']) + return queryset.filter(status__name__in=["Pending", "Denied"]) def filter_time_to_fulfillment(self, queryset, name, value): if value.start is not None: - queryset = queryset.filter( - time_to_fulfillment__gte=timedelta(days=int(value.start)) - ) + queryset = queryset.filter(time_to_fulfillment__gte=timedelta(days=int(value.start))) if value.stop is not None: - queryset = queryset.filter( - time_to_fulfillment__lte=timedelta(days=int(value.stop)) - ) + queryset = queryset.filter(time_to_fulfillment__lte=timedelta(days=int(value.stop))) return queryset class AllocationChangeRequestViewSet(viewsets.ReadOnlyModelViewSet): - ''' + """ Data: - allocation: allocation object details - justification: justification provided at time of filing @@ -222,106 +220,110 @@ class AllocationChangeRequestViewSet(viewsets.ReadOnlyModelViewSet): - fulfilled_date_before/fulfilled_date_after (structure date as 'YYYY-MM-DD') - time_to_fulfillment_max/time_to_fulfillment_min (integer) Set to the maximum/minimum number of days between request creation and time_to_fulfillment. - ''' + """ + serializer_class = serializers.AllocationChangeRequestSerializer filter_backends = (filters.DjangoFilterBackend,) filterset_class = AllocationChangeRequestFilter def get_queryset(self): requests = AllocationChangeRequest.objects.prefetch_related( - 'allocation', 'allocation__project', 'allocation__project__pi' + "allocation", "allocation__project", "allocation__project__pi" ) if not (self.request.user.is_superuser or self.request.user.is_staff): requests = requests.filter( - Q(allocation__project__status__name__in=['New', 'Active']) & - ( + Q(allocation__project__status__name__in=["New", "Active"]) + & ( ( - Q(allocation__project__projectuser__role__name__contains='Manager') + Q(allocation__project__projectuser__role__name__contains="Manager") & Q(allocation__project__projectuser__user=self.request.user) ) | Q(allocation__project__pi=self.request.user) ) ).distinct() - HistoricalAllocationChangeRequest = get_history_model_for_model( - AllocationChangeRequest - ) + HistoricalAllocationChangeRequest = get_history_model_for_model(AllocationChangeRequest) - fulfilled_date = HistoricalAllocationChangeRequest.objects.filter( - id=OuterRef('pk'), status__name='Approved' - ).order_by('history_date').values('modified')[:1] + fulfilled_date = ( + HistoricalAllocationChangeRequest.objects.filter(id=OuterRef("pk"), status__name="Approved") + .order_by("history_date") + .values("modified")[:1] + ) requests = requests.annotate(fulfilled_date=Subquery(fulfilled_date)) requests = requests.annotate( time_to_fulfillment=ExpressionWrapper( - (Cast(Subquery(fulfilled_date), fields.DateTimeField()) - F('created')), - output_field=fields.DurationField() + (Cast(Subquery(fulfilled_date), fields.DateTimeField()) - F("created")), + output_field=fields.DurationField(), ) ) - requests = requests.order_by('created') + requests = requests.order_by("created") return requests class ProjectViewSet(viewsets.ReadOnlyModelViewSet): - ''' + """ Query parameters: - allocations (default false) Show related allocation data. - project_users (default false) Show related user data. - ''' + """ + serializer_class = serializers.ProjectSerializer def get_queryset(self): - projects = Project.objects.prefetch_related('status') + projects = Project.objects.prefetch_related("status") if not ( self.request.user.is_superuser or self.request.user.is_staff - or self.request.user.has_perm('project.can_view_all_projects') + or self.request.user.has_perm("project.can_view_all_projects") ): - projects = projects.filter( - Q(status__name__in=['New', 'Active']) & - ( - ( - Q(projectuser__role__name__contains='Manager') - & Q(projectuser__user=self.request.user) + projects = ( + projects.filter( + Q(status__name__in=["New", "Active"]) + & ( + (Q(projectuser__role__name__contains="Manager") & Q(projectuser__user=self.request.user)) + | Q(pi=self.request.user) ) - | Q(pi=self.request.user) ) - ).distinct().order_by('pi') + .distinct() + .order_by("pi") + ) - if self.request.query_params.get('project_users') in ['True', 'true']: - projects = projects.prefetch_related('projectuser_set') + if self.request.query_params.get("project_users") in ["True", "true"]: + projects = projects.prefetch_related("projectuser_set") - if self.request.query_params.get('allocations') in ['True', 'true']: - projects = projects.prefetch_related('allocation_set') + if self.request.query_params.get("allocations") in ["True", "true"]: + projects = projects.prefetch_related("allocation_set") - return projects.order_by('pi') + return projects.order_by("pi") class UserFilter(filters.FilterSet): is_staff = filters.BooleanFilter() is_active = filters.BooleanFilter() is_superuser = filters.BooleanFilter() - username = filters.CharFilter(field_name='username', lookup_expr='exact') + username = filters.CharFilter(field_name="username", lookup_expr="exact") class Meta: model = get_user_model() - fields = ['is_staff', 'is_active', 'is_superuser', 'username'] + fields = ["is_staff", "is_active", "is_superuser", "username"] class UserViewSet(viewsets.ReadOnlyModelViewSet): - '''Staff and superuser-only view for user data. + """Staff and superuser-only view for user data. Filter parameters: - username (exact) - is_active - is_superuser - is_staff - ''' + """ + serializer_class = serializers.UserSerializer filter_backends = (filters.DjangoFilterBackend,) filterset_class = UserFilter diff --git a/coldfront/plugins/freeipa/__init__.py b/coldfront/plugins/freeipa/__init__.py index 4b08178ede..ac9048af25 100644 --- a/coldfront/plugins/freeipa/__init__.py +++ b/coldfront/plugins/freeipa/__init__.py @@ -1 +1,5 @@ -default_app_config = 'coldfront.plugins.freeipa.apps.IPAConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +default_app_config = "coldfront.plugins.freeipa.apps.IPAConfig" diff --git a/coldfront/plugins/freeipa/apps.py b/coldfront/plugins/freeipa/apps.py index 6fc29db35f..d3bb248f8e 100644 --- a/coldfront/plugins/freeipa/apps.py +++ b/coldfront/plugins/freeipa/apps.py @@ -1,12 +1,19 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import importlib + from django.apps import AppConfig from coldfront.core.utils.common import import_from_settings -FREEIPA_ENABLE_SIGNALS = import_from_settings('FREEIPA_ENABLE_SIGNALS', False) +FREEIPA_ENABLE_SIGNALS = import_from_settings("FREEIPA_ENABLE_SIGNALS", False) + class IPAConfig(AppConfig): - name = 'coldfront.plugins.freeipa' + name = "coldfront.plugins.freeipa" def ready(self): if FREEIPA_ENABLE_SIGNALS: - import coldfront.plugins.freeipa.signals + importlib.import_module("coldfront.plugins.freeipa.signals") diff --git a/coldfront/plugins/freeipa/management/__init__.py b/coldfront/plugins/freeipa/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/freeipa/management/__init__.py +++ b/coldfront/plugins/freeipa/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/freeipa/management/commands/__init__.py b/coldfront/plugins/freeipa/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/freeipa/management/commands/__init__.py +++ b/coldfront/plugins/freeipa/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/freeipa/management/commands/freeipa_check.py b/coldfront/plugins/freeipa/management/commands/freeipa_check.py index 01d16aa9a6..ef914ccdef 100644 --- a/coldfront/plugins/freeipa/management/commands/freeipa_check.py +++ b/coldfront/plugins/freeipa/management/commands/freeipa_check.py @@ -1,54 +1,65 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging import os import sys -import dbus +import dbus from django.contrib.auth.models import User -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from ipalib import api -from ipalib.errors import NotFound +from coldfront.core.allocation.models import AllocationUser, AllocationUserStatusChoice +from coldfront.core.project.models import ProjectUser, ProjectUserStatusChoice from coldfront.plugins.freeipa.search import LDAPUserSearch -from coldfront.core.project.models import (ProjectUser, ProjectUserStatusChoice) -from coldfront.core.allocation.models import (Allocation, AllocationUser, AllocationUserStatusChoice) -from coldfront.plugins.freeipa.utils import (CLIENT_KTNAME, FREEIPA_NOOP, - UNIX_GROUP_ATTRIBUTE_NAME, - AlreadyMemberError, - NotMemberError, - check_ipa_group_error) +from coldfront.plugins.freeipa.utils import ( + CLIENT_KTNAME, + FREEIPA_NOOP, + UNIX_GROUP_ATTRIBUTE_NAME, + AlreadyMemberError, + NotMemberError, + check_ipa_group_error, +) logger = logging.getLogger(__name__) class Command(BaseCommand): - help = 'Sync groups in FreeIPA' + help = "Sync groups in FreeIPA" def add_arguments(self, parser): parser.add_argument("-s", "--sync", help="Sync changes to/from FreeIPA", action="store_true") parser.add_argument("-u", "--username", help="Check specific username") parser.add_argument("-g", "--group", help="Check specific group") - parser.add_argument("-d", "--disable", help="Disable users in ColdFront that are Disabled/NotFound in FreeIPA", action="store_true") + parser.add_argument( + "-d", + "--disable", + help="Disable users in ColdFront that are Disabled/NotFound in FreeIPA", + action="store_true", + ) parser.add_argument("-n", "--noop", help="Print commands only. Do not run any commands.", action="store_true") parser.add_argument("-x", "--header", help="Include header in output", action="store_true") def writerow(self, row): try: - self.stdout.write('{0: <12}{1: <20}{2: <30}{3}'.format(*row)) + self.stdout.write("{0: <12}{1: <20}{2: <30}{3}".format(*row)) except BrokenPipeError: devnull = os.open(os.devnull, os.O_WRONLY) os.dup2(devnull, sys.stdout.fileno()) sys.exit(1) def check_ipa_error(self, res): - if not res or 'result' not in res: - raise ValueError('Missing FreeIPA result') + if not res or "result" not in res: + raise ValueError("Missing FreeIPA result") def add_group(self, user, group, status): if self.sync and not self.noop: try: res = api.Command.group_add_member(group, user=[user.username]) check_ipa_group_error(res) - except AlreadyMemberError as e: + except AlreadyMemberError: logger.warn("User %s is already a member of group %s", user.username, group) except Exception as e: logger.error("Failed adding user %s to group %s: %s", user.username, group, e) @@ -56,10 +67,10 @@ def add_group(self, user, group, status): logger.info("Added user %s to group %s successfully", user.username, group) row = [ - 'Add', + "Add", user.username, group, - '/'.join([status, 'Active' if user.is_active else 'Inactive']), + "/".join([status, "Active" if user.is_active else "Inactive"]), ] self.writerow(row) @@ -69,7 +80,7 @@ def remove_group(self, user, group, status): try: res = api.Command.group_remove_member(group, user=[user.username]) check_ipa_group_error(res) - except NotMemberError as e: + except NotMemberError: logger.warn("User %s is not a member of group %s", user.username, group) except Exception as e: logger.error("Failed removing user %s from group %s: %s", user.username, group, e) @@ -77,20 +88,20 @@ def remove_group(self, user, group, status): logger.info("Removed user %s from group %s successfully", user.username, group) row = [ - 'Remove', + "Remove", user.username, group, - '/'.join([status, 'Active' if user.is_active else 'Inactive']), + "/".join([status, "Active" if user.is_active else "Inactive"]), ] self.writerow(row) def disable_user_in_coldfront(self, user, freeipa_status): row = [ - 'Disable', + "Disable", user.username, - '', - '/'.join([freeipa_status, 'Active' if user.is_active else 'Inactive']), + "", + "/".join([freeipa_status, "Active" if user.is_active else "Inactive"]), ] self.writerow(row) @@ -101,19 +112,19 @@ def disable_user_in_coldfront(self, user, freeipa_status): return # Disable user from any active allocations - inactive_status = AllocationUserStatusChoice.objects.get(name='Removed') + inactive_status = AllocationUserStatusChoice.objects.get(name="Removed") user_allocations = AllocationUser.objects.filter(user=user) for ua in user_allocations: - if ua.status.name == 'Active' and ua.allocation.status.name == 'Active': + if ua.status.name == "Active" and ua.allocation.status.name == "Active": logger.info("Removing user from allocation user=%s allocation=%s", user.username, ua.allocation) ua.status = inactive_status ua.save() # Disable user from any active projects - inactive_status = ProjectUserStatusChoice.objects.get(name='Removed') + inactive_status = ProjectUserStatusChoice.objects.get(name="Removed") user_projects = ProjectUser.objects.filter(user=user) for pa in user_projects: - if pa.status.name == 'Active' and pa.project.status.name == 'Active': + if pa.status.name == "Active" and pa.project.status.name == "Active": logger.info("Removing user from project user=%s project=%s", user.username, pa.project) pa.status = inactive_status pa.save() @@ -131,13 +142,15 @@ def sync_user_status(self, user, active=False): user.is_active = active user.save() except Exception as e: - logger.error('Failed to update user status: %s - %s', user.username, e) + logger.error("Failed to update user status: %s - %s", user.username, e) def check_user_freeipa(self, user, active_groups, removed_groups): - logger.info("Checking FreeIPA user=%s active_groups=%s removed_groups=%s", user.username, active_groups, removed_groups) + logger.info( + "Checking FreeIPA user=%s active_groups=%s removed_groups=%s", user.username, active_groups, removed_groups + ) freeipa_groups = [] - freeipa_status = 'Unknown' + freeipa_status = "Unknown" try: result = self.ifp.GetUserGroups(user.username) logger.debug(result) @@ -145,32 +158,32 @@ def check_user_freeipa(self, user, active_groups, removed_groups): users = self.ipa_ldap.search_a_user(user.username, "username_only") if len(users) == 1: - freeipa_status = 'Enabled' + freeipa_status = "Enabled" else: - freeipa_status = 'Disabled' + freeipa_status = "Disabled" except dbus.exceptions.DBusException as e: - if 'No such user' in str(e) or 'NotFound' in str(e): + if "No such user" in str(e) or "NotFound" in str(e): logger.info("Skipping user %s not found in FreeIPA", user.username) - freeipa_status = 'NotFound' + freeipa_status = "NotFound" else: logger.error("dbus error failed to find user %s in FreeIPA: %s", user.username, e) return - if freeipa_status == 'Disabled' and user.is_active: - logger.warn('User is active in coldfront but disabled in FreeIPA: %s', user.username) + if freeipa_status == "Disabled" and user.is_active: + logger.warn("User is active in coldfront but disabled in FreeIPA: %s", user.username) self.sync_user_status(user, active=False) - elif freeipa_status == 'Enabled' and not user.is_active: - logger.warn('User is not active in coldfront but enabled in FreeIPA: %s', user.username) + elif freeipa_status == "Enabled" and not user.is_active: + logger.warn("User is not active in coldfront but enabled in FreeIPA: %s", user.username) self.sync_user_status(user, active=True) for g in active_groups: if g not in freeipa_groups: - logger.info('User %s should be added to freeipa group: %s', user.username, g) + logger.info("User %s should be added to freeipa group: %s", user.username, g) self.add_group(user, g, freeipa_status) for g in removed_groups: if g in freeipa_groups: - logger.info('User %s should be removed from freeipa group: %s', user.username, g) + logger.info("User %s should be removed from freeipa group: %s", user.username, g) self.remove_group(user, g, freeipa_status) def process_user(self, user): @@ -178,8 +191,7 @@ def process_user(self, user): return user_allocations = AllocationUser.objects.filter( - user=user, - allocation__allocationattribute__allocation_attribute_type__name=UNIX_GROUP_ATTRIBUTE_NAME + user=user, allocation__allocationattribute__allocation_attribute_type__name=UNIX_GROUP_ATTRIBUTE_NAME ) active_groups = [] @@ -193,7 +205,11 @@ def process_user(self, user): all_resources_inactive = False if all_resources_inactive: - logger.debug("Skipping allocation to %s for user %s due to all resources being inactive", ua.allocation.get_resources_as_string, user.username) + logger.debug( + "Skipping allocation to %s for user %s due to all resources being inactive", + ua.allocation.get_resources_as_string, + user.username, + ) continue for g in ua.allocation.get_attribute_list(UNIX_GROUP_ATTRIBUTE_NAME): @@ -228,8 +244,8 @@ def process_user(self, user): def handle(self, *args, **options): os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME - verbosity = int(options['verbosity']) - root_logger = logging.getLogger('') + verbosity = int(options["verbosity"]) + root_logger = logging.getLogger("") if verbosity == 0: root_logger.setLevel(logging.ERROR) elif verbosity == 2: @@ -240,47 +256,46 @@ def handle(self, *args, **options): root_logger.setLevel(logging.WARN) self.noop = FREEIPA_NOOP - if options['noop']: + if options["noop"]: self.noop = True logger.warn("NOOP enabled") self.sync = False - if options['sync']: + if options["sync"]: self.sync = True logger.warn("Syncing FreeIPA with ColdFront") self.disable = False - if options['disable']: + if options["disable"]: self.disable = True logger.warn("Disabling users in ColdFront that are disabled in FreeIPA") header = [ - 'action', - 'username', - 'group', - 'ipa/cf', + "action", + "username", + "group", + "ipa/cf", ] - if options['header']: + if options["header"]: self.writerow(header) self.ipa_ldap = LDAPUserSearch("", "") bus = dbus.SystemBus() infopipe_obj = bus.get_object("org.freedesktop.sssd.infopipe", "/org/freedesktop/sssd/infopipe") - self.ifp = dbus.Interface(infopipe_obj, dbus_interface='org.freedesktop.sssd.infopipe') + self.ifp = dbus.Interface(infopipe_obj, dbus_interface="org.freedesktop.sssd.infopipe") users = User.objects.filter(is_active=True) logger.info("Processing %s active users", len(users)) - self.filter_user = '' - self.filter_group = '' - if options['username']: - logger.info("Filtering output by username: %s", - options['username']) - self.filter_user = options['username'] - if options['group']: - logger.info("Filtering output by group: %s", options['group']) - self.filter_group = options['group'] + self.filter_user = "" + self.filter_group = "" + if options["username"]: + logger.info("Filtering output by username: %s", options["username"]) + self.filter_user = options["username"] + if options["group"]: + logger.info("Filtering output by group: %s", options["group"]) + self.filter_group = options["group"] for user in users: self.process_user(user) @@ -292,15 +307,14 @@ def handle(self, *args, **options): try: result = self.ifp.GetUserAttr(user.username, ["nsaccountlock"]) - if 'nsAccountLock' in result and str(result['nsAccountLock'][0]) == 'TRUE': + if "nsAccountLock" in result and str(result["nsAccountLock"][0]) == "TRUE": # User is disabled in FreeIPA so disable in coldfront logger.info("User is disabled in FreeIPA so disable in ColdFront: %s", user.username) - self.disable_user_in_coldfront(user, 'Disabled') + self.disable_user_in_coldfront(user, "Disabled") except dbus.exceptions.DBusException as e: - if 'No such user' in str(e) or 'NotFound' in str(e): + if "No such user" in str(e) or "NotFound" in str(e): # User is not found in FreeIPA so disable in coldfront logger.info("User is not found in FreeIPA so disable in ColdFront: %s", user.username) - self.disable_user_in_coldfront(user, 'NotFound') + self.disable_user_in_coldfront(user, "NotFound") else: logger.error("dbus error failed while checking user %s in FreeIPA: %s", user.username, e) - diff --git a/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py b/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py index bbad1409b1..000a4841e7 100644 --- a/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py +++ b/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py @@ -1,22 +1,26 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import datetime import logging import os import sys -import datetime -import dbus +import dbus from django.core.management.base import BaseCommand from django.urls import reverse from ipalib import api -from ipalib.errors import NotFound +from coldfront.core.allocation.models import AllocationUser from coldfront.core.utils.mail import build_link -from coldfront.core.allocation.models import Allocation, AllocationUser -from coldfront.plugins.freeipa.utils import (CLIENT_KTNAME, FREEIPA_NOOP) +from coldfront.plugins.freeipa.utils import CLIENT_KTNAME, FREEIPA_NOOP logger = logging.getLogger(__name__) + class Command(BaseCommand): - help = 'Report users to expire in FreeIPA' + help = "Report users to expire in FreeIPA" def add_arguments(self, parser): parser.add_argument("-s", "--sync", help="Sync changes to/from FreeIPA", action="store_true") @@ -25,7 +29,7 @@ def add_arguments(self, parser): def writerow(self, row): try: - self.stdout.write('{0: <20}{1: <15}{2}'.format(*row)) + self.stdout.write("{0: <20}{1: <15}{2}".format(*row)) except BrokenPipeError: devnull = os.open(os.devnull, os.O_WRONLY) os.dup2(devnull, sys.stdout.fileno()) @@ -34,8 +38,8 @@ def writerow(self, row): def handle(self, *args, **options): os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME - verbosity = int(options['verbosity']) - root_logger = logging.getLogger('') + verbosity = int(options["verbosity"]) + root_logger = logging.getLogger("") if verbosity == 0: root_logger.setLevel(logging.ERROR) elif verbosity == 2: @@ -46,38 +50,41 @@ def handle(self, *args, **options): root_logger.setLevel(logging.WARN) self.sync = False - if options['sync']: + if options["sync"]: self.sync = True logger.warn("Syncing FreeIPA with ColdFront") self.noop = FREEIPA_NOOP - if options['noop']: + if options["noop"]: self.noop = True logger.warn("NOOP enabled") header = [ - 'username', - 'expire_date', - 'allocation', + "username", + "expire_date", + "allocation", ] - if options['header']: + if options["header"]: self.writerow(header) bus = dbus.SystemBus() infopipe_obj = bus.get_object("org.freedesktop.sssd.infopipe", "/org/freedesktop/sssd/infopipe") - ifp = dbus.Interface(infopipe_obj, dbus_interface='org.freedesktop.sssd.infopipe') - + ifp = dbus.Interface(infopipe_obj, dbus_interface="org.freedesktop.sssd.infopipe") expired_365_days_ago = datetime.datetime.today() - datetime.timedelta(days=365) expired_365_days_ago = expired_365_days_ago.date() # Find all active users on active allocations - active_users = sorted(list(set( - AllocationUser.objects.filter( - status__name='Active' - ).exclude(allocation__status__name__in=['Expired']).values_list('user__username', flat=True) - ))) + active_users = sorted( + list( + set( + AllocationUser.objects.filter(status__name="Active") + .exclude(allocation__status__name__in=["Expired"]) + .values_list("user__username", flat=True) + ) + ) + ) # Filter out users to expire, either not active or have been removed expired_allocation_users = {} @@ -87,59 +94,69 @@ def handle(self, *args, **options): allocation = allocationuser.allocation expire_date = allocation.end_date - if allocation.status.name != 'Expired' and allocationuser.status.name == 'Removed': + if allocation.status.name != "Expired" and allocationuser.status.name == "Removed": expire_date = allocationuser.modified.date() if not expire_date: - logger.info("Unable to find expire date for user=%s allocation_id=%s", allocationuser.user.username, allocation.id) + logger.info( + "Unable to find expire date for user=%s allocation_id=%s", + allocationuser.user.username, + allocation.id, + ) continue if allocationuser.user.username not in expired_allocation_users: expired_allocation_users[allocationuser.user.username] = { - 'user': allocationuser.user, - 'expire_date': expire_date, - 'allocation_id': allocation.id + "user": allocationuser.user, + "expire_date": expire_date, + "allocation_id": allocation.id, } else: - if expire_date > expired_allocation_users[allocationuser.user.username]['expire_date']: + if expire_date > expired_allocation_users[allocationuser.user.username]["expire_date"]: expired_allocation_users[allocationuser.user.username] = { - 'user': allocationuser.user, - 'expire_date': expire_date, - 'allocation_id': allocation.id + "user": allocationuser.user, + "expire_date": expire_date, + "allocation_id": allocation.id, } # Print users whose latest allocation expiration date GTE 365 days and active in FreeIPA for key in expired_allocation_users.keys(): - if expired_allocation_users[key]['expire_date'] > expired_365_days_ago: + if expired_allocation_users[key]["expire_date"] > expired_365_days_ago: continue try: result = ifp.GetUserAttr(key, ["nsaccountlock"]) - if 'nsAccountLock' in result and str(result['nsAccountLock'][0]).lower() == 'true': + if "nsAccountLock" in result and str(result["nsAccountLock"][0]).lower() == "true": # User is already disabled in FreeIPA so do nothing logger.info("User already disabled in FreeIPA: %s", key) pass else: # User is active in FreeIPA but not on any active allocations - self.writerow([ - key, - expired_allocation_users[key]['expire_date'].strftime("%Y-%m-%d"), - build_link(reverse('allocation-detail', kwargs={'pk': expired_allocation_users[key]['allocation_id']})) - ]) + self.writerow( + [ + key, + expired_allocation_users[key]["expire_date"].strftime("%Y-%m-%d"), + build_link( + reverse( + "allocation-detail", kwargs={"pk": expired_allocation_users[key]["allocation_id"]} + ) + ), + ] + ) if self.sync and not self.noop: # Disable in ColdFront - expired_allocation_users[key]['user'].is_active = False - expired_allocation_users[key]['user'].save() + expired_allocation_users[key]["user"].is_active = False + expired_allocation_users[key]["user"].save() # Disable in FreeIPA res = api.Command.user_disable(key) if not res: - raise ValueError('Missing FreeIPA response') - if 'result' not in res or not res['result']: - raise ValueError(f'Failed to disable user: {res}') + raise ValueError("Missing FreeIPA response") + if "result" not in res or not res["result"]: + raise ValueError(f"Failed to disable user: {res}") except dbus.exceptions.DBusException as e: - if 'No such user' in str(e) or 'NotFound' in str(e): + if "No such user" in str(e) or "NotFound" in str(e): logger.info("User %s not found in FreeIPA", key) else: logger.error("dbus error failed to find user %s in FreeIPA: %s", key, e) diff --git a/coldfront/plugins/freeipa/search.py b/coldfront/plugins/freeipa/search.py index 2c7cf8a7eb..12da2f9cab 100644 --- a/coldfront/plugins/freeipa/search.py +++ b/coldfront/plugins/freeipa/search.py @@ -1,27 +1,33 @@ -import os +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import json import logging +import os import ldap.filter +from django.core.exceptions import ImproperlyConfigured +from ldap3 import KERBEROS, SASL, Connection, Server + from coldfront.core.user.utils import UserSearch from coldfront.core.utils.common import import_from_settings -from django.core.exceptions import ImproperlyConfigured -from ldap3 import Connection, Server, SASL, KERBEROS logger = logging.getLogger(__name__) + class LDAPUserSearch(UserSearch): - search_source = 'LDAP' + search_source = "LDAP" def __init__(self, user_search_string, search_by): super().__init__(user_search_string, search_by) - self.FREEIPA_SERVER = import_from_settings('FREEIPA_SERVER') - self.FREEIPA_USER_SEARCH_BASE = import_from_settings('FREEIPA_USER_SEARCH_BASE', 'cn=users,cn=accounts') - self.FREEIPA_KTNAME = import_from_settings('FREEIPA_KTNAME', '') + self.FREEIPA_SERVER = import_from_settings("FREEIPA_SERVER") + self.FREEIPA_USER_SEARCH_BASE = import_from_settings("FREEIPA_USER_SEARCH_BASE", "cn=users,cn=accounts") + self.FREEIPA_KTNAME = import_from_settings("FREEIPA_KTNAME", "") - self.server = Server('ldap://{}'.format(self.FREEIPA_SERVER), use_ssl=True, connect_timeout=1) + self.server = Server("ldap://{}".format(self.FREEIPA_SERVER), use_ssl=True, connect_timeout=1) if len(self.FREEIPA_KTNAME) > 0: - logger.info('Kerberos bind enabled: %s', self.FREEIPA_KTNAME) + logger.info("Kerberos bind enabled: %s", self.FREEIPA_KTNAME) # kerberos SASL/GSSAPI bind os.environ["KRB5_CLIENT_KTNAME"] = self.FREEIPA_KTNAME self.conn = Connection(self.server, authentication=SASL, sasl_mechanism=KERBEROS, auto_bind=True) @@ -30,39 +36,46 @@ def __init__(self, user_search_string, search_by): self.conn = Connection(self.server, auto_bind=True) if not self.conn.bind(): - raise ImproperlyConfigured('Failed to bind to LDAP server: {}'.format(self.conn.result)) + raise ImproperlyConfigured("Failed to bind to LDAP server: {}".format(self.conn.result)) else: - logger.info('LDAP bind successful: %s', self.conn.extend.standard.who_am_i()) + logger.info("LDAP bind successful: %s", self.conn.extend.standard.who_am_i()) def parse_ldap_entry(self, entry): - entry_dict = json.loads(entry.entry_to_json()).get('attributes') + entry_dict = json.loads(entry.entry_to_json()).get("attributes") user_dict = { - 'last_name': entry_dict.get('sn')[0] if entry_dict.get('sn') else '', - 'first_name': entry_dict.get('givenName')[0] if entry_dict.get('givenName') else '', - 'username': entry_dict.get('uid')[0] if entry_dict.get('uid') else '', - 'email': entry_dict.get('mail')[0] if entry_dict.get('mail') else '', - 'source': self.search_source, + "last_name": entry_dict.get("sn")[0] if entry_dict.get("sn") else "", + "first_name": entry_dict.get("givenName")[0] if entry_dict.get("givenName") else "", + "username": entry_dict.get("uid")[0] if entry_dict.get("uid") else "", + "email": entry_dict.get("mail")[0] if entry_dict.get("mail") else "", + "source": self.search_source, } return user_dict - def search_a_user(self, user_search_string=None, search_by='all_fields'): + def search_a_user(self, user_search_string=None, search_by="all_fields"): os.environ["KRB5_CLIENT_KTNAME"] = self.FREEIPA_KTNAME size_limit = 50 - if user_search_string and search_by == 'all_fields': - filter = ldap.filter.filter_format("(&(|(givenName=*%s*)(sn=*%s*)(uid=*%s*)(mail=*%s*))(|(nsaccountlock=FALSE)(!(nsaccountlock=*))))", [user_search_string] * 4) - elif user_search_string and search_by == 'username_only': - filter = ldap.filter.filter_format("(&(uid=%s)(|(nsaccountlock=FALSE)(!(nsaccountlock=*))))", [user_search_string]) + if user_search_string and search_by == "all_fields": + filter = ldap.filter.filter_format( + "(&(|(givenName=*%s*)(sn=*%s*)(uid=*%s*)(mail=*%s*))(|(nsaccountlock=FALSE)(!(nsaccountlock=*))))", + [user_search_string] * 4, + ) + elif user_search_string and search_by == "username_only": + filter = ldap.filter.filter_format( + "(&(uid=%s)(|(nsaccountlock=FALSE)(!(nsaccountlock=*))))", [user_search_string] + ) size_limit = 1 else: - filter = '(objectclass=person)' + filter = "(objectclass=person)" - searchParameters = {'search_base': self.FREEIPA_USER_SEARCH_BASE, - 'search_filter': filter, - 'attributes': ['uid', 'sn', 'givenName', 'mail'], - 'size_limit': size_limit} + searchParameters = { + "search_base": self.FREEIPA_USER_SEARCH_BASE, + "search_filter": filter, + "attributes": ["uid", "sn", "givenName", "mail"], + "size_limit": size_limit, + } self.conn.search(**searchParameters) users = [] for idx, entry in enumerate(self.conn.entries, 1): diff --git a/coldfront/plugins/freeipa/signals.py b/coldfront/plugins/freeipa/signals.py index e54782dd91..ebcaa6246d 100644 --- a/coldfront/plugins/freeipa/signals.py +++ b/coldfront/plugins/freeipa/signals.py @@ -1,28 +1,25 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.dispatch import receiver from django_q.tasks import async_task -from coldfront.core.allocation.signals import (allocation_activate_user, - allocation_remove_user) -from coldfront.core.allocation.views import (AllocationAddUsersView, - AllocationRemoveUsersView, - AllocationRenewView) -from coldfront.core.project.views import (ProjectAddUsersView, - ProjectRemoveUsersView) -from coldfront.core.utils.common import import_from_settings +from coldfront.core.allocation.signals import allocation_activate_user, allocation_remove_user +from coldfront.core.allocation.views import AllocationAddUsersView, AllocationRemoveUsersView, AllocationRenewView +from coldfront.core.project.views import ProjectAddUsersView, ProjectRemoveUsersView @receiver(allocation_activate_user, sender=ProjectAddUsersView) @receiver(allocation_activate_user, sender=AllocationAddUsersView) def activate_user(sender, **kwargs): - allocation_user_pk = kwargs.get('allocation_user_pk') - async_task('coldfront.plugins.freeipa.tasks.add_user_group', - allocation_user_pk) + allocation_user_pk = kwargs.get("allocation_user_pk") + async_task("coldfront.plugins.freeipa.tasks.add_user_group", allocation_user_pk) @receiver(allocation_remove_user, sender=ProjectRemoveUsersView) @receiver(allocation_remove_user, sender=AllocationRemoveUsersView) @receiver(allocation_remove_user, sender=AllocationRenewView) def remove_user(sender, **kwargs): - allocation_user_pk = kwargs.get('allocation_user_pk') - async_task('coldfront.plugins.freeipa.tasks.remove_user_group', - allocation_user_pk) + allocation_user_pk = kwargs.get("allocation_user_pk") + async_task("coldfront.plugins.freeipa.tasks.remove_user_group", allocation_user_pk) diff --git a/coldfront/plugins/freeipa/tasks.py b/coldfront/plugins/freeipa/tasks.py index 95d2e62ad2..3ef66a74dc 100644 --- a/coldfront/plugins/freeipa/tasks.py +++ b/coldfront/plugins/freeipa/tasks.py @@ -1,33 +1,37 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging import os -from django.contrib.auth.models import User from ipalib import api from coldfront.core.allocation.models import Allocation, AllocationUser from coldfront.core.allocation.utils import set_allocation_user_status_to_error -from coldfront.plugins.freeipa.utils import (CLIENT_KTNAME, FREEIPA_NOOP, - UNIX_GROUP_ATTRIBUTE_NAME, - AlreadyMemberError, ApiError, - NotMemberError, - check_ipa_group_error) +from coldfront.plugins.freeipa.utils import ( + CLIENT_KTNAME, + FREEIPA_NOOP, + UNIX_GROUP_ATTRIBUTE_NAME, + AlreadyMemberError, + NotMemberError, + check_ipa_group_error, +) logger = logging.getLogger(__name__) def add_user_group(allocation_user_pk): allocation_user = AllocationUser.objects.get(pk=allocation_user_pk) - if allocation_user.allocation.status.name != 'Active': + if allocation_user.allocation.status.name != "Active": logger.warn("Allocation is not active. Will not add groups") return - if allocation_user.status.name != 'Active': - logger.warn( - "Allocation user status is not 'Active'. Will not add groups.") + if allocation_user.status.name != "Active": + logger.warn("Allocation user status is not 'Active'. Will not add groups.") return - groups = allocation_user.allocation.get_attribute_list( - UNIX_GROUP_ATTRIBUTE_NAME) + groups = allocation_user.allocation.get_attribute_list(UNIX_GROUP_ATTRIBUTE_NAME) if len(groups) == 0: logger.info("Allocation does not have any groups. Nothing to add") return @@ -35,52 +39,57 @@ def add_user_group(allocation_user_pk): os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME for g in groups: if FREEIPA_NOOP: - logger.warn("NOOP - FreeIPA adding user %s to group %s for allocation %s", - allocation_user.user.username, g, allocation_user.allocation) + logger.warn( + "NOOP - FreeIPA adding user %s to group %s for allocation %s", + allocation_user.user.username, + g, + allocation_user.allocation, + ) continue try: - res = api.Command.group_add_member( - g, user=[allocation_user.user.username]) + res = api.Command.group_add_member(g, user=[allocation_user.user.username]) check_ipa_group_error(res) - except AlreadyMemberError as e: - logger.warn("User %s is already a member of group %s", - allocation_user.user.username, g) + except AlreadyMemberError: + logger.warn("User %s is already a member of group %s", allocation_user.user.username, g) except Exception as e: - logger.error("Failed adding user %s to group %s: %s", - allocation_user.user.username, g, e) + logger.error("Failed adding user %s to group %s: %s", allocation_user.user.username, g, e) set_allocation_user_status_to_error(allocation_user_pk) else: - logger.info("Added user %s to group %s successfully", - allocation_user.user.username, g) + logger.info("Added user %s to group %s successfully", allocation_user.user.username, g) def remove_user_group(allocation_user_pk): allocation_user = AllocationUser.objects.get(pk=allocation_user_pk) - if allocation_user.allocation.status.name not in ['Active', 'Pending', 'Inactive (Renewed)', ]: - logger.warn( - "Allocation is not active or pending. Will not remove groups.") + if allocation_user.allocation.status.name not in [ + "Active", + "Pending", + "Inactive (Renewed)", + ]: + logger.warn("Allocation is not active or pending. Will not remove groups.") return - if allocation_user.status.name != 'Removed': - logger.warn( - "Allocation user status is not 'Removed'. Will not remove groups.") + if allocation_user.status.name != "Removed": + logger.warn("Allocation user status is not 'Removed'. Will not remove groups.") return - groups = allocation_user.allocation.get_attribute_list( - UNIX_GROUP_ATTRIBUTE_NAME) + groups = allocation_user.allocation.get_attribute_list(UNIX_GROUP_ATTRIBUTE_NAME) if len(groups) == 0: logger.info("Allocation does not have any groups. Nothing to remove") return # Check other active allocations the user is active on for FreeIPA groups # and ensure we don't remove them. - user_allocations = Allocation.objects.filter( - allocationuser__user=allocation_user.user, - allocationuser__status__name='Active', - status__name='Active', - allocationattribute__allocation_attribute_type__name=UNIX_GROUP_ATTRIBUTE_NAME - ).exclude(pk=allocation_user.allocation.pk).distinct() + user_allocations = ( + Allocation.objects.filter( + allocationuser__user=allocation_user.user, + allocationuser__status__name="Active", + status__name="Active", + allocationattribute__allocation_attribute_type__name=UNIX_GROUP_ATTRIBUTE_NAME, + ) + .exclude(pk=allocation_user.allocation.pk) + .distinct() + ) exclude = [] for a in user_allocations: @@ -92,28 +101,27 @@ def remove_user_group(allocation_user_pk): groups.remove(g) if len(groups) == 0: - logger.info( - "No groups to remove. User may belong to these groups in other active allocations: %s", exclude) + logger.info("No groups to remove. User may belong to these groups in other active allocations: %s", exclude) return os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME for g in groups: if FREEIPA_NOOP: - logger.warn("NOOP - FreeIPA removing user %s from group %s for allocation %s", - allocation_user.user.username, g, allocation_user.allocation) + logger.warn( + "NOOP - FreeIPA removing user %s from group %s for allocation %s", + allocation_user.user.username, + g, + allocation_user.allocation, + ) continue try: - res = api.Command.group_remove_member( - g, user=[allocation_user.user.username]) + res = api.Command.group_remove_member(g, user=[allocation_user.user.username]) check_ipa_group_error(res) - except NotMemberError as e: - logger.warn("User %s is not a member of group %s", - allocation_user.user.username, g) + except NotMemberError: + logger.warn("User %s is not a member of group %s", allocation_user.user.username, g) except Exception as e: - logger.error("Failed removing user %s from group %s: %s", - allocation_user.user.username, g, e) + logger.error("Failed removing user %s from group %s: %s", allocation_user.user.username, g, e) set_allocation_user_status_to_error(allocation_user_pk) else: - logger.info("Removed user %s from group %s successfully", - allocation_user.user.username, g) + logger.info("Removed user %s from group %s successfully", allocation_user.user.username, g) diff --git a/coldfront/plugins/freeipa/utils.py b/coldfront/plugins/freeipa/utils.py index e3bf5ef995..0893bf9662 100644 --- a/coldfront/plugins/freeipa/utils.py +++ b/coldfront/plugins/freeipa/utils.py @@ -1,26 +1,34 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging import os from django.core.exceptions import ImproperlyConfigured -from coldfront.core.utils.common import import_from_settings - from ipalib import api -CLIENT_KTNAME = import_from_settings('FREEIPA_KTNAME') -UNIX_GROUP_ATTRIBUTE_NAME = import_from_settings('FREEIPA_GROUP_ATTRIBUTE_NAME', 'freeipa_group') -FREEIPA_NOOP = import_from_settings('FREEIPA_NOOP', False) +from coldfront.core.utils.common import import_from_settings + +CLIENT_KTNAME = import_from_settings("FREEIPA_KTNAME") +UNIX_GROUP_ATTRIBUTE_NAME = import_from_settings("FREEIPA_GROUP_ATTRIBUTE_NAME", "freeipa_group") +FREEIPA_NOOP = import_from_settings("FREEIPA_NOOP", False) logger = logging.getLogger(__name__) + class ApiError(Exception): pass + class AlreadyMemberError(ApiError): pass + class NotMemberError(ApiError): pass + try: os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME api.bootstrap() @@ -28,25 +36,26 @@ class NotMemberError(ApiError): api.Backend.rpcclient.connect() except Exception as e: logger.error("Failed to initialze FreeIPA lib: %s", e) - raise ImproperlyConfigured('Failed to initialze FreeIPA: {0}'.format(e)) + raise ImproperlyConfigured("Failed to initialze FreeIPA: {0}".format(e)) + def check_ipa_group_error(res): if not res: - raise ValueError('Missing FreeIPA response') + raise ValueError("Missing FreeIPA response") - if res['completed'] == 1: + if res["completed"] == 1: return - user = res['failed']['member']['user'][0][0] - group = res['result']['cn'][0] - err_msg = res['failed']['member']['user'][0][1] + res["failed"]["member"]["user"][0][0] + res["result"]["cn"][0] + err_msg = res["failed"]["member"]["user"][0][1] # Check if user is already a member - if err_msg == 'This entry is already a member': + if err_msg == "This entry is already a member": raise AlreadyMemberError(err_msg) # Check if user is not a member - if err_msg == 'This entry is not a member': + if err_msg == "This entry is not a member": raise NotMemberError(err_msg) raise ApiError(err_msg) diff --git a/coldfront/plugins/iquota/__init__.py b/coldfront/plugins/iquota/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/iquota/__init__.py +++ b/coldfront/plugins/iquota/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/iquota/admin.py b/coldfront/plugins/iquota/admin.py index 8c38f3f3da..97070bc06b 100644 --- a/coldfront/plugins/iquota/admin.py +++ b/coldfront/plugins/iquota/admin.py @@ -1,3 +1,5 @@ -from django.contrib import admin +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Register your models here. diff --git a/coldfront/plugins/iquota/apps.py b/coldfront/plugins/iquota/apps.py index f257a970b2..87b7756bf2 100644 --- a/coldfront/plugins/iquota/apps.py +++ b/coldfront/plugins/iquota/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class IquotaConfig(AppConfig): - name = 'coldfront.plugins.iquota' + name = "coldfront.plugins.iquota" diff --git a/coldfront/plugins/iquota/exceptions.py b/coldfront/plugins/iquota/exceptions.py index 747362931c..e7167eb73d 100644 --- a/coldfront/plugins/iquota/exceptions.py +++ b/coldfront/plugins/iquota/exceptions.py @@ -1,3 +1,8 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + + class IquotaError(Exception): """Base error class.""" @@ -7,9 +12,11 @@ def __init__(self, message): class KerberosError(IquotaError): """Kerberos Auth error""" + pass class MissingQuotaError(IquotaError): """User request error""" + pass diff --git a/coldfront/plugins/iquota/migrations/__init__.py b/coldfront/plugins/iquota/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/iquota/migrations/__init__.py +++ b/coldfront/plugins/iquota/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/iquota/urls.py b/coldfront/plugins/iquota/urls.py index cd24f4e724..6b68641c5a 100644 --- a/coldfront/plugins/iquota/urls.py +++ b/coldfront/plugins/iquota/urls.py @@ -1,7 +1,11 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.urls import path from coldfront.plugins.iquota.views import get_isilon_quota urlpatterns = [ - path('get-isilon-quota/', get_isilon_quota, name='get-isilon-quota'), + path("get-isilon-quota/", get_isilon_quota, name="get-isilon-quota"), ] diff --git a/coldfront/plugins/iquota/utils.py b/coldfront/plugins/iquota/utils.py index c8149c6f0b..9b63bcc3a9 100644 --- a/coldfront/plugins/iquota/utils.py +++ b/coldfront/plugins/iquota/utils.py @@ -1,26 +1,29 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import os import humanize +import kerberos import requests -import kerberos from coldfront.core.utils.common import import_from_settings from coldfront.plugins.iquota.exceptions import KerberosError, MissingQuotaError class Iquota: - def __init__(self, username, groups): """Initialize settings.""" - self.IQUOTA_API_HOST = import_from_settings('IQUOTA_API_HOST') - self.IQUOTA_API_PORT = import_from_settings('IQUOTA_API_PORT') - self.IQUOTA_CA_CERT = import_from_settings('IQUOTA_CA_CERT') - self.IQUOTA_KEYTAB = import_from_settings('IQUOTA_KEYTAB') + self.IQUOTA_API_HOST = import_from_settings("IQUOTA_API_HOST") + self.IQUOTA_API_PORT = import_from_settings("IQUOTA_API_PORT") + self.IQUOTA_CA_CERT = import_from_settings("IQUOTA_CA_CERT") + self.IQUOTA_KEYTAB = import_from_settings("IQUOTA_KEYTAB") self.username = username self.groups = groups def gssclient_token(self): - os.environ['KRB5_CLIENT_KTNAME'] = self.IQUOTA_KEYTAB + os.environ["KRB5_CLIENT_KTNAME"] = self.IQUOTA_KEYTAB service = "HTTP@" + self.IQUOTA_API_HOST @@ -28,87 +31,75 @@ def gssclient_token(self): (_, vc) = kerberos.authGSSClientInit(service) kerberos.authGSSClientStep(vc, "") return kerberos.authGSSClientResponse(vc) - except kerberos.GSSError as e: - raise KerberosError('error initializing GSS client') + except kerberos.GSSError: + raise KerberosError("error initializing GSS client") def _humanize_user_quota(self, path, user_used, user_limit): - user_quota = { - 'path': path, - 'username': self.username, - 'used': humanize.naturalsize(user_used), - 'limit': humanize.naturalsize(user_limit), - 'percent_used': round((user_used / user_limit) * 100) + "path": path, + "username": self.username, + "used": humanize.naturalsize(user_used), + "limit": humanize.naturalsize(user_limit), + "percent_used": round((user_used / user_limit) * 100), } return user_quota def get_user_quota(self): - token = self.gssclient_token() headers = {"Authorization": "Negotiate " + token} - url = "https://{}:{}/quota?user={}".format( - self.IQUOTA_API_HOST, - self.IQUOTA_API_PORT, - self.username) + url = "https://{}:{}/quota?user={}".format(self.IQUOTA_API_HOST, self.IQUOTA_API_PORT, self.username) r = requests.get(url, headers=headers, verify=self.IQUOTA_CA_CERT) try: usage = r.json()[0] - except KeyError as e: - raise MissingQuotaError( - 'Missing user quota for username: %s' % (self.username)) + except KeyError: + raise MissingQuotaError("Missing user quota for username: %s" % (self.username)) else: - user_used = usage['used'] - user_limit = usage['soft_limit'] - return self._humanize_user_quota(usage['path'], user_used, user_limit) + user_used = usage["used"] + user_limit = usage["soft_limit"] + return self._humanize_user_quota(usage["path"], user_used, user_limit) def _humanize_group_quota(self, path, group_user, group_limit): - group_quota = { - 'path': path, - 'used': humanize.naturalsize(group_user), - 'limit': humanize.naturalsize(group_limit), - 'percent_used': round((group_user / group_limit) * 100) + "path": path, + "used": humanize.naturalsize(group_user), + "limit": humanize.naturalsize(group_limit), + "percent_used": round((group_user / group_limit) * 100), } return group_quota def _get_group_quota(self, group): - token = self.gssclient_token() headers = {"Authorization": "Negotiate " + token} - url = "https://{}:{}/quota?group={}".format( - self.IQUOTA_API_HOST, - self.IQUOTA_API_PORT, - group) + url = "https://{}:{}/quota?group={}".format(self.IQUOTA_API_HOST, self.IQUOTA_API_PORT, group) r = requests.get(url, headers=headers, verify=self.IQUOTA_CA_CERT) try: usage = r.json() - check = usage[0] - except: + usage[0] + except Exception: return [] quotas = [] for q in usage: - group_limit = q['soft_limit'] - group_used = q['used'] + group_limit = q["soft_limit"] + group_used = q["used"] if group_limit == 0: continue - quotas.append(self._humanize_group_quota(q['path'], group_used, group_limit)) + quotas.append(self._humanize_group_quota(q["path"], group_used, group_limit)) return quotas def get_group_quotas(self): - if not self.groups: return None @@ -116,6 +107,6 @@ def get_group_quotas(self): for group in self.groups: group_quota = self._get_group_quota(group) for g in group_quota: - group_quotas[g['path']] = g + group_quotas[g["path"]] = g return group_quotas diff --git a/coldfront/plugins/iquota/views.py b/coldfront/plugins/iquota/views.py index d0840693be..fc0497a8e6 100644 --- a/coldfront/plugins/iquota/views.py +++ b/coldfront/plugins/iquota/views.py @@ -1,13 +1,16 @@ -from django.contrib.auth.decorators import login_required -from django.shortcuts import render +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.http import HttpResponse +from django.shortcuts import render from coldfront.plugins.iquota.utils import Iquota def get_isilon_quota(request): if not request.user.is_authenticated: - return HttpResponse('401 Unauthorized', status=401) + return HttpResponse("401 Unauthorized", status=401) username = request.user.username groups = [group.name for group in request.user.groups.all()] @@ -15,8 +18,8 @@ def get_isilon_quota(request): iquota = Iquota(username, groups) context = { - 'user_quota': iquota.get_user_quota(), - 'group_quotas': iquota.get_group_quotas(), + "user_quota": iquota.get_user_quota(), + "group_quotas": iquota.get_group_quotas(), } return render(request, "iquota/iquota.html", context) diff --git a/coldfront/plugins/ldap_user_search/__init__.py b/coldfront/plugins/ldap_user_search/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/ldap_user_search/__init__.py +++ b/coldfront/plugins/ldap_user_search/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/ldap_user_search/admin.py b/coldfront/plugins/ldap_user_search/admin.py index 8c38f3f3da..97070bc06b 100644 --- a/coldfront/plugins/ldap_user_search/admin.py +++ b/coldfront/plugins/ldap_user_search/admin.py @@ -1,3 +1,5 @@ -from django.contrib import admin +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Register your models here. diff --git a/coldfront/plugins/ldap_user_search/apps.py b/coldfront/plugins/ldap_user_search/apps.py index 8b5610bb15..5a86d5beba 100644 --- a/coldfront/plugins/ldap_user_search/apps.py +++ b/coldfront/plugins/ldap_user_search/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class LdapUserSearchConfig(AppConfig): - name = 'coldfront.plugins.ldap_user_search' + name = "coldfront.plugins.ldap_user_search" diff --git a/coldfront/plugins/ldap_user_search/migrations/__init__.py b/coldfront/plugins/ldap_user_search/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/ldap_user_search/migrations/__init__.py +++ b/coldfront/plugins/ldap_user_search/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/ldap_user_search/models.py b/coldfront/plugins/ldap_user_search/models.py index 71a8362390..73294d7dba 100644 --- a/coldfront/plugins/ldap_user_search/models.py +++ b/coldfront/plugins/ldap_user_search/models.py @@ -1,3 +1,5 @@ -from django.db import models +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your models here. diff --git a/coldfront/plugins/ldap_user_search/tests.py b/coldfront/plugins/ldap_user_search/tests.py index 7ce503c2dd..576ead011d 100644 --- a/coldfront/plugins/ldap_user_search/tests.py +++ b/coldfront/plugins/ldap_user_search/tests.py @@ -1,3 +1,5 @@ -from django.test import TestCase +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your tests here. diff --git a/coldfront/plugins/ldap_user_search/utils.py b/coldfront/plugins/ldap_user_search/utils.py index b6f59acf47..fe65c57afe 100644 --- a/coldfront/plugins/ldap_user_search/utils.py +++ b/coldfront/plugins/ldap_user_search/utils.py @@ -1,48 +1,56 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import json import logging +import ssl import ldap.filter +from ldap3 import AUTO_BIND_TLS_BEFORE_BIND, SASL, Connection, Server, Tls, get_config_parameter, set_config_parameter + from coldfront.core.user.utils import UserSearch from coldfront.core.utils.common import import_from_settings -from ldap3 import Connection, Server, Tls, get_config_parameter, set_config_parameter, SASL, AUTO_BIND_TLS_BEFORE_BIND -import ssl logger = logging.getLogger(__name__) class LDAPUserSearch(UserSearch): - search_source = 'LDAP' + search_source = "LDAP" def __init__(self, user_search_string, search_by): super().__init__(user_search_string, search_by) - self.LDAP_SERVER_URI = import_from_settings('LDAP_USER_SEARCH_SERVER_URI') - self.LDAP_USER_SEARCH_BASE = import_from_settings('LDAP_USER_SEARCH_BASE') - self.LDAP_BIND_DN = import_from_settings('LDAP_USER_SEARCH_BIND_DN', None) - self.LDAP_BIND_PASSWORD = import_from_settings('LDAP_USER_SEARCH_BIND_PASSWORD', None) - self.LDAP_CONNECT_TIMEOUT = import_from_settings('LDAP_USER_SEARCH_CONNECT_TIMEOUT', 2.5) - self.LDAP_USE_SSL = import_from_settings('LDAP_USER_SEARCH_USE_SSL', True) + self.LDAP_SERVER_URI = import_from_settings("LDAP_USER_SEARCH_SERVER_URI") + self.LDAP_USER_SEARCH_BASE = import_from_settings("LDAP_USER_SEARCH_BASE") + self.LDAP_BIND_DN = import_from_settings("LDAP_USER_SEARCH_BIND_DN", None) + self.LDAP_BIND_PASSWORD = import_from_settings("LDAP_USER_SEARCH_BIND_PASSWORD", None) + self.LDAP_CONNECT_TIMEOUT = import_from_settings("LDAP_USER_SEARCH_CONNECT_TIMEOUT", 2.5) + self.LDAP_USE_SSL = import_from_settings("LDAP_USER_SEARCH_USE_SSL", True) self.LDAP_USE_TLS = import_from_settings("LDAP_USER_SEARCH_USE_TLS", False) self.LDAP_SASL_MECHANISM = import_from_settings("LDAP_USER_SEARCH_SASL_MECHANISM", None) - self.LDAP_SASL_CREDENTIALS = import_from_settings("LDAP_USER_SEARCH_SASL_CREDENTIALS", None) - self.LDAP_PRIV_KEY_FILE = import_from_settings('LDAP_USER_SEARCH_PRIV_KEY_FILE', None) - self.LDAP_CERT_FILE = import_from_settings('LDAP_USER_SEARCH_CERT_FILE', None) - self.LDAP_CACERT_FILE = import_from_settings('LDAP_USER_SEARCH_CACERT_FILE', None) - self.LDAP_CERT_VALIDATE_MODE = import_from_settings('LDAP_USER_SEARCH_CERT_VALIDATE_MODE', None) - self.USERNAME_ONLY_ATTR = import_from_settings('LDAP_USER_SEARCH_USERNAME_ONLY_ATTR', 'username') - self.ATTRIBUTE_MAP = import_from_settings('LDAP_USER_SEARCH_ATTRIBUTE_MAP', { - "username": "uid", - "last_name": "sn", - "first_name": "givenName", - "email": "mail", - }) - self.MAPPING_CALLBACK = import_from_settings('LDAP_USER_SEARCH_MAPPING_CALLBACK', self.parse_ldap_entry) + self.LDAP_SASL_CREDENTIALS = import_from_settings("LDAP_USER_SEARCH_SASL_CREDENTIALS", None) + self.LDAP_PRIV_KEY_FILE = import_from_settings("LDAP_USER_SEARCH_PRIV_KEY_FILE", None) + self.LDAP_CERT_FILE = import_from_settings("LDAP_USER_SEARCH_CERT_FILE", None) + self.LDAP_CACERT_FILE = import_from_settings("LDAP_USER_SEARCH_CACERT_FILE", None) + self.LDAP_CERT_VALIDATE_MODE = import_from_settings("LDAP_USER_SEARCH_CERT_VALIDATE_MODE", None) + self.USERNAME_ONLY_ATTR = import_from_settings("LDAP_USER_SEARCH_USERNAME_ONLY_ATTR", "username") + self.ATTRIBUTE_MAP = import_from_settings( + "LDAP_USER_SEARCH_ATTRIBUTE_MAP", + { + "username": "uid", + "last_name": "sn", + "first_name": "givenName", + "email": "mail", + }, + ) + self.MAPPING_CALLBACK = import_from_settings("LDAP_USER_SEARCH_MAPPING_CALLBACK", self.parse_ldap_entry) tls = None if self.LDAP_USE_TLS: ldap_cert_validate_mode = ssl.CERT_NONE - if self.LDAP_CERT_VALIDATE_MODE == 'optional': + if self.LDAP_CERT_VALIDATE_MODE == "optional": ldap_cert_validate_mode = ssl.CERT_OPTIONAL - elif self.LDAP_CERT_VALIDATE_MODE == 'required': + elif self.LDAP_CERT_VALIDATE_MODE == "required": ldap_cert_validate_mode = ssl.CERT_REQUIRED tls = Tls( @@ -52,7 +60,9 @@ def __init__(self, user_search_string, search_by): validate=ldap_cert_validate_mode, ) - self.server = Server(self.LDAP_SERVER_URI, use_ssl=self.LDAP_USE_SSL, connect_timeout=self.LDAP_CONNECT_TIMEOUT, tls=tls) + self.server = Server( + self.LDAP_SERVER_URI, use_ssl=self.LDAP_USE_SSL, connect_timeout=self.LDAP_CONNECT_TIMEOUT, tls=tls + ) auto_bind = True if self.LDAP_USE_TLS: auto_bind = AUTO_BIND_TLS_BEFORE_BIND @@ -67,42 +77,41 @@ def __init__(self, user_search_string, search_by): def parse_ldap_entry(attribute_map, entry_dict): user_dict = {} for user_attr, ldap_attr in attribute_map.items(): - user_dict[user_attr] = entry_dict.get(ldap_attr)[0] if entry_dict.get(ldap_attr) else '' + user_dict[user_attr] = entry_dict.get(ldap_attr)[0] if entry_dict.get(ldap_attr) else "" return user_dict - def search_a_user(self, user_search_string=None, search_by='all_fields'): + def search_a_user(self, user_search_string=None, search_by="all_fields"): size_limit = 50 ldap_attrs = list(self.ATTRIBUTE_MAP.values()) attrs = get_config_parameter("ATTRIBUTES_EXCLUDED_FROM_CHECK") attrs.extend(ldap_attrs) set_config_parameter("ATTRIBUTES_EXCLUDED_FROM_CHECK", attrs) - if user_search_string and search_by == 'all_fields': + if user_search_string and search_by == "all_fields": filter = ldap.filter.filter_format( f"(|({ldap_attrs[0]}=*%s*)({ldap_attrs[1]}=*%s*)({ldap_attrs[2]}=*%s*)({ldap_attrs[3]}=*%s*))", - [user_search_string] * 4) - elif user_search_string and search_by == 'username_only': - attr = self.USERNAME_ONLY_ATTR - filter = ldap.filter.filter_format( - f"({self.ATTRIBUTE_MAP[attr]}=%s)", [user_search_string] + [user_search_string] * 4, ) + elif user_search_string and search_by == "username_only": + attr = self.USERNAME_ONLY_ATTR + filter = ldap.filter.filter_format(f"({self.ATTRIBUTE_MAP[attr]}=%s)", [user_search_string]) size_limit = 1 elif user_search_string and search_by in self.ATTRIBUTE_MAP.keys(): - filter = ldap.filter.filter_format( - f"({self.ATTRIBUTE_MAP[search_by]}=%s)", [user_search_string] - ) + filter = ldap.filter.filter_format(f"({self.ATTRIBUTE_MAP[search_by]}=%s)", [user_search_string]) size_limit = 1 else: - filter = '(objectclass=person)' + filter = "(objectclass=person)" - searchParameters = {'search_base': self.LDAP_USER_SEARCH_BASE, - 'search_filter': filter, - 'attributes': ldap_attrs, - 'size_limit': size_limit} + searchParameters = { + "search_base": self.LDAP_USER_SEARCH_BASE, + "search_filter": filter, + "attributes": ldap_attrs, + "size_limit": size_limit, + } logger.debug(f"search params: {searchParameters}") self.conn.search(**searchParameters) users = [] for idx, entry in enumerate(self.conn.entries, 1): - entry_dict = json.loads(entry.entry_to_json()).get('attributes') + entry_dict = json.loads(entry.entry_to_json()).get("attributes") logger.debug(f"Entry dict: {entry_dict}") user_dict = self.MAPPING_CALLBACK(self.ATTRIBUTE_MAP, entry_dict) user_dict["source"] = self.search_source diff --git a/coldfront/plugins/ldap_user_search/views.py b/coldfront/plugins/ldap_user_search/views.py index 91ea44a218..2fa8704650 100644 --- a/coldfront/plugins/ldap_user_search/views.py +++ b/coldfront/plugins/ldap_user_search/views.py @@ -1,3 +1,5 @@ -from django.shortcuts import render +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later # Create your views here. diff --git a/coldfront/plugins/mokey_oidc/__init__.py b/coldfront/plugins/mokey_oidc/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/mokey_oidc/__init__.py +++ b/coldfront/plugins/mokey_oidc/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/mokey_oidc/apps.py b/coldfront/plugins/mokey_oidc/apps.py index 47c44765a5..0b8923ff5b 100644 --- a/coldfront/plugins/mokey_oidc/apps.py +++ b/coldfront/plugins/mokey_oidc/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class MokeyOidcConfig(AppConfig): - name = 'coldfront.plugins.mokey_oidc' + name = "coldfront.plugins.mokey_oidc" diff --git a/coldfront/plugins/mokey_oidc/auth.py b/coldfront/plugins/mokey_oidc/auth.py index 354e6e8175..71bb6fdd0a 100644 --- a/coldfront/plugins/mokey_oidc/auth.py +++ b/coldfront/plugins/mokey_oidc/auth.py @@ -1,18 +1,22 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging from django.contrib.auth.models import Group +from mozilla_django_oidc.auth import OIDCAuthenticationBackend from coldfront.core.utils.common import import_from_settings -from mozilla_django_oidc.auth import OIDCAuthenticationBackend logger = logging.getLogger(__name__) -PI_GROUP = import_from_settings('MOKEY_OIDC_PI_GROUP', 'pi') -ALLOWED_GROUPS = import_from_settings('MOKEY_OIDC_ALLOWED_GROUPS', []) -DENY_GROUPS = import_from_settings('MOKEY_OIDC_DENY_GROUPS', []) +PI_GROUP = import_from_settings("MOKEY_OIDC_PI_GROUP", "pi") +ALLOWED_GROUPS = import_from_settings("MOKEY_OIDC_ALLOWED_GROUPS", []) +DENY_GROUPS = import_from_settings("MOKEY_OIDC_DENY_GROUPS", []) -class OIDCMokeyAuthenticationBackend(OIDCAuthenticationBackend): +class OIDCMokeyAuthenticationBackend(OIDCAuthenticationBackend): def _sync_groups(self, user, groups): is_pi = False user.groups.clear() @@ -25,26 +29,30 @@ def _sync_groups(self, user, groups): user.userprofile.is_pi = is_pi def _parse_groups_from_claims(self, claims): - groups = claims.get('groups', []) or [] + groups = claims.get("groups", []) or [] if isinstance(groups, str): - groups = groups.split(';') + groups = groups.split(";") return groups def create_user(self, claims): - email = claims.get('email') - username = claims.get('uid') + email = claims.get("email") + username = claims.get("uid") if not username: logger.error("Failed to create user. username not found in mokey oidc id_token claims: %s", claims) return None if not email: - logger.warn("Creating user with no email. Could not find email for user %s in mokey oidc id_token claims: %s", username, claims) + logger.warn( + "Creating user with no email. Could not find email for user %s in mokey oidc id_token claims: %s", + username, + claims, + ) user = self.UserModel.objects.create_user(username, email) - user.first_name = claims.get('first', '') - user.last_name = claims.get('last', '') + user.first_name = claims.get("first", "") + user.last_name = claims.get("last", "") groups = self._parse_groups_from_claims(claims) self._sync_groups(user, groups) @@ -54,13 +62,18 @@ def create_user(self, claims): return user def update_user(self, user, claims): - user.first_name = claims.get('first', '') - user.last_name = claims.get('last', '') - email = claims.get('email') + user.first_name = claims.get("first", "") + user.last_name = claims.get("last", "") + email = claims.get("email") + username = claims.get("uid") if email and len(email) > 0: user.email = email else: - logger.warn("Failed to update email. Could not find email for user %s in mokey oidc id_token claims: %s", username, claims) + logger.warn( + "Failed to update email. Could not find email for user %s in mokey oidc id_token claims: %s", + username, + claims, + ) groups = self._parse_groups_from_claims(claims) self._sync_groups(user, groups) @@ -70,7 +83,7 @@ def update_user(self, user, claims): return user def filter_users_by_claims(self, claims): - uid = claims.get('uid') + uid = claims.get("uid") if not uid: return self.UserModel.objects.none() @@ -86,7 +99,7 @@ def verify_claims(self, claims): return verified and True groups = self._parse_groups_from_claims(claims) - + if len(ALLOWED_GROUPS) > 0: for g in ALLOWED_GROUPS: if g not in groups: @@ -96,5 +109,5 @@ def verify_claims(self, claims): for g in DENY_GROUPS: if g in groups: return False - + return verified and True diff --git a/coldfront/plugins/mokey_oidc/migrations/__init__.py b/coldfront/plugins/mokey_oidc/migrations/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/mokey_oidc/migrations/__init__.py +++ b/coldfront/plugins/mokey_oidc/migrations/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/slurm/__init__.py b/coldfront/plugins/slurm/__init__.py index ced8ade9bb..e9dcec9ec6 100644 --- a/coldfront/plugins/slurm/__init__.py +++ b/coldfront/plugins/slurm/__init__.py @@ -1 +1,5 @@ -default_app_config = 'coldfront.plugins.slurm.apps.SlurmConfig' +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +default_app_config = "coldfront.plugins.slurm.apps.SlurmConfig" diff --git a/coldfront/plugins/slurm/apps.py b/coldfront/plugins/slurm/apps.py index 778fb94274..b617b7d987 100644 --- a/coldfront/plugins/slurm/apps.py +++ b/coldfront/plugins/slurm/apps.py @@ -1,5 +1,9 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from django.apps import AppConfig class SlurmConfig(AppConfig): - name = 'coldfront.plugins.slurm' + name = "coldfront.plugins.slurm" diff --git a/coldfront/plugins/slurm/associations.py b/coldfront/plugins/slurm/associations.py index afa7cdf4d2..28b74fde92 100644 --- a/coldfront/plugins/slurm/associations.py +++ b/coldfront/plugins/slurm/associations.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import datetime import logging import os @@ -5,14 +9,17 @@ import sys from coldfront.core.resource.models import Resource -from coldfront.plugins.slurm.utils import (SLURM_ACCOUNT_ATTRIBUTE_NAME, - SLURM_CLUSTER_ATTRIBUTE_NAME, - SLURM_SPECS_ATTRIBUTE_NAME, - SLURM_USER_SPECS_ATTRIBUTE_NAME, - SlurmError) +from coldfront.plugins.slurm.utils import ( + SLURM_ACCOUNT_ATTRIBUTE_NAME, + SLURM_CLUSTER_ATTRIBUTE_NAME, + SLURM_SPECS_ATTRIBUTE_NAME, + SLURM_USER_SPECS_ATTRIBUTE_NAME, + SlurmError, +) logger = logging.getLogger(__name__) + class SlurmParserError(SlurmError): pass @@ -29,7 +36,7 @@ def spec_list(self): """Return unique list of Slurm Specs""" items = [] for s in self.specs: - for i in s.split(':'): + for i in s.split(":"): items.append(i) return list(set(items)) @@ -38,10 +45,10 @@ def format_specs(self): """Format unique list of Slurm Specs""" items = [] for s in self.specs: - for i in s.split(':'): + for i in s.split(":"): items.append(i) - return ':'.join([x for x in self.spec_list()]) + return ":".join([x for x in self.spec_list()]) def _write(self, out, data): try: @@ -67,35 +74,31 @@ def new_from_stream(stream): if re.match("^#", line): continue elif re.match("^Cluster - '[^']+'", line): - parts = line.split(':') - name = re.sub(r"^Cluster - ", '', parts[0]).strip("\n'") + parts = line.split(":") + name = re.sub(r"^Cluster - ", "", parts[0]).strip("\n'") if len(name) == 0: - raise(SlurmParserError( - 'Cluster name not found for line: {}'.format(line))) + raise (SlurmParserError("Cluster name not found for line: {}".format(line))) cluster = SlurmCluster(name) cluster.specs += parts[1:] elif re.match("^Account - '[^']+'", line): account = SlurmAccount.new_from_sacctmgr(line) cluster.accounts[account.name] = account elif re.match("^Parent - '[^']+'", line): - parent = re.sub(r"^Parent - ", '', line).strip("\n'") - if parent == 'root': - cluster.accounts['root'] = SlurmAccount('root') + parent = re.sub(r"^Parent - ", "", line).strip("\n'") + if parent == "root": + cluster.accounts["root"] = SlurmAccount("root") if not parent: - raise(SlurmParserError( - 'Parent name not found for line: {}'.format(line))) + raise (SlurmParserError("Parent name not found for line: {}".format(line))) elif re.match("^User - '[^']+'", line): user = SlurmUser.new_from_sacctmgr(line) if not parent: - raise(SlurmParserError( - 'Found user record without Parent for line: {}'.format(line))) + raise (SlurmParserError("Found user record without Parent for line: {}".format(line))) account = cluster.accounts[parent] account.add_user(user) cluster.accounts[parent] = account if not cluster or not cluster.name: - raise(SlurmParserError( - 'Failed to parse Slurm cluster name. Is this in sacctmgr dump file format?')) + raise (SlurmParserError("Failed to parse Slurm cluster name. Is this in sacctmgr dump file format?")) return cluster @@ -106,20 +109,20 @@ def new_from_resource(resource): specs = resource.get_attribute_list(SLURM_SPECS_ATTRIBUTE_NAME) user_specs = resource.get_attribute_list(SLURM_USER_SPECS_ATTRIBUTE_NAME) if not name: - raise(SlurmError('Resource {} missing slurm_cluster'.format(resource))) + raise (SlurmError("Resource {} missing slurm_cluster".format(resource))) cluster = SlurmCluster(name, specs) # Process allocations - for allocation in resource.allocation_set.filter(status__name__in=['Active', 'Renewal Requested']): + for allocation in resource.allocation_set.filter(status__name__in=["Active", "Renewal Requested"]): cluster.add_allocation(allocation, user_specs=user_specs) # Process child resources - children = Resource.objects.filter(parent_resource_id=resource.id, resource_type__name='Cluster Partition') + children = Resource.objects.filter(parent_resource_id=resource.id, resource_type__name="Cluster Partition") for r in children: partition_specs = r.get_attribute_list(SLURM_SPECS_ATTRIBUTE_NAME) partition_user_specs = r.get_attribute_list(SLURM_USER_SPECS_ATTRIBUTE_NAME) - for allocation in r.allocation_set.filter(status__name__in=['Active', 'Renewal Requested']): + for allocation in r.allocation_set.filter(status__name__in=["Active", "Renewal Requested"]): cluster.add_allocation(allocation, specs=partition_specs, user_specs=partition_user_specs) return cluster @@ -131,7 +134,7 @@ def add_allocation(self, allocation, specs=None, user_specs=None): """Add accounts from a ColdFront Allocation model to SlurmCluster""" name = allocation.get_attribute(SLURM_ACCOUNT_ATTRIBUTE_NAME) if not name: - name = 'root' + name = "root" logger.debug("Adding allocation name=%s specs=%s user_specs=%s", name, specs, user_specs) account = self.accounts.get(name, SlurmAccount(name)) @@ -140,21 +143,22 @@ def add_allocation(self, allocation, specs=None, user_specs=None): self.accounts[name] = account def write(self, out): - self._write(out, "# ColdFront Allocation Slurm associations dump {}\n".format( - datetime.datetime.now().date())) - self._write(out, "Cluster - '{}':{}\n".format( - self.name, - self.format_specs(), - )) - if 'root' in self.accounts: - self.accounts['root'].write(out) + self._write(out, "# ColdFront Allocation Slurm associations dump {}\n".format(datetime.datetime.now().date())) + self._write( + out, + "Cluster - '{}':{}\n".format( + self.name, + self.format_specs(), + ), + ) + if "root" in self.accounts: + self.accounts["root"].write(out) else: self._write(out, "Parent - 'root'\n") - self._write( - out, "User - 'root':DefaultAccount='root':AdminLevel='Administrator':Fairshare=1\n") + self._write(out, "User - 'root':DefaultAccount='root':AdminLevel='Administrator':Fairshare=1\n") for name, account in self.accounts.items(): - if account.name == 'root': + if account.name == "root": continue account.write(out) @@ -172,14 +176,12 @@ def new_from_sacctmgr(line): """Create a new SlurmAccount by parsing a line from sacctmgr dump. For example: Account - 'physics':Description='physics group':Organization='cas':Fairshare=100""" if not re.match("^Account - '[^']+'", line): - raise(SlurmParserError( - 'Invalid format. Must start with "Account" for line: {}'.format(line))) + raise (SlurmParserError('Invalid format. Must start with "Account" for line: {}'.format(line))) - parts = line.split(':') - name = re.sub(r"^Account - ", '', parts[0]).strip("\n'") + parts = line.split(":") + name = re.sub(r"^Account - ", "", parts[0]).strip("\n'") if len(name) == 0: - raise(SlurmParserError( - 'Cluster name not found for line: {}'.format(line))) + raise (SlurmParserError("Cluster name not found for line: {}".format(line))) return SlurmAccount(name, specs=parts[1:]) @@ -190,16 +192,15 @@ def add_allocation(self, allocation, user_specs=None): name = allocation.get_attribute(SLURM_ACCOUNT_ATTRIBUTE_NAME) if not name: - name = 'root' + name = "root" if name != self.name: - raise(SlurmError('Allocation {} slurm_account_name does not match {}'.format( - allocation, self.name))) + raise (SlurmError("Allocation {} slurm_account_name does not match {}".format(allocation, self.name))) self.specs += allocation.get_attribute_list(SLURM_SPECS_ATTRIBUTE_NAME) allocation_user_specs = allocation.get_attribute_list(SLURM_USER_SPECS_ATTRIBUTE_NAME) - for u in allocation.allocationuser_set.filter(status__name='Active'): + for u in allocation.allocationuser_set.filter(status__name="Active"): user = SlurmUser(u.user.username) user.specs += allocation_user_specs user.specs += user_specs @@ -214,11 +215,14 @@ def add_user(self, user): self.users[user.name] = rec def write(self, out): - if self.name != 'root': - self._write(out, "Account - '{}':{}\n".format( - self.name, - self.format_specs(), - )) + if self.name != "root": + self._write( + out, + "Account - '{}':{}\n".format( + self.name, + self.format_specs(), + ), + ) def write_users(self, out): self._write(out, "Parent - '{}'\n".format(self.name)) @@ -227,24 +231,25 @@ def write_users(self, out): class SlurmUser(SlurmBase): - @staticmethod def new_from_sacctmgr(line): """Create a new SlurmUser by parsing a line from sacctmgr dump. For example: User - 'jane':DefaultAccount='physics':Fairshare=Parent:QOS='general-compute'""" if not re.match("^User - '[^']+'", line): - raise(SlurmParserError( - 'Invalid format. Must start with "User" for line: {}'.format(line))) + raise (SlurmParserError('Invalid format. Must start with "User" for line: {}'.format(line))) - parts = line.split(':') - name = re.sub(r"^User - ", '', parts[0]).strip("\n'") + parts = line.split(":") + name = re.sub(r"^User - ", "", parts[0]).strip("\n'") if len(name) == 0: - raise(SlurmParserError('User name not found for line: {}'.format(line))) + raise (SlurmParserError("User name not found for line: {}".format(line))) return SlurmUser(name, specs=parts[1:]) def write(self, out): - self._write(out, "User - '{}':{}\n".format( - self.name, - self.format_specs(), - )) + self._write( + out, + "User - '{}':{}\n".format( + self.name, + self.format_specs(), + ), + ) diff --git a/coldfront/plugins/slurm/management/__init__.py b/coldfront/plugins/slurm/management/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/slurm/management/__init__.py +++ b/coldfront/plugins/slurm/management/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/slurm/management/commands/__init__.py b/coldfront/plugins/slurm/management/commands/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/slurm/management/commands/__init__.py +++ b/coldfront/plugins/slurm/management/commands/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/slurm/management/commands/slurm_check.py b/coldfront/plugins/slurm/management/commands/slurm_check.py index ec2b3d6b57..74a33cff40 100644 --- a/coldfront/plugins/slurm/management/commands/slurm_check.py +++ b/coldfront/plugins/slurm/management/commands/slurm_check.py @@ -1,45 +1,47 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging import os import sys import tempfile -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from coldfront.core.resource.models import ResourceAttribute from coldfront.core.utils.common import import_from_settings from coldfront.plugins.slurm.associations import SlurmCluster -from coldfront.plugins.slurm.utils import (SLURM_ACCOUNT_ATTRIBUTE_NAME, - SLURM_CLUSTER_ATTRIBUTE_NAME, - SLURM_USER_SPECS_ATTRIBUTE_NAME, - SlurmError, slurm_remove_qos, - slurm_dump_cluster, slurm_remove_account, - slurm_remove_assoc) - -SLURM_IGNORE_USERS = import_from_settings('SLURM_IGNORE_USERS', []) -SLURM_IGNORE_ACCOUNTS = import_from_settings('SLURM_IGNORE_ACCOUNTS', []) -SLURM_IGNORE_CLUSTERS = import_from_settings('SLURM_IGNORE_CLUSTERS', []) -SLURM_NOOP = import_from_settings('SLURM_NOOP', False) +from coldfront.plugins.slurm.utils import ( + SLURM_CLUSTER_ATTRIBUTE_NAME, + SlurmError, + slurm_dump_cluster, + slurm_remove_account, + slurm_remove_assoc, + slurm_remove_qos, +) + +SLURM_IGNORE_USERS = import_from_settings("SLURM_IGNORE_USERS", []) +SLURM_IGNORE_ACCOUNTS = import_from_settings("SLURM_IGNORE_ACCOUNTS", []) +SLURM_IGNORE_CLUSTERS = import_from_settings("SLURM_IGNORE_CLUSTERS", []) +SLURM_NOOP = import_from_settings("SLURM_NOOP", False) logger = logging.getLogger(__name__) class Command(BaseCommand): - help = 'Check consistency between Slurm associations and ColdFront allocations' + help = "Check consistency between Slurm associations and ColdFront allocations" def add_arguments(self, parser): + parser.add_argument("-i", "--input", help="Path to sacctmgr dump flat file as input. Defaults to stdin") + parser.add_argument("-c", "--cluster", help="Run sacctmgr dump [cluster] as input") parser.add_argument( - "-i", "--input", help="Path to sacctmgr dump flat file as input. Defaults to stdin") - parser.add_argument("-c", "--cluster", - help="Run sacctmgr dump [cluster] as input") - parser.add_argument( - "-s", "--sync", help="Remove associations in Slurm that no longer exist in ColdFront", action="store_true") - parser.add_argument( - "-n", "--noop", help="Print commands only. Do not run any commands.", action="store_true") + "-s", "--sync", help="Remove associations in Slurm that no longer exist in ColdFront", action="store_true" + ) + parser.add_argument("-n", "--noop", help="Print commands only. Do not run any commands.", action="store_true") parser.add_argument("-u", "--username", help="Check specific username") parser.add_argument("-a", "--account", help="Check specific account") - parser.add_argument( - "-x", "--header", help="Include header in output", action="store_true") + parser.add_argument("-x", "--header", help="Include header in output", action="store_true") def write(self, data): try: @@ -88,19 +90,21 @@ def remove_user(self, user, account, cluster): slurm_remove_assoc(user, cluster, account, noop=self.noop) except SlurmError as e: logger.error( - "Failed removing Slurm association user %s account %s cluster %s: %s", user, account, cluster, e) + "Failed removing Slurm association user %s account %s cluster %s: %s", user, account, cluster, e + ) else: logger.error( - "Removed Slurm association user %s account %s cluster %s successfully", user, account, cluster) + "Removed Slurm association user %s account %s cluster %s successfully", user, account, cluster + ) row = [ user, account, cluster, - 'Remove', + "Remove", ] - self.write('\t'.join(row)) + self.write("\t".join(row)) def remove_account(self, account, cluster): if self._skip_account(account): @@ -110,20 +114,18 @@ def remove_account(self, account, cluster): try: slurm_remove_account(cluster, account, noop=self.noop) except SlurmError as e: - logger.error( - "Failed removing Slurm account %s cluster %s: %s", account, cluster, e) + logger.error("Failed removing Slurm account %s cluster %s: %s", account, cluster, e) else: - logger.error( - "Removed Slurm account %s cluster %s successfully", account, cluster) + logger.error("Removed Slurm account %s cluster %s successfully", account, cluster) row = [ - '', + "", account, cluster, - 'Remove', + "Remove", ] - self.write('\t'.join(row)) + self.write("\t".join(row)) def remove_qos(self, user, account, cluster, qos): if self._skip_user(user, account): @@ -135,68 +137,79 @@ def remove_qos(self, user, account, cluster, qos): pass except SlurmError as e: logger.error( - "Failed removing Slurm qos %s for user %s account %s cluster %s: %s", qos, user, account, cluster, e) + "Failed removing Slurm qos %s for user %s account %s cluster %s: %s", qos, user, account, cluster, e + ) else: logger.error( - "Removed Slurm qos %s for user %s account %s cluster %s successfully", qos, user, account, cluster) + "Removed Slurm qos %s for user %s account %s cluster %s successfully", qos, user, account, cluster + ) - row = [ - user, - account, - cluster, - 'Remove', - qos - ] + row = [user, account, cluster, "Remove", qos] - self.write('\t'.join(row)) + self.write("\t".join(row)) def _parse_qos(self, qos): - if qos.startswith('QOS+='): - qos = qos.replace('QOS+=', '') - qos = qos.replace("'", '') - return qos.split(',') - elif qos.startswith('QOS='): - qos = qos.replace('QOS=', '') - qos = qos.replace("'", '') + if qos.startswith("QOS+="): + qos = qos.replace("QOS+=", "") + qos = qos.replace("'", "") + return qos.split(",") + elif qos.startswith("QOS="): + qos = qos.replace("QOS=", "") + qos = qos.replace("'", "") lst = [] - for q in qos.split(','): - if q.startswith('+'): - lst.append(q.replace('+', '')) + for q in qos.split(","): + if q.startswith("+"): + lst.append(q.replace("+", "")) return lst return [] - + def _diff_qos(self, account_name, cluster_name, user_a, user_b): - logger.debug("diff qos: cluster=%s account=%s uid=%s a=%s b=%s", cluster_name, account_name, user_a.name, user_a.spec_list(), user_b.spec_list()) + logger.debug( + "diff qos: cluster=%s account=%s uid=%s a=%s b=%s", + cluster_name, + account_name, + user_a.name, + user_a.spec_list(), + user_b.spec_list(), + ) specs_a = [] for s in user_a.spec_list(): - if s.startswith('QOS'): + if s.startswith("QOS"): specs_a += self._parse_qos(s) specs_b = [] for s in user_b.spec_list(): - if s.startswith('QOS'): + if s.startswith("QOS"): specs_b += self._parse_qos(s) specs_set_a = set(specs_a) specs_set_b = set(specs_b) diff = specs_set_a.difference(specs_set_b) - logger.debug("diff qos: cluster=%s account=%s uid=%s a=%s b=%s diff=%s", cluster_name, account_name, user_a.name, specs_set_a, specs_set_b, diff) - + logger.debug( + "diff qos: cluster=%s account=%s uid=%s a=%s b=%s diff=%s", + cluster_name, + account_name, + user_a.name, + specs_set_a, + specs_set_b, + diff, + ) + if len(diff) > 0: - self.remove_qos(user_a.name, account_name, cluster_name, 'QOS-='+','.join([x for x in list(diff)])) + self.remove_qos(user_a.name, account_name, cluster_name, "QOS-=" + ",".join([x for x in list(diff)])) def _diff(self, cluster_a, cluster_b): for name, account in cluster_a.accounts.items(): - if name == 'root': + if name == "root": continue if name in cluster_b.accounts: total = 0 for uid, user in account.users.items(): - if uid == 'root': + if uid == "root": continue if uid not in cluster_b.accounts[name].users: self.remove_user(uid, name, cluster_a.name) @@ -219,7 +232,7 @@ def check_consistency(self, slurm_cluster, coldfront_cluster): def _cluster_from_dump(self, cluster): slurm_cluster = None with tempfile.TemporaryDirectory() as tmpdir: - fname = os.path.join(tmpdir, 'cluster.cfg') + fname = os.path.join(tmpdir, "cluster.cfg") try: slurm_dump_cluster(cluster, fname) with open(fname) as fh: @@ -230,8 +243,8 @@ def _cluster_from_dump(self, cluster): return slurm_cluster def handle(self, *args, **options): - verbosity = int(options['verbosity']) - root_logger = logging.getLogger('') + verbosity = int(options["verbosity"]) + root_logger = logging.getLogger("") if verbosity == 0: root_logger.setLevel(logging.ERROR) elif verbosity == 2: @@ -242,19 +255,19 @@ def handle(self, *args, **options): root_logger.setLevel(logging.WARN) self.sync = False - if options['sync']: + if options["sync"]: self.sync = True logger.warn("Syncing Slurm with ColdFront") self.noop = SLURM_NOOP - if options['noop']: + if options["noop"]: self.noop = True logger.warn("NOOP enabled") - if options['cluster']: - slurm_cluster = self._cluster_from_dump(options['cluster']) - elif options['input']: - with open(options['input']) as fh: + if options["cluster"]: + slurm_cluster = self._cluster_from_dump(options["cluster"]) + elif options["input"]: + with open(options["input"]) as fh: slurm_cluster = SlurmCluster.new_from_stream(fh) else: slurm_cluster = SlurmCluster.new_from_stream(sys.stdin) @@ -264,31 +277,34 @@ def handle(self, *args, **options): sys.exit(1) if slurm_cluster.name in SLURM_IGNORE_CLUSTERS: - logger.warn("Ignoring cluster %s. Nothing to do.", - slurm_cluster.name) + logger.warn("Ignoring cluster %s. Nothing to do.", slurm_cluster.name) sys.exit(0) try: resource = ResourceAttribute.objects.get( - resource_attribute_type__name=SLURM_CLUSTER_ATTRIBUTE_NAME, value=slurm_cluster.name).resource + resource_attribute_type__name=SLURM_CLUSTER_ATTRIBUTE_NAME, value=slurm_cluster.name + ).resource except ResourceAttribute.DoesNotExist: - logger.error("No Slurm '%s' cluster resource found in ColdFront using '%s' attribute", - slurm_cluster.name, SLURM_CLUSTER_ATTRIBUTE_NAME) + logger.error( + "No Slurm '%s' cluster resource found in ColdFront using '%s' attribute", + slurm_cluster.name, + SLURM_CLUSTER_ATTRIBUTE_NAME, + ) sys.exit(1) header = [ - 'username', - 'account', - 'cluster', - 'slurm_action', - 'slurm_specs', + "username", + "account", + "cluster", + "slurm_action", + "slurm_specs", ] - if options['header']: - self.write('\t'.join(header)) + if options["header"]: + self.write("\t".join(header)) - self.filter_user = options['username'] - self.filter_account = options['account'] + self.filter_user = options["username"] + self.filter_account = options["account"] coldfront_cluster = SlurmCluster.new_from_resource(resource) diff --git a/coldfront/plugins/slurm/management/commands/slurm_dump.py b/coldfront/plugins/slurm/management/commands/slurm_dump.py index 93b6a73349..0557a5c0c0 100644 --- a/coldfront/plugins/slurm/management/commands/slurm_dump.py +++ b/coldfront/plugins/slurm/management/commands/slurm_dump.py @@ -1,24 +1,29 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import logging import os -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from coldfront.core.resource.models import ResourceAttribute -from coldfront.plugins.slurm.utils import SLURM_CLUSTER_ATTRIBUTE_NAME from coldfront.plugins.slurm.associations import SlurmCluster +from coldfront.plugins.slurm.utils import SLURM_CLUSTER_ATTRIBUTE_NAME logger = logging.getLogger(__name__) + class Command(BaseCommand): - help = 'Dump slurm associations for sacctmgr in flat file format' + help = "Dump slurm associations for sacctmgr in flat file format" def add_arguments(self, parser): parser.add_argument("-o", "--output", help="Path to output directory") parser.add_argument("-c", "--cluster", help="Only output specific Slurm cluster") def handle(self, *args, **options): - verbosity = int(options['verbosity']) - root_logger = logging.getLogger('') + verbosity = int(options["verbosity"]) + root_logger = logging.getLogger("") if verbosity == 0: root_logger.setLevel(logging.ERROR) elif verbosity == 2: @@ -29,15 +34,15 @@ def handle(self, *args, **options): root_logger.setLevel(logging.WARN) out_dir = None - if options['output']: - out_dir = options['output'] + if options["output"]: + out_dir = options["output"] if not os.path.isdir(out_dir): os.mkdir(out_dir, 0o0700) logger.warn("Writing output to directory: %s", out_dir) for attr in ResourceAttribute.objects.filter(resource_attribute_type__name=SLURM_CLUSTER_ATTRIBUTE_NAME): - if options['cluster'] and options['cluster'] != attr.value: + if options["cluster"] and options["cluster"] != attr.value: continue if not attr.resource.is_available: @@ -48,5 +53,5 @@ def handle(self, *args, **options): cluster.write(self.stdout) continue - with open(os.path.join(out_dir, '{}.cfg'.format(cluster.name)), 'w') as fh: + with open(os.path.join(out_dir, "{}.cfg".format(cluster.name)), "w") as fh: cluster.write(fh) diff --git a/coldfront/plugins/slurm/tests/test_associations.py b/coldfront/plugins/slurm/tests/test_associations.py index a3fc4f1a41..7d97ea55ef 100644 --- a/coldfront/plugins/slurm/tests/test_associations.py +++ b/coldfront/plugins/slurm/tests/test_associations.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + from io import StringIO from django.core.management import call_command @@ -5,30 +9,29 @@ from coldfront.core.resource.models import Resource from coldfront.plugins.slurm.associations import SlurmCluster -from coldfront.plugins.slurm.utils import SLURM_CLUSTER_ATTRIBUTE_NAME class AssociationTest(TestCase): - fixtures = ['test_data.json'] + fixtures = ["test_data.json"] @classmethod def setUpClass(cls): - call_command('import_field_of_science_data') - call_command('add_default_grant_options') - call_command('add_default_project_choices') - call_command('add_default_allocation_choices') - call_command('add_default_publication_sources') + call_command("import_field_of_science_data") + call_command("add_default_grant_options") + call_command("add_default_project_choices") + call_command("add_default_allocation_choices") + call_command("add_default_publication_sources") super(AssociationTest, cls).setUpClass() def test_allocations_to_slurm(self): - resource = Resource.objects.get(name='University HPC') + resource = Resource.objects.get(name="University HPC") cluster = SlurmCluster.new_from_resource(resource) - self.assertEqual(cluster.name, 'university-hpc') + self.assertEqual(cluster.name, "university-hpc") self.assertEqual(len(cluster.accounts), 1) - self.assertIn('ccollins', cluster.accounts) - self.assertEqual(len(cluster.accounts['ccollins'].users), 3) - for u in ['ccollins', 'radams', 'mlopez']: - self.assertIn(u, cluster.accounts['ccollins'].users) + self.assertIn("ccollins", cluster.accounts) + self.assertEqual(len(cluster.accounts["ccollins"].users), 3) + for u in ["ccollins", "radams", "mlopez"]: + self.assertIn(u, cluster.accounts["ccollins"].users) def test_parse_sacctmgr_roundtrip(self): dump = StringIO(""" @@ -56,12 +59,12 @@ def test_parse_sacctmgr_roundtrip(self): # Parse sacctmgr dump format cluster = SlurmCluster.new_from_stream(dump) - self.assertEqual(cluster.name, 'alpha') + self.assertEqual(cluster.name, "alpha") self.assertEqual(len(cluster.accounts), 2) - self.assertIn('physics', cluster.accounts) - self.assertEqual(len(cluster.accounts['physics'].users), 3) - for u in ['jane', 'john', 'larry']: - self.assertIn(u, cluster.accounts['physics'].users) + self.assertIn("physics", cluster.accounts) + self.assertEqual(len(cluster.accounts["physics"].users), 3) + for u in ["jane", "john", "larry"]: + self.assertIn(u, cluster.accounts["physics"].users) # Write sacctmgr dump format out = StringIO("") @@ -69,9 +72,9 @@ def test_parse_sacctmgr_roundtrip(self): # Roundtrip cluster2 = SlurmCluster.new_from_stream(StringIO(out.getvalue())) - self.assertEqual(cluster2.name, 'alpha') + self.assertEqual(cluster2.name, "alpha") self.assertEqual(len(cluster2.accounts), 2) - self.assertIn('physics', cluster2.accounts) - self.assertEqual(len(cluster2.accounts['physics'].users), 3) - for u in ['jane', 'john', 'larry']: - self.assertIn(u, cluster2.accounts['physics'].users) + self.assertIn("physics", cluster2.accounts) + self.assertEqual(len(cluster2.accounts["physics"].users), 3) + for u in ["jane", "john", "larry"]: + self.assertIn(u, cluster2.accounts["physics"].users) diff --git a/coldfront/plugins/slurm/utils.py b/coldfront/plugins/slurm/utils.py index 242e5801e9..107c1ebe58 100644 --- a/coldfront/plugins/slurm/utils.py +++ b/coldfront/plugins/slurm/utils.py @@ -1,61 +1,72 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import csv import logging import shlex import subprocess -import csv from io import StringIO from coldfront.core.utils.common import import_from_settings -SLURM_CLUSTER_ATTRIBUTE_NAME = import_from_settings('SLURM_CLUSTER_ATTRIBUTE_NAME', 'slurm_cluster') -SLURM_ACCOUNT_ATTRIBUTE_NAME = import_from_settings('SLURM_ACCOUNT_ATTRIBUTE_NAME', 'slurm_account_name') -SLURM_SPECS_ATTRIBUTE_NAME = import_from_settings('SLURM_SPECS_ATTRIBUTE_NAME', 'slurm_specs') -SLURM_USER_SPECS_ATTRIBUTE_NAME = import_from_settings('SLURM_USER_SPECS_ATTRIBUTE_NAME', 'slurm_user_specs') -SLURM_SACCTMGR_PATH = import_from_settings('SLURM_SACCTMGR_PATH', '/usr/bin/sacctmgr') -SLURM_CMD_REMOVE_USER = SLURM_SACCTMGR_PATH + ' -Q -i delete user where name={} cluster={} account={}' -SLURM_CMD_REMOVE_QOS = SLURM_SACCTMGR_PATH + ' -Q -i modify user where name={} cluster={} account={} set {}' -SLURM_CMD_REMOVE_ACCOUNT = SLURM_SACCTMGR_PATH + ' -Q -i delete account where name={} cluster={}' -SLURM_CMD_ADD_ACCOUNT = SLURM_SACCTMGR_PATH + ' -Q -i create account name={} cluster={}' -SLURM_CMD_ADD_USER = SLURM_SACCTMGR_PATH + ' -Q -i create user name={} cluster={} account={}' -SLURM_CMD_CHECK_ASSOCIATION = SLURM_SACCTMGR_PATH + ' list associations User={} Cluster={} Account={} Format=Cluster,Account,User,QOS -P' -SLURM_CMD_LIST_ACCOUNTS = SLURM_SACCTMGR_PATH + ' list associations User={} Cluster={} Format=Account -Pn' -SLURM_CMD_CHECK_DEFAULT_ACCOUNT = SLURM_SACCTMGR_PATH + ' show user User={} Cluster={} Format=DefaultAccount -Pn' -SLURM_CMD_CHANGE_DEFAULT_ACCOUNT = SLURM_SACCTMGR_PATH + ' -Q -i modify user User={} where Cluster={} set DefaultAccount={}' -SLURM_CMD_BLOCK_ACCOUNT = SLURM_SACCTMGR_PATH + ' -Q -i modify account {} where Cluster={} set GrpSubmitJobs=0' -SLURM_CMD_DUMP_CLUSTER = SLURM_SACCTMGR_PATH + ' dump {} file={}' +SLURM_CLUSTER_ATTRIBUTE_NAME = import_from_settings("SLURM_CLUSTER_ATTRIBUTE_NAME", "slurm_cluster") +SLURM_ACCOUNT_ATTRIBUTE_NAME = import_from_settings("SLURM_ACCOUNT_ATTRIBUTE_NAME", "slurm_account_name") +SLURM_SPECS_ATTRIBUTE_NAME = import_from_settings("SLURM_SPECS_ATTRIBUTE_NAME", "slurm_specs") +SLURM_USER_SPECS_ATTRIBUTE_NAME = import_from_settings("SLURM_USER_SPECS_ATTRIBUTE_NAME", "slurm_user_specs") +SLURM_SACCTMGR_PATH = import_from_settings("SLURM_SACCTMGR_PATH", "/usr/bin/sacctmgr") +SLURM_CMD_REMOVE_USER = SLURM_SACCTMGR_PATH + " -Q -i delete user where name={} cluster={} account={}" +SLURM_CMD_REMOVE_QOS = SLURM_SACCTMGR_PATH + " -Q -i modify user where name={} cluster={} account={} set {}" +SLURM_CMD_REMOVE_ACCOUNT = SLURM_SACCTMGR_PATH + " -Q -i delete account where name={} cluster={}" +SLURM_CMD_ADD_ACCOUNT = SLURM_SACCTMGR_PATH + " -Q -i create account name={} cluster={}" +SLURM_CMD_ADD_USER = SLURM_SACCTMGR_PATH + " -Q -i create user name={} cluster={} account={}" +SLURM_CMD_CHECK_ASSOCIATION = ( + SLURM_SACCTMGR_PATH + " list associations User={} Cluster={} Account={} Format=Cluster,Account,User,QOS -P" +) +SLURM_CMD_LIST_ACCOUNTS = SLURM_SACCTMGR_PATH + " list associations User={} Cluster={} Format=Account -Pn" +SLURM_CMD_CHECK_DEFAULT_ACCOUNT = SLURM_SACCTMGR_PATH + " show user User={} Cluster={} Format=DefaultAccount -Pn" +SLURM_CMD_CHANGE_DEFAULT_ACCOUNT = ( + SLURM_SACCTMGR_PATH + " -Q -i modify user User={} where Cluster={} set DefaultAccount={}" +) +SLURM_CMD_BLOCK_ACCOUNT = SLURM_SACCTMGR_PATH + " -Q -i modify account {} where Cluster={} set GrpSubmitJobs=0" +SLURM_CMD_DUMP_CLUSTER = SLURM_SACCTMGR_PATH + " dump {} file={}" logger = logging.getLogger(__name__) + class SlurmError(Exception): pass + def _run_slurm_cmd(cmd, noop=True): if noop: - logger.warn('NOOP - Slurm cmd: %s', cmd) + logger.warn("NOOP - Slurm cmd: %s", cmd) return try: result = subprocess.run(shlex.split(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) except subprocess.CalledProcessError as e: - if 'Nothing deleted' in str(e.stdout): + if "Nothing deleted" in str(e.stdout): # We tried to delete something that didn't exist. Don't throw error - logger.warn('Nothing to delete: %s', cmd) + logger.warn("Nothing to delete: %s", cmd) return e.stdout - if 'Nothing new added' in str(e.stdout): + if "Nothing new added" in str(e.stdout): # We tried to add something that already exists. Don't throw error - logger.warn('Nothing new to add: %s', cmd) + logger.warn("Nothing new to add: %s", cmd) return e.stdout - logger.error('Slurm command failed: %s', cmd) - err_msg = 'return_value={} stdout={} stderr={}'.format(e.returncode, e.stdout, e.stderr) + logger.error("Slurm command failed: %s", cmd) + err_msg = "return_value={} stdout={} stderr={}".format(e.returncode, e.stdout, e.stderr) raise SlurmError(err_msg) - logger.debug('Slurm cmd: %s', cmd) - logger.debug('Slurm cmd output: %s', result.stdout) + logger.debug("Slurm cmd: %s", cmd) + logger.debug("Slurm cmd output: %s", result.stdout) return result.stdout + def slurm_remove_assoc(user, cluster, account, noop=False): - #check default account + # check default account cmd = SLURM_CMD_CHECK_DEFAULT_ACCOUNT.format(shlex.quote(user), shlex.quote(cluster)) output = _run_slurm_cmd(cmd, noop=noop) default = "" @@ -66,7 +77,7 @@ def slurm_remove_assoc(user, cluster, account, noop=False): _remove_assoc(user=user, cluster=cluster, account=account, noop=noop) return - #get accounts + # get accounts cmd = SLURM_CMD_LIST_ACCOUNTS.format(shlex.quote(user), shlex.quote(cluster)) output = _run_slurm_cmd(cmd, noop=noop) accounts = [] @@ -77,57 +88,66 @@ def slurm_remove_assoc(user, cluster, account, noop=False): for userAccount in accounts: if userAccount != account: - cmd = SLURM_CMD_CHANGE_DEFAULT_ACCOUNT.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(userAccount)) + cmd = SLURM_CMD_CHANGE_DEFAULT_ACCOUNT.format( + shlex.quote(user), shlex.quote(cluster), shlex.quote(userAccount) + ) _run_slurm_cmd(cmd, noop=noop) break - + _remove_assoc(user=user, cluster=cluster, account=account, noop=noop) - - + + def _remove_assoc(user, cluster, account, noop=False): cmd = SLURM_CMD_REMOVE_USER.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(account)) _run_slurm_cmd(cmd, noop=noop) + def slurm_remove_qos(user, cluster, account, qos, noop=False): cmd = SLURM_CMD_REMOVE_QOS.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(account), shlex.quote(qos)) _run_slurm_cmd(cmd, noop=noop) + def slurm_remove_account(cluster, account, noop=False): cmd = SLURM_CMD_REMOVE_ACCOUNT.format(shlex.quote(account), shlex.quote(cluster)) _run_slurm_cmd(cmd, noop=noop) + def slurm_add_assoc(user, cluster, account, specs=None, noop=False): if specs is None: specs = [] cmd = SLURM_CMD_ADD_USER.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(account)) if len(specs) > 0: - cmd += ' ' + ' '.join(specs) + cmd += " " + " ".join(specs) _run_slurm_cmd(cmd, noop=noop) + def slurm_add_account(cluster, account, specs=None, noop=False): if specs is None: specs = [] cmd = SLURM_CMD_ADD_ACCOUNT.format(shlex.quote(account), shlex.quote(cluster)) if len(specs) > 0: - cmd += ' ' + ' '.join(specs) + cmd += " " + " ".join(specs) _run_slurm_cmd(cmd, noop=noop) + def slurm_block_account(cluster, account, noop=False): cmd = SLURM_CMD_BLOCK_ACCOUNT.format(shlex.quote(account), shlex.quote(cluster)) _run_slurm_cmd(cmd, noop=noop) + def slurm_check_assoc(user, cluster, account): cmd = SLURM_CMD_CHECK_ASSOCIATION.format(shlex.quote(user), shlex.quote(cluster), shlex.quote(account)) - output = _run_slurm_cmd(cmd, noop=False) + output = _run_slurm_cmd(cmd, noop=False) with StringIO(output.decode("UTF-8")) as fh: - reader = csv.DictReader(fh, delimiter='|') + reader = csv.DictReader(fh, delimiter="|") for row in reader: - if row['User'] == user and row['Account'] == account and row['Cluster'] == cluster: + if row["User"] == user and row["Account"] == account and row["Cluster"] == cluster: return True return False + def slurm_dump_cluster(cluster, fname, noop=False): cmd = SLURM_CMD_DUMP_CLUSTER.format(shlex.quote(cluster), shlex.quote(fname)) _run_slurm_cmd(cmd, noop=noop) diff --git a/coldfront/plugins/system_monitor/__init__.py b/coldfront/plugins/system_monitor/__init__.py index e69de29bb2..2f61f96d86 100644 --- a/coldfront/plugins/system_monitor/__init__.py +++ b/coldfront/plugins/system_monitor/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/coldfront/plugins/system_monitor/utils.py b/coldfront/plugins/system_monitor/utils.py index 75b7d5dc9e..98ec886c19 100644 --- a/coldfront/plugins/system_monitor/utils.py +++ b/coldfront/plugins/system_monitor/utils.py @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + import re import requests @@ -12,26 +16,29 @@ def get_system_monitor_context(): system_monitor_data = system_monitor.get_data() system_monitor_panel_title = system_monitor.get_panel_title() - context['last_updated'] = system_monitor_data.get('last_updated') - context['utilization_data'] = system_monitor_data.get('utilization_data') - context['jobs_data'] = system_monitor_data.get('jobs_data') - context['system_monitor_panel_title'] = system_monitor_panel_title - context['SYSTEM_MONITOR_DISPLAY_XDMOD_LINK'] = import_from_settings('SYSTEM_MONITOR_DISPLAY_XDMOD_LINK', None) - context['SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK'] = import_from_settings('SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK', None) + context["last_updated"] = system_monitor_data.get("last_updated") + context["utilization_data"] = system_monitor_data.get("utilization_data") + context["jobs_data"] = system_monitor_data.get("jobs_data") + context["system_monitor_panel_title"] = system_monitor_panel_title + context["SYSTEM_MONITOR_DISPLAY_XDMOD_LINK"] = import_from_settings("SYSTEM_MONITOR_DISPLAY_XDMOD_LINK", None) + context["SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK"] = import_from_settings( + "SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK", None + ) return context class SystemMonitor: """If anything fails, the home page will still work""" - RESPONSE_PARSER_FUNCTION = 'parse_html_using_beautiful_soup' - primary_color = '#002f56' - info_color = '#2f9fd0' - secondary_color = '#666666' + + RESPONSE_PARSER_FUNCTION = "parse_html_using_beautiful_soup" + primary_color = "#002f56" + info_color = "#2f9fd0" + secondary_color = "#666666" def __init__(self): - self.SYSTEM_MONITOR_ENDPOINT = import_from_settings('SYSTEM_MONITOR_ENDPOINT') - self.SYSTEM_MONITOR_PANEL_TITLE = import_from_settings('SYSTEM_MONITOR_PANEL_TITLE') + self.SYSTEM_MONITOR_ENDPOINT = import_from_settings("SYSTEM_MONITOR_ENDPOINT") + self.SYSTEM_MONITOR_PANEL_TITLE = import_from_settings("SYSTEM_MONITOR_PANEL_TITLE") self.response = None self.data = {} self.parse_function = getattr(self, self.RESPONSE_PARSER_FUNCTION) @@ -40,7 +47,7 @@ def __init__(self): def fetch_data(self): try: r = requests.get(self.SYSTEM_MONITOR_ENDPOINT, timeout=5) - except Exception as e: + except Exception: r = None if r and r.status_code == 200: @@ -48,9 +55,9 @@ def fetch_data(self): def parse_html_using_beautiful_soup(self): try: - soup = BeautifulSoup(self.response.text, 'html.parser') - except Exception as e: - print('Error in parsing HTML response') + soup = BeautifulSoup(self.response.text, "html.parser") + except Exception: + print("Error in parsing HTML response") return pattern = re.compile(r"Last updated: (?P

diff --git a/coldfront/core/project/templates/project/project_list.html b/coldfront/core/project/templates/project/project_list.html index 808c680e29..2045548a18 100644 --- a/coldfront/core/project/templates/project/project_list.html +++ b/coldfront/core/project/templates/project/project_list.html @@ -71,6 +71,13 @@

Projects

Sort Status asc Sort Status desc + {% if PROJECT_INSTITUTION_EMAIL_MAP %} + + Institution + Sort Institution asc + Sort Institution desc + + {% endif %} @@ -85,6 +92,9 @@

Projects

{{ project.title }} {{ project.field_of_science.description }} {{ project.status.name }} + {% if PROJECT_INSTITUTION_EMAIL_MAP %} + {{ project.institution }} + {% endif %} {% endfor %} diff --git a/coldfront/core/project/tests.py b/coldfront/core/project/tests.py index f648bd8e07..39a9676833 100644 --- a/coldfront/core/project/tests.py +++ b/coldfront/core/project/tests.py @@ -13,7 +13,10 @@ ProjectAttribute, ProjectAttributeType, ) -from coldfront.core.project.utils import generate_project_code +from coldfront.core.project.utils import ( + determine_automated_institution_choice, + generate_project_code, +) from coldfront.core.test_helpers.factories import ( FieldOfScienceFactory, PAttributeTypeFactory, @@ -276,3 +279,103 @@ def test_different_prefix_padding(self): # Test the generated project codes self.assertEqual(project_with_code_padding1, "BFO001") self.assertEqual(project_with_code_padding2, "BFO002") + + +class TestInstitution(TestCase): + def setUp(self): + self.user = UserFactory(username="capeo") + self.field_of_science = FieldOfScienceFactory(description="Physics") + self.status = ProjectStatusChoiceFactory(name="Active") + + def create_project_with_institution(self, title, institution_dict=None): + """Helper method to create a project and assign a institution value based on the argument passed""" + # Project Creation + project = Project.objects.create( + title=title, + pi=self.user, + status=self.status, + field_of_science=self.field_of_science, + ) + + if institution_dict: + determine_automated_institution_choice(project, institution_dict) + + project.save() + + return project.institution + + @patch( + "coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP", + {"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"}, + ) + def test_institution_is_none(self): + from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP + + """Test to check if institution is none after both env vars are enabled. """ + + # Create project with both institution + project_institution = self.create_project_with_institution("Project 1", PROJECT_INSTITUTION_EMAIL_MAP) + + # Create the first project + self.assertEqual(project_institution, "None") + + @patch( + "coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP", + {"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"}, + ) + def test_institution_multiple_users(self): + from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP + + """Test to check multiple projects with different user email addresses, """ + + # Create project for user 1 + self.user.email = "user@inst.ac.com" + self.user.save() + project_institution_one = self.create_project_with_institution("Project 1", PROJECT_INSTITUTION_EMAIL_MAP) + self.assertEqual(project_institution_one, "AC") + + # Create project for user 2 + self.user.email = "user@bfo.ac.uk" + self.user.save() + project_institution_two = self.create_project_with_institution("Project 2", PROJECT_INSTITUTION_EMAIL_MAP) + self.assertEqual(project_institution_two, "BFO") + + # Create project for user 3 + self.user.email = "user@inst.edu.com" + self.user.save() + project_institution_three = self.create_project_with_institution("Project 3", PROJECT_INSTITUTION_EMAIL_MAP) + self.assertEqual(project_institution_three, "EDU") + + @patch( + "coldfront.config.core.PROJECT_INSTITUTION_EMAIL_MAP", + {"inst.ac.com": "AC", "inst.edu.com": "EDU", "bfo.ac.uk": "BFO"}, + ) + def test_determine_automated_institution_choice_does_not_save_to_database(self): + from coldfront.config.core import PROJECT_INSTITUTION_EMAIL_MAP + + """Test that the function only modifies project in memory, not in database""" + + self.user.email = "user@inst.ac.com" + self.user.save() + + # Create project, similar to create_project_with_institution, but without the save function. + project = Project.objects.create( + title="Test Project", + pi=self.user, + status=self.status, + field_of_science=self.field_of_science, + institution="Default", + ) + + original_db_project = Project.objects.get(id=project.id) + self.assertEqual(original_db_project.institution, "Default") + + # Call the function and check object was modified in memory. + determine_automated_institution_choice(project, PROJECT_INSTITUTION_EMAIL_MAP) + self.assertEqual(project.institution, "AC") + + # Check that database was NOT modified + current_db_project = Project.objects.get(id=project.id) + self.assertEqual(original_db_project.institution, "Default") + + self.assertNotEqual(project.institution, current_db_project.institution) diff --git a/coldfront/core/project/utils.py b/coldfront/core/project/utils.py index 5c4ce847a5..137ba4ab26 100644 --- a/coldfront/core/project/utils.py +++ b/coldfront/core/project/utils.py @@ -48,3 +48,35 @@ def generate_project_code(project_code: str, project_pk: int, padding: int = 0) """ return f"{project_code.upper()}{str(project_pk).zfill(padding)}" + + +def determine_automated_institution_choice(project, institution_map: dict): + """ + Determine automated institution choice for a project. Taking PI email of current project + and comparing to domain key from institution_map. Will first try to match a domain exactly + as provided in institution_map, if a direct match cannot be found an indirect match will be + attempted by looking for the first occurrence of an institution domain that occurs as a substring + in the PI's email address. This does not save changes to the database. The project object in + memory will have the institution field modified. + :param project: Project to add automated institution choice to. + :param institution_map: Dictionary of institution keys, values. + """ + email: str = project.pi.email + + try: + _, pi_email_domain = email.split("@") + except ValueError: + pi_email_domain = None + + direct_institution_match = institution_map.get(pi_email_domain) + + if direct_institution_match: + project.institution = direct_institution_match + return direct_institution_match + else: + for institution_email_domain, indirect_institution_match in institution_map.items(): + if institution_email_domain in pi_email_domain: + project.institution = indirect_institution_match + return indirect_institution_match + + return project.institution diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 9fac3ab514..df2adc82f3 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -64,7 +64,7 @@ project_remove_user, project_update, ) -from coldfront.core.project.utils import generate_project_code +from coldfront.core.project.utils import determine_automated_institution_choice, generate_project_code from coldfront.core.publication.models import Publication from coldfront.core.research_output.models import ResearchOutput from coldfront.core.user.forms import UserSearchForm @@ -84,6 +84,7 @@ PROJECT_CODE_PADDING = import_from_settings("PROJECT_CODE_PADDING", False) logger = logging.getLogger(__name__) +PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False) class ProjectDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView): @@ -215,6 +216,7 @@ def get_context_data(self, **kwargs): context["attributes_with_usage"] = attributes_with_usage context["project_users"] = project_users context["ALLOCATION_ENABLE_ALLOCATION_RENEWAL"] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL + context["PROJECT_INSTITUTION_EMAIL_MAP"] = PROJECT_INSTITUTION_EMAIL_MAP try: context["ondemand_url"] = settings.ONDEMAND_URL @@ -358,6 +360,7 @@ def get_context_data(self, **kwargs): context["filter_parameters"] = filter_parameters context["filter_parameters_with_order_by"] = filter_parameters_with_order_by + context["PROJECT_INSTITUTION_EMAIL_MAP"] = PROJECT_INSTITUTION_EMAIL_MAP project_list = context.get("project_list") paginator = Paginator(project_list, self.paginate_by) @@ -597,6 +600,9 @@ def form_valid(self, form): project_obj.project_code = generate_project_code(PROJECT_CODE, project_obj.pk, PROJECT_CODE_PADDING or 0) project_obj.save(update_fields=["project_code"]) + if PROJECT_INSTITUTION_EMAIL_MAP: + determine_automated_institution_choice(project_obj, PROJECT_INSTITUTION_EMAIL_MAP) + # project signals project_new.send(sender=self.__class__, project_obj=project_obj) diff --git a/docs/pages/config.md b/docs/pages/config.md index 873df3d3b9..9649dbe577 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -103,7 +103,7 @@ The following settings are ColdFront specific settings related to the core appli | PUBLICATION_ENABLE | Enable or disable publications. Default True | | PROJECT_CODE | Specifies a custom internal project identifier. Default False, provide string value to enable.| | PROJECT_CODE_PADDING | Defines a optional padding value to be added before the Primary Key section of PROJECT_CODE. Default False, provide integer value to enable.| - +| PROJECT_INSTITUTION_EMAIL_MAP | Defines a dictionary where PI domain email addresses are keys and their corresponding institutions are values. Default is False, provide key-value pairs to enable this feature.| ### Database settings The following settings configure the database server to use, if not set will default to using SQLite: From 12f4af7f7f77db2e6dca9762dac1e1240bcd1e99 Mon Sep 17 00:00:00 2001 From: David Simpson <> Date: Thu, 3 Jul 2025 07:54:22 +0100 Subject: [PATCH 030/110] Update README.md minor improvements to auto_compute_allocation README Signed-off-by: David Simpson --- .../plugins/auto_compute_allocation/README.md | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/coldfront/plugins/auto_compute_allocation/README.md b/coldfront/plugins/auto_compute_allocation/README.md index 29eecf3834..72a2c4bd05 100644 --- a/coldfront/plugins/auto_compute_allocation/README.md +++ b/coldfront/plugins/auto_compute_allocation/README.md @@ -1,12 +1,12 @@ # auto\_compute\_allocation - A plugin to create an automatically assigned compute allocation -Coldfront django plugin providing capability to create an automatically assigned compute allocation (a coldfront project resource allocation mapping to an HPC Cluster resource or multiple such resources). +Coldfront django plugin providing capability to create an automatically assigned compute allocation (a Coldfront project resource allocation mapping to an HPC Cluster resource or multiple such resources). -The motivation for using this plugin is to use Coldfront as the source of truth. This might be in contrast to another operating modality where information is [generally] imported from another system into Coldfront to provide allocations. +The motivation for using this plugin is to use Coldfront as the source of truth. This might be in contrast to another operating modality where information is [generally] imported from another system into Coldfront to provide allocations. - By using the plugin an allocation to use configured HPC Cluster(s) will be created each time a new project is created, which Coldfront operators simply need to check over and then approve/activate... -This has the benefit of reducing Workload: +This has the benefit of reducing workload: - Coldfront operators workload is reduced slightly, whilst also providing consistency and accuracy - operators are required to input and do less. - Another reason might be to reduce PI workload. As on a free-at-the-point of use system, its likely that all projects simply get granted a compute allocation and therefore a slurm association to be able to use the HPC Cluster(s). The PI will automatically have the allocation created by Coldfront itself. @@ -71,22 +71,29 @@ PROJECT_CODE_PADDING=4 ``` +**Required Plugin load:** + +| Option | Type | Default | Description | +|--- | --- | --- | --- | +| `PLUGIN_AUTO_COMPUTE_ALLOCATION` | Bool | False, not defined | Enable the plugin, required to be set as True (bool). | + + Next the environment variables for the plugin itself, here are the descriptions and defaults. ### Auto_Compute_Allocation Plugin optional variables All variables for this plugin are currently **optional**. -| Option | Default | Description | -|--- | --- | --- | -| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS` | `int`, 0 | Optional, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added | -| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING` | `int`, 0 | Optional, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added. This applies to projects which select 'Training' as their field of science discipline. | -| `AUTO_COMPUTE_ALLOCATION_END_DELTA` | `int`, 365 | Optional, number of days from creation of the allocation to expiry, default 365 to align with default project duration of 1 year | -| `AUTO_COMPUTE_ALLOCATION_CHANGEABLE` | `bool`, True | Optional, allows the allocation to have a request logged to change - this might be useful for an extension | -| `AUTO_COMPUTE_ALLOCATION_LOCKED` | `bool`, False | Optional, prevents the allocation from being modified by admin - this might be useful for an extensions | -| `AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION` | `bool`, False | Optional, provides an institution based slurm fairshare attribute, requires that the _institution feature_ is setup correctly | -| `AUTO_COMPUTE_ALLOCATION_CLUSTERS` | `tuple`, empty () | Optional, filter for clusters to automatically allocate on - example value ``AUTO_COMPUTE_ALLOCATION_CLUSTERS=(Cluster1,Cluster4)`` | -| ``AUTO_COMPUTE_ALLOCATION_DESCRIPTION`` | `str`, "auto\|Cluster\|" | Optionally control the produced description for the allocation and its delimiters within. The _project_code_ will always be appended. Example resultant description: ``auto\|Cluster\|CDF0001`` | +| Option | Type | Default | Description | +|--- | --- | --- | --- | +| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS` | int | 0 | Optional, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added | +| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING` | int | 0 | Optional, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added. This applies to projects which select 'Training' as their field of science discipline. | +| `AUTO_COMPUTE_ALLOCATION_END_DELTA` | int | 365 | Optional, number of days from creation of the allocation to expiry, default 365 to align with default project duration of 1 year | +| `AUTO_COMPUTE_ALLOCATION_CHANGEABLE` | bool | True | Optional, allows the allocation to have a request logged to change - this might be useful for an extension | +| `AUTO_COMPUTE_ALLOCATION_LOCKED` | bool | False | Optional, prevents the allocation from being modified by admin - this might be useful for an extensions | +| `AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION` | bool | False | Optional, provides an institution based slurm fairshare attribute, requires that the _institution feature_ is setup correctly | +| `AUTO_COMPUTE_ALLOCATION_CLUSTERS` | tuple | empty () | Optional, filter for clusters to automatically allocate on - example value ``AUTO_COMPUTE_ALLOCATION_CLUSTERS=(Cluster1,Cluster4)`` | +| ``AUTO_COMPUTE_ALLOCATION_DESCRIPTION`` | str | "auto\|Cluster\|" | Optionally control the produced description for the allocation and its delimiters within. The _project_code_ will always be appended. Example resultant description: ``auto\|Cluster\|CDF0001`` | From c504d0ff6bd4b8103c6039aca30742fd61bdad6e Mon Sep 17 00:00:00 2001 From: David Simpson <> Date: Thu, 3 Jul 2025 08:39:29 +0100 Subject: [PATCH 031/110] minor improvements to project_openldap README.md minor improvements to project_openldap README.md Signed-off-by: David Simpson --- coldfront/plugins/project_openldap/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/coldfront/plugins/project_openldap/README.md b/coldfront/plugins/project_openldap/README.md index 3d594f7843..3b1e4c716e 100644 --- a/coldfront/plugins/project_openldap/README.md +++ b/coldfront/plugins/project_openldap/README.md @@ -22,7 +22,7 @@ To do this django signals are used, which fire upon actions in the Coldfront Web The plugin uses a bind user (which the site has to setup with appropriate write access) and _python ldap3_ to write changes to OpenLDAP. -The **bind user** will **write to a project project OU** and **potentially an archive OU** (if defined by the site admin). +The **bind user** will **write to a project OU** and **potentially an archive OU** (if defined by the site admin, using the relevant environment variable). The OpenLDAP server (slapd) must be operational and ready to accept changes via the configured bind user. @@ -99,7 +99,7 @@ classDiagram -### Syncronization and management command usage +### Synchronization and management command usage Should Coldfront continue creating or modifying projects whilst the OpenLDAP server is unavailable, corrective action will likely be needed. A management command is provided to perform checks and synchronize changes required. This should only be used when Coldfront and OpenLDAP are (or are suspected) to be out of sync. @@ -155,7 +155,7 @@ PROJECT_CODE_PADDING=4 ### Usage - Example setup An example setup might look something like this. -- The institution feature usage is not required or mandated. +- The institution feature usage is not required or mandated. If it is enabled, it will appear in the description field of the OpenLDAP posixgroup. - Here we are setup to use the Archive OU and not delete per project OUs upon Coldfront archive action in WebUI **NOTE:** Security (e.g. SSL + TLS) configuration is the responsibility of the site administrator - these aren't production settings below, only a starting point. @@ -189,7 +189,7 @@ PROJECT_INSTITUTION_EMAIL_MAP=coldfront.ac.uk=ColdfrontUniversity | Option | Type | Default | Description | |--- | --- | --- | --- | -| `PLUGIN_PROJECT_OPENLDAP` | Bool | True | Enable the plugin, required to be set as True (bool). | +| `PLUGIN_PROJECT_OPENLDAP` | Bool | False, not defined | Enable the plugin, required to be set as True (bool). | **Required:** | Option | Type | Default | Description | @@ -234,7 +234,7 @@ PROJECT_INSTITUTION_EMAIL_MAP=coldfront.ac.uk=ColdfrontUniversity An example of a project posixgroup and OU (see further down for OU). - Using 8000 as the start GID, this is project 11 (pk=11), so 8000+11 is the resultant gidNumber. -- Within the OpenLDAP description _INSTITUTE_ gets populated if possible, the plugin doesn't require the institution feature is enabled though. _INSTITUTE: NotDefined_ will be seen if not enabled. +- Within the OpenLDAP description _INSTITUTE_ gets populated if possible, the plugin doesn't require the institution feature is enabled though. _INSTITUTE: NotDefined_ will be seen if enabled and there isn't a match.
``` From 549ffb1a32a2e9547db98db4c386c176883e8de0 Mon Sep 17 00:00:00 2001 From: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:47:50 -0400 Subject: [PATCH 032/110] Refactored django management commands to use self.stdout.write() instead of print(). Signed-off-by: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> --- .../commands/import_field_of_science_data.py | 4 ++-- .../core/utils/management/commands/initial_setup.py | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py b/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py index 67a9a33b1e..f71e1f0038 100644 --- a/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py +++ b/coldfront/core/field_of_science/management/commands/import_field_of_science_data.py @@ -15,7 +15,7 @@ class Command(BaseCommand): help = "Import field of science data" def handle(self, *args, **options): - print("Adding field of science ...") + self.stdout.write("Adding field of science ...") file_path = os.path.join(app_commands_dir, "data", "field_of_science_data.csv") FieldOfScience.objects.all().delete() with open(file_path, "r") as fp: @@ -38,4 +38,4 @@ def handle(self, *args, **options): fos.parent_id = parent_fos fos.save() - print("Finished adding field of science") + self.stdout.write("Finished adding field of science") diff --git a/coldfront/core/utils/management/commands/initial_setup.py b/coldfront/core/utils/management/commands/initial_setup.py index dac9e2a7b5..fb02cdddb3 100644 --- a/coldfront/core/utils/management/commands/initial_setup.py +++ b/coldfront/core/utils/management/commands/initial_setup.py @@ -14,7 +14,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - "-f", "--force_overwrite", help="Force intial_setup script to run with no warning.", action="store_true" + "-f", "--force_overwrite", help="Force initial_setup script to run with no warning.", action="store_true" ) def handle(self, *args, **options): @@ -22,15 +22,17 @@ def handle(self, *args, **options): run_setup() else: - print( - """WARNING: Running this command initializes the ColdFront database and may modify/delete data in your existing ColdFront database. This command is typically only run once.""" + self.stdout.write( + self.style.WARNING( + """WARNING: Running this command initializes the ColdFront database and may modify/delete data in your existing ColdFront database. This command is typically only run once.""" + ) ) user_response = input("Do you want to proceed?(yes):") if user_response == "yes": run_setup() else: - print("Please enter 'yes' if you wish to run intital setup.") + self.stdout.write("Please enter 'yes' if you wish to run initial setup.") def run_setup(): From a5abae724e27b1905d8b852b67c3faf6a5a8a87e Mon Sep 17 00:00:00 2001 From: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> Date: Thu, 3 Jul 2025 13:48:41 -0400 Subject: [PATCH 033/110] Tests for the Allocation __str__ method. Signed-off-by: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> --- .../core/allocation/tests/test_models.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/coldfront/core/allocation/tests/test_models.py b/coldfront/core/allocation/tests/test_models.py index 87849db100..cfa4352b9d 100644 --- a/coldfront/core/allocation/tests/test_models.py +++ b/coldfront/core/allocation/tests/test_models.py @@ -6,6 +6,7 @@ import datetime +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.test import TestCase from django.utils import timezone @@ -20,6 +21,7 @@ AllocationStatusChoiceFactory, ProjectFactory, ResourceFactory, + UserFactory, ) @@ -140,3 +142,88 @@ def test_status_is_active_and_start_date_equals_end_date_no_error(self): status=self.active_status, start_date=start_and_end_date, end_date=start_and_end_date, project=self.project ) actual_allocation.full_clean() + + +class AllocationModelStrTests(TestCase): + """Tests for Allocation.__str__""" + + def setUp(self): + self.allocation = AllocationFactory() + self.resource = ResourceFactory() + self.allocation.resources.add(self.resource) + + def test_allocation_str_only_contains_parent_resource_and_project_pi(self): + """Test that the allocation's str only contains self.allocation.get_parent_resource.name and self.allocation.project.pi""" + parent_resource_name: str = self.allocation.get_parent_resource.name + project_pi: str = self.allocation.project.pi + expected: str = f"{parent_resource_name} ({project_pi})" + actual = str(self.allocation) + self.assertEqual(actual, expected) + + def test_parent_resource_name_updated_changes_str(self): + """Test that when the name of the parent resource changes the str changes""" + project_pi: str = self.allocation.project.pi + + new_name: str = "This is the new name" + self.resource.name = new_name + self.resource.save() + + expected: str = f"{new_name} ({project_pi})" + actual = str(self.allocation) + self.assertEqual(actual, expected) + + def test_project_pi_name_updated_changes_str(self): + """Test that if the name of the PI is updated that the str changes""" + pi: User = self.allocation.project.pi + new_username: str = "This is a new username!" + pi.username = new_username + pi.save() + + parent_resource_name: str = self.allocation.get_parent_resource.name + expected: str = f"{parent_resource_name} ({pi})" + actual = str(self.allocation) + self.assertEqual(actual, expected) + + def test_parent_resource_changed_changes_str(self): + """When the original parent resource is removed and replaced with another the str changes""" + original_pi: User = self.allocation.project.pi + + original_string = str(self.allocation) + + self.allocation.resources.clear() + new_resource = ResourceFactory() + self.allocation.resources.add(new_resource) + new_string = str(self.allocation) + + expected_new_string = f"{new_resource.name} ({original_pi})" + + self.assertNotEqual(original_string, new_string) + self.assertIn(new_string, expected_new_string) + + def test_project_changed_changes_str(self): + """When the project associated with this allocation changes the str should change""" + original_string = str(self.allocation) + + new_project = ProjectFactory() + self.allocation.project = new_project + self.allocation.save() + + new_string = str(self.allocation) + expected_new_string = f"{self.resource.name} ({new_project.pi})" + + self.assertNotEqual(original_string, new_string) + self.assertEqual(new_string, expected_new_string) + + def test_project_pi_changed_changes_str(self): + """When the project associated with this allocation has its PI change the str should change""" + original_string = str(self.allocation) + + new_pi = UserFactory() + self.allocation.project.pi = new_pi + self.allocation.save() + + new_string = str(self.allocation) + expected_new_string = f"{self.resource.name} ({new_pi})" + + self.assertNotEqual(original_string, new_string) + self.assertEqual(new_string, expected_new_string) From 11b6a109bb028bce6dbcf1752f2e29a6936193cf Mon Sep 17 00:00:00 2001 From: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:47:18 -0400 Subject: [PATCH 034/110] Reorganized the tests into seperate directories for each core app. Signed-off-by: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> --- coldfront/core/field_of_science/tests/__init__.py | 0 coldfront/core/field_of_science/{ => tests}/tests.py | 0 coldfront/core/grant/tests/__init__.py | 0 coldfront/core/grant/{ => tests}/tests.py | 0 coldfront/core/portal/tests/__init__.py | 0 coldfront/core/portal/{ => tests}/tests.py | 0 coldfront/core/project/tests/__init__.py | 0 coldfront/core/project/{ => tests}/test_views.py | 0 coldfront/core/project/{ => tests}/tests.py | 0 coldfront/core/publication/tests/__init__.py | 0 coldfront/core/publication/{ => tests}/tests.py | 0 coldfront/core/research_output/tests/__init__.py | 0 coldfront/core/research_output/{ => tests}/tests.py | 0 coldfront/core/resource/tests/__init__.py | 0 coldfront/core/resource/{ => tests}/tests.py | 0 coldfront/core/user/tests/__init__.py | 0 coldfront/core/user/{ => tests}/tests.py | 0 coldfront/core/utils/tests/__init__.py | 0 coldfront/core/utils/{ => tests}/tests.py | 0 19 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 coldfront/core/field_of_science/tests/__init__.py rename coldfront/core/field_of_science/{ => tests}/tests.py (100%) create mode 100644 coldfront/core/grant/tests/__init__.py rename coldfront/core/grant/{ => tests}/tests.py (100%) create mode 100644 coldfront/core/portal/tests/__init__.py rename coldfront/core/portal/{ => tests}/tests.py (100%) create mode 100644 coldfront/core/project/tests/__init__.py rename coldfront/core/project/{ => tests}/test_views.py (100%) rename coldfront/core/project/{ => tests}/tests.py (100%) create mode 100644 coldfront/core/publication/tests/__init__.py rename coldfront/core/publication/{ => tests}/tests.py (100%) create mode 100644 coldfront/core/research_output/tests/__init__.py rename coldfront/core/research_output/{ => tests}/tests.py (100%) create mode 100644 coldfront/core/resource/tests/__init__.py rename coldfront/core/resource/{ => tests}/tests.py (100%) create mode 100644 coldfront/core/user/tests/__init__.py rename coldfront/core/user/{ => tests}/tests.py (100%) create mode 100644 coldfront/core/utils/tests/__init__.py rename coldfront/core/utils/{ => tests}/tests.py (100%) diff --git a/coldfront/core/field_of_science/tests/__init__.py b/coldfront/core/field_of_science/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/field_of_science/tests.py b/coldfront/core/field_of_science/tests/tests.py similarity index 100% rename from coldfront/core/field_of_science/tests.py rename to coldfront/core/field_of_science/tests/tests.py diff --git a/coldfront/core/grant/tests/__init__.py b/coldfront/core/grant/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/grant/tests.py b/coldfront/core/grant/tests/tests.py similarity index 100% rename from coldfront/core/grant/tests.py rename to coldfront/core/grant/tests/tests.py diff --git a/coldfront/core/portal/tests/__init__.py b/coldfront/core/portal/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/portal/tests.py b/coldfront/core/portal/tests/tests.py similarity index 100% rename from coldfront/core/portal/tests.py rename to coldfront/core/portal/tests/tests.py diff --git a/coldfront/core/project/tests/__init__.py b/coldfront/core/project/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/project/test_views.py b/coldfront/core/project/tests/test_views.py similarity index 100% rename from coldfront/core/project/test_views.py rename to coldfront/core/project/tests/test_views.py diff --git a/coldfront/core/project/tests.py b/coldfront/core/project/tests/tests.py similarity index 100% rename from coldfront/core/project/tests.py rename to coldfront/core/project/tests/tests.py diff --git a/coldfront/core/publication/tests/__init__.py b/coldfront/core/publication/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/publication/tests.py b/coldfront/core/publication/tests/tests.py similarity index 100% rename from coldfront/core/publication/tests.py rename to coldfront/core/publication/tests/tests.py diff --git a/coldfront/core/research_output/tests/__init__.py b/coldfront/core/research_output/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/research_output/tests.py b/coldfront/core/research_output/tests/tests.py similarity index 100% rename from coldfront/core/research_output/tests.py rename to coldfront/core/research_output/tests/tests.py diff --git a/coldfront/core/resource/tests/__init__.py b/coldfront/core/resource/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/resource/tests.py b/coldfront/core/resource/tests/tests.py similarity index 100% rename from coldfront/core/resource/tests.py rename to coldfront/core/resource/tests/tests.py diff --git a/coldfront/core/user/tests/__init__.py b/coldfront/core/user/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/user/tests.py b/coldfront/core/user/tests/tests.py similarity index 100% rename from coldfront/core/user/tests.py rename to coldfront/core/user/tests/tests.py diff --git a/coldfront/core/utils/tests/__init__.py b/coldfront/core/utils/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/coldfront/core/utils/tests.py b/coldfront/core/utils/tests/tests.py similarity index 100% rename from coldfront/core/utils/tests.py rename to coldfront/core/utils/tests/tests.py From 5523ef07ea83024dedd29f931d877b451c232140 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Mon, 14 Jul 2025 22:15:36 -0400 Subject: [PATCH 035/110] Update doc theme and branding Signed-off-by: Andrew E. Bruno --- REUSE.toml | 1 + coldfront/config/urls.py | 11 ++++ coldfront/static/common/css/common.css | 34 +++++----- coldfront/static/common/images/logo.png | Bin 121290 -> 15065 bytes coldfront/templates/common/navbar_brand.html | 2 +- docs/mkdocs.yml | 22 +++---- docs/pages/assets/cf-icon.png | Bin 0 -> 6437 bytes docs/pages/assets/styles.css | 6 ++ docs/pages/images/favicon.png | Bin 1431 -> 1321 bytes docs/pages/images/logo-lg.png | Bin 41588 -> 0 bytes docs/pages/images/logo.png | Bin 30581 -> 0 bytes docs/pages/images/logo.svg | 1 - docs/pages/index.md | 2 - pyproject.toml | 1 - uv.lock | 64 ------------------- 15 files changed, 46 insertions(+), 98 deletions(-) create mode 100644 docs/pages/assets/cf-icon.png create mode 100644 docs/pages/assets/styles.css delete mode 100644 docs/pages/images/logo-lg.png delete mode 100644 docs/pages/images/logo.png delete mode 100644 docs/pages/images/logo.svg diff --git a/REUSE.toml b/REUSE.toml index 3a83aa39e5..18005a899f 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -27,6 +27,7 @@ SPDX-License-Identifier = "AGPL-3.0-or-later" [[annotations]] path = [ "docs/pages/images/*", + "docs/pages/assets/*", "**.ico", ] SPDX-FileCopyrightText = "(C) ColdFront Authors" diff --git a/coldfront/config/urls.py b/coldfront/config/urls.py index 0511edc552..78d958977a 100644 --- a/coldfront/config/urls.py +++ b/coldfront/config/urls.py @@ -8,6 +8,8 @@ from django.conf import settings from django.contrib import admin +from django.core import serializers +from django.http import HttpResponse from django.urls import include, path from django.views.generic import TemplateView @@ -49,3 +51,12 @@ if "django_su.backends.SuBackend" in settings.AUTHENTICATION_BACKENDS: urlpatterns.append(path("su/", include("django_su.urls"))) + + +def export_as_json(modeladmin, request, queryset): + response = HttpResponse(content_type="application/json") + serializers.serialize("json", queryset, stream=response) + return response + + +admin.site.add_action(export_as_json, "export_as_json") diff --git a/coldfront/static/common/css/common.css b/coldfront/static/common/css/common.css index be0952d969..bd7923483e 100644 --- a/coldfront/static/common/css/common.css +++ b/coldfront/static/common/css/common.css @@ -3,20 +3,6 @@ background: #E2E2E2; } -/*.navbar-custom { - background-color: #FFFFFF; - font-weight: bold; - height: 50px; -} - -.navbar-custom > .navbar-nav > .active { - background: #E2E2E2; -} - -*/ - - - html { position: relative; min-height: 100%; @@ -31,6 +17,22 @@ main { padding-bottom: 40px; } + +.bg-primary { + background-color: #1f1d1d !important; +} + +.text-primary { + color: #1f1d1d !important; + +} + +.border-primary { + border-color: #1f1d1d !important; +} + + + #brand .container { margin-bottom: 1px; } @@ -64,6 +66,7 @@ main { .banner-name { padding-top: 25px; + color: #ffffff; } .banner-logo { @@ -72,7 +75,6 @@ main { margin-bottom: 5px; } - body > main > form > div.alert.alert-block.alert-danger > ul > li { list-style: none; margin-top: 14px; @@ -144,4 +146,4 @@ body > main > div > div.card.border-primary > div.card-body > form > div.alert.a top:1px; } -.form-control[readonly]{background-color:#ffffff;opacity:1} \ No newline at end of file +.form-control[readonly]{background-color:#ffffff;opacity:1} diff --git a/coldfront/static/common/images/logo.png b/coldfront/static/common/images/logo.png index 2a71d916c0d96de25bb40a79d132f3df458ef5e7..d833049221009f9918b165f5e53ff6f24aeb94af 100644 GIT binary patch literal 15065 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 Xg7g~J>qY|Aq!=m?O@(r~mjV9^LsLH+ literal 121290 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�Ta^_$Jl0 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 diff --git a/coldfront/templates/common/navbar_brand.html b/coldfront/templates/common/navbar_brand.html index 84dd63a4bd..24532d3f3c 100644 --- a/coldfront/templates/common/navbar_brand.html +++ b/coldfront/templates/common/navbar_brand.html @@ -1,6 +1,6 @@ {% load static %} {% load common_tags %} -

+
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 4b1ab7b0cc..d9b29cd41a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,24 +1,20 @@ site_name: ColdFront docs_dir: 'pages' site_description: 'HPC Resource Allocation Management System' -site_url: 'https://coldfront.readthedocs.io' -copyright: 'Copyright © 2021 Center for Computational Research, University at Buffalo' +site_url: 'https://docs.coldfront.dev' +copyright: 'Copyright © University at Buffalo Center for Computational Research' repo_name: 'ubccr/coldfront' repo_url: 'https://github.com/ubccr/coldfront' edit_uri: '' -theme: - name: 'material' - logo: 'images/logo.png' - favicon: 'images/favicon.png' - palette: - primary: 'blue' +extra_css: + - assets/styles.css - features: - - toc.integrate - - navigation.tabs - - navigation.tabs.sticky - - navigation.indexes +theme: + logo: assets/cf-icon.png + name: readthedocs + prev_next_buttons_location: 'none' + highlightjs: true plugins: - search diff --git a/docs/pages/assets/cf-icon.png b/docs/pages/assets/cf-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e5a9d3206a1dd4c558768be9a6bec3250b3e348a GIT binary patch literal 6437 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 T(zEw&00000NkvXXu0mjf1y`ze diff --git a/docs/pages/images/logo-lg.png b/docs/pages/images/logo-lg.png deleted file mode 100644 index fdb0134f49fc5d8fd23d4b9be9eb0ee901614515..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41588 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 L8tZ(}dK~#bk*Z=} diff --git a/docs/pages/images/logo.png b/docs/pages/images/logo.png deleted file mode 100644 index 1eaf13a47134b711e2c3a3d5cb64289453a234a4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30581 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 C`knLu diff --git a/docs/pages/images/logo.svg b/docs/pages/images/logo.svg deleted file mode 100644 index fd0f93eef3..0000000000 --- a/docs/pages/images/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/pages/index.md b/docs/pages/index.md index a3f34c7b64..b1d74f2774 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -1,7 +1,5 @@ # What is ColdFront? -![Diagram](images/logo-lg.png) - ColdFront is an open source resource and allocation management system designed to provide a central portal for administration, reporting, and measuring scientific impact of cyberinfrastructure resources. ColdFront was created to help high performance computing (HPC) centers manage access to a diverse set of resources across large groups of users and provide a rich set of diff --git a/pyproject.toml b/pyproject.toml index a7b36f1b95..5d0280df51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,6 @@ dev = [ docs = [ "mkdocs>=1.6.1", "mkdocs-awesome-pages-plugin>=2.10.1", - "mkdocs-material>=9.6.11", "mkdocstrings>=0.29.1", "mkdocstrings-python>=1.16.10", "pygments", diff --git a/uv.lock b/uv.lock index 8b62be002f..d55ad80d66 100644 --- a/uv.lock +++ b/uv.lock @@ -32,28 +32,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, ] -[[package]] -name = "babel" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, -] - -[[package]] -name = "backrefs" -version = "5.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/46/caba1eb32fa5784428ab401a5487f73db4104590ecd939ed9daaf18b47e0/backrefs-5.8.tar.gz", hash = "sha256:2cab642a205ce966af3dd4b38ee36009b31fa9502a35fd61d59ccc116e40a6bd", size = 6773994 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/cb/d019ab87fe70e0fe3946196d50d6a4428623dc0c38a6669c8cae0320fbf3/backrefs-5.8-py310-none-any.whl", hash = "sha256:c67f6638a34a5b8730812f5101376f9d41dc38c43f1fdc35cb54700f6ed4465d", size = 380337 }, - { url = "https://files.pythonhosted.org/packages/a9/86/abd17f50ee21b2248075cb6924c6e7f9d23b4925ca64ec660e869c2633f1/backrefs-5.8-py311-none-any.whl", hash = "sha256:2e1c15e4af0e12e45c8701bd5da0902d326b2e200cafcd25e49d9f06d44bb61b", size = 392142 }, - { url = "https://files.pythonhosted.org/packages/b3/04/7b415bd75c8ab3268cc138c76fa648c19495fcc7d155508a0e62f3f82308/backrefs-5.8-py312-none-any.whl", hash = "sha256:bbef7169a33811080d67cdf1538c8289f76f0942ff971222a16034da88a73486", size = 398021 }, - { url = "https://files.pythonhosted.org/packages/04/b8/60dcfb90eb03a06e883a92abbc2ab95c71f0d8c9dd0af76ab1d5ce0b1402/backrefs-5.8-py313-none-any.whl", hash = "sha256:e3a63b073867dbefd0536425f43db618578528e3896fb77be7141328642a1585", size = 399915 }, - { url = "https://files.pythonhosted.org/packages/0c/37/fb6973edeb700f6e3d6ff222400602ab1830446c25c7b4676d8de93e65b8/backrefs-5.8-py39-none-any.whl", hash = "sha256:a66851e4533fb5b371aa0628e1fee1af05135616b86140c9d787a2ffdf4b8fdc", size = 380336 }, -] - [[package]] name = "bibtexparser" version = "1.4.3" @@ -326,7 +304,6 @@ dev = [ docs = [ { name = "mkdocs" }, { name = "mkdocs-awesome-pages-plugin" }, - { name = "mkdocs-material" }, { name = "mkdocstrings" }, { name = "mkdocstrings-python" }, { name = "pygments" }, @@ -377,7 +354,6 @@ dev = [ docs = [ { name = "mkdocs", specifier = ">=1.6.1" }, { name = "mkdocs-awesome-pages-plugin", specifier = ">=2.10.1" }, - { name = "mkdocs-material", specifier = ">=9.6.11" }, { name = "mkdocstrings", specifier = ">=0.29.1" }, { name = "mkdocstrings-python", specifier = ">=1.16.10" }, { name = "pygments" }, @@ -1090,37 +1066,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, ] -[[package]] -name = "mkdocs-material" -version = "9.6.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/7e/c65e330e99daa5813e7594e57a09219ad041ed631604a72588ec7c11b34b/mkdocs_material-9.6.11.tar.gz", hash = "sha256:0b7f4a0145c5074cdd692e4362d232fb25ef5b23328d0ec1ab287af77cc0deff", size = 3951595 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/91/79a15a772151aca0d505f901f6bbd4b85ee1fe54100256a6702056bab121/mkdocs_material-9.6.11-py3-none-any.whl", hash = "sha256:47f21ef9cbf4f0ebdce78a2ceecaa5d413581a55141e4464902224ebbc0b1263", size = 8703720 }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, -] - [[package]] name = "mkdocstrings" version = "0.29.1" @@ -1211,15 +1156,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, -] - [[package]] name = "pathspec" version = "0.12.1" From aa9eedb92aff98d0a59296663065bccfa45275d5 Mon Sep 17 00:00:00 2001 From: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> Date: Mon, 14 Jul 2025 13:21:07 -0400 Subject: [PATCH 036/110] Created containers.md docs. Signed-off-by: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> --- docs/mkdocs.yml | 1 + docs/pages/.pages | 1 + docs/pages/containers.md | 246 +++++++++++++++++++++++ docs/pages/images/container_topology.png | Bin 0 -> 198835 bytes 4 files changed, 248 insertions(+) create mode 100644 docs/pages/containers.md create mode 100644 docs/pages/images/container_topology.png diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 4b1ab7b0cc..f6fab1e492 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -31,6 +31,7 @@ plugins: show_signature: false markdown_extensions: + - attr_list - footnotes - admonition - codehilite: diff --git a/docs/pages/.pages b/docs/pages/.pages index d413782899..9eadda367e 100644 --- a/docs/pages/.pages +++ b/docs/pages/.pages @@ -2,6 +2,7 @@ nav: - index.md - install.md - config.md + - containers.md - upgrading.md - Plugins: plugin - Deployment: deploy.md diff --git a/docs/pages/containers.md b/docs/pages/containers.md new file mode 100644 index 0000000000..2fda848923 --- /dev/null +++ b/docs/pages/containers.md @@ -0,0 +1,246 @@ +# Containers + +ColdFront provides a standard base image when using containers. This image is updated regularly, but its releases do not necessarily correspond with the bleeding edge on the main branch on GitHub or with official releases on PyPI. The official ColdFront image is hosted on [Docker Hub](https://hub.docker.com/r/ubccr/coldfront/tags). The image can also be created from source using the included `Dockerfile` in the root directory of the [repository on GitHub](https://github.com/ubccr/coldfront). The container is configured by default to run Gunicorn workers to serve the web page, but can also be used to run ColdFront management commands and ColdFront's background workers. + +The provided image was designed to maximize ease of use and portability. Therefore, it is most suited to help users trial and develop ColdFront. The image comes pre-configured with everything needed to run any configuration of ColdFront, regardless of factors such as the plugins used, what database is used, or how the rest of the deployment is configured. To maximize performance and security, ColdFront administrators may want to customize and harden the provided image to suit their specific needs before using it in a deployment. + +!!! tip "Container Security" + Container security is a broad and complex topic that falls outside the scope of this document. Container security best practices will vary based on the specific circumstances of each deployment. [Docker's security documentation](https://docs.docker.com/engine/security/) and the [OWASP container cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html) are good resources to familiarize yourself with container security. + + +The rest of this document will provide guidance on how to use the ColdFront container in common situations. A basic understanding of using the [Docker CLI](https://docs.docker.com/reference/cli/docker/) is assumed. While this document uses Docker for examples, the ColdFront container image should be compatible with any [Open Container Initiative](https://opencontainers.org/) compliant tooling, such as `podman`. If you experience any problems with the provided image not behaving as expected with alternative containerization tools, please open an issue or discussion on GitHub. + + +## Run and Test ColdFront Using Container + +While running Gunicorn by default is suitable for a production deployment, for testing purposes it is better to use Django's built-in test server. When ColdFront is configured with the setting `DEBUG = True` and ran with the built-in Django test server, static files will also be displayed without the need to configure a separate server such as nginx. With the ColdFront container also connected to a database, most of ColdFront's functionality can be tested using just a single container! + +To test ColdFront with a container, a `.env` file containing settings needs to be written. A good minimal configuration for testing is: +```shell +DEBUG=True +CENTER_NAME='University HPC' +PROJECT_ENABLE_PROJECT_REVIEW=False +DB_URL=sqlite:////usr/share/coldfront/coldfront.db +``` +which can be saved anywhere on disk, but for this example will be saved to `/tmp/test/coldfront.env`. + +ColdFront will also need to have a relational database configured. The simplest method is to use a sqlite database file on the local machine that will be mapped into the container with a bind mount. Create a location on your local disk where the sqlite database file can be stored, for example `mkdir -p /tmp/test/coldfront/`. + +The container can now be ran. The following command specifies the location of the environment variable file, a bind mount that maps a path from the local filesystem to the container's filesystem to store the database, runs the application on port 8000, and overrides the default command to use Django's built-in test server: +```shell +docker run -it --rm --env-file /tmp/test/coldfront.env -v /tmp/test/coldfront:/usr/share/coldfront -p 8000:8000 --name cftest docker.io/ubccr/coldfront:latest coldfront runserver 0.0.0.0:8000 +``` + +The ColdFront web application is now running and should be accessible from `localhost:8000`. While ColdFront is running, it does not yet have any data in it. To populate the database open up a shell in the container: +```shell +docker exec -it cftest /bin/bash +``` +which will allow you to run management commands. + +To set up the initial database tables you can run: + ```shell + coldfront initial_setup + ``` + +A new admin account can be created using: +``` +coldfront createsuperuser +``` + +A set of pre-configured test data can be loaded into the database using: +``` +coldfront load_test_data +``` +please see [the installation docs](install.md#loading-the-sample-test-data) for more information on using ColdFront's management commands including the credentials created for the users populated using `load_test_data`. + +!!! warning "`load_test_data`" + A database that has been configured with `load_test_data` contains insecure user account logins and should never be used in production. The data provided by `load_test_data` is only meant for development. + +## Customizing Coldfront Container + +### Extending the Base Image + +There are many cases where it might be necessary to have a customized ColdFront container to meet your own needs. These may include: + +- increasing container security +- adding custom plugins +- using custom static resources in the image +- configuring a log aggregator +- many other situations! + +The easiest way to create a customized ColdFront container is to extend the provided ColdFront image and add your own modifications. + +To create your own image: + +1. Start a `Dockerfile` with a `FROM docker.io/ubccr/coldfront:` directive (if you created the base image locally from a Dockerfile instead of pulling from Docker Hub, you would have to specify the `name:tag` of the local image instead of the Docker Hub URI). +2. Write all the directives necessary to set up your custom modifications. +3. Close the new Dockerfile with a `CMD` directive to specify what you want the container to run by default. This will override the `CMD` directive of the base image. + +The following is an example of a customized image that will run ColdFront using a non-root user for increased security. This custom ColdFront container uses a sqlite database which requires that the process running ColdFront has direct write permissions on the database file. Giving a non-root user direct access to a file stored outside the container's filesystem (since databases should always be stored outside of a container image) requires knowing the path to the directory at build time. +```Dockerfile +FROM docker.io/ubccr/coldfront:latest + +# Create a non-root user and group +RUN groupadd -r coldfrontgroup && useradd -r -g coldfrontgroup -d /app -m -s /bin/false coldfrontuser + +# Make sure the mount point for the sqlite directory exists and is owned by the coldfrontuser +RUN mkdir /path/to/sqlite/data && chown coldfrontuser:coldfrontgroup /path/to/sqlite/data + +# Make sure coldfrontuser has proper permissions +RUN chmod 770 /path/to/sqlite/data + +# Make sure everything runs from /app still +WORKDIR /app + +# Run everything in the container as non-root user +USER coldfrontuser + +CMD ["put", "your", "command", "here"] +``` + + + + +### Creating a Custom Image from Source + +Extending the provided ColdFront image is the best solution when adding or tweaking functionality on the base ColdFront image. However, certain deployments may want to remove functionality or heavily modify the base image itself. These types of changes will be easiest by modifying the source code for the base image to the desired configuration. A common case for doing this is eliminating unused dependencies to reduce the size and attack surface of the container. + +For example, if you are using sqlite for your database, there is no need to have drivers installed in the image to support Postgres or MySQL/MariaDB. You could simply remove the lines which install these dependencies from the provided `Dockerfile`: +```diff +# Dockerfile contents ... +RUN apt update && DEBIAN_FRONTEND=noninteractive apt install -y --no-install-recommends \ + sqlite3 \ + freeipa-client \ +- mariadb-client \ +- postgresql-client + +# Dockerfile continues... +- libmariadb-dev \ +- libpq-dev \ + libssl-dev \ + libdbus-1-dev \ + libldap2-dev \ + libkrb5-dev \ + libglib2.0-dev \ + libsasl2-dev + +# Dockerfile continues... ++ --extra oidc +- --extra oidc \ +- --extra mysql \ +- --extra pg + +# Dockerfile continues... ++ --extra oidc +- --extra oidc \ +- --extra mysql \ +- --extra pg +``` +and then build from the modified `Dockerfile` using `docker build`. + + +## Deploying ColdFront with Containers + +### Required Components + +Deploying ColdFront with containers requires the following components: + + +1. Services: + + 1. Container running a web server (nginx) + + 2. ColdFront container running a python application HTTP server (such as gunicorn) + + 3. ColdFront container running Django-Q cluster background workers + + 4. Container running a database server (unless using sqlite) + + 5. Container running redis as an in-memory database + + 6. (Optional) Temporary ColdFront container used to run management commands + +2. Storage: + + 1. Volume for storing static files + + 2. Volume for the relational database data + + 3. Volume for the in-memory/redis data to backup to + + 4. Volume/bind-mount for the site-specific templates + + 5. Volume/bind-mount for the site-specific static files + +### Executing Management Commands + +In any deployment it will be necessary to use a ColdFront container to execute admin commands to perform actions such as initializing the database, applying migrations for new updates, and collecting static files. +While admin commands can be executed from any ColdFront container, a temporary ColdFront container can be spun up whenever admin commands need to be performed. This eliminates needing a container to have access to volumes/mounts unnecessarily and makes it easier to manage an active deployment without interfering with a container already running another service. + +Regardless of which ColdFront container is used to run admin commands, that ColdFront container must have access to the mounts/volumes containing the site-specific static files, site-specific template files, and the regular static-files volume in order to run `coldfront collectstatic`. Most admin commands will also require access to the SQL database container, and some commands will also require access to the redis database container. + +### Topology + +To help visualize how the various components fit together, the following is a topology diagram of a typical ColdFront deployment using containers: + +![Infrastructure Diagram for ColdFront Deployment Using Containers](./images/container_topology.png){width=60%} + + +### Docker Compose Example + +Docker compose is a powerful and convenient way to set up multi-container applications, and can be used to set up a ColdFront deployment as well. The following is an abbreviated example of a docker compose file demonstrating how to set up a ColdFront deployment: +```yaml +services: + web-server: + image: nginx:latest + volumes: + - static-files-directory:/srv/coldfront/static/ + + coldfront-web-app: + image: docker.io/ubccr/coldfront:latest + command: ["gunicorn", "--workers", "3", "--bind", ":8000", "coldfront.config.wsgi"] + env_file: + - ./coldfront.env + depends_on: + - relational-database + - in-memory-database + + coldfront-background-workers: + image: docker.io/ubccr/coldfront:latest + command: ["coldfront", "qcluster"] + env_file: + - ./coldfront.env + depends_on: + - relational-database + - in-memory-database + + # this container would be ran individually, this is just here for illustration + # coldfront-admin-commands: + # image: docker.io/ubccr/coldfront:latest + # command: ["/bin/bash"] + # volumes: + # - static-files-directory:/srv/coldfront/static/ + # - /path/to/your/site/templates/locally:/usr/share/coldfront/site/templates + # - /path/to/your/site/static/locally:/usr/share/coldfront/site/static + + relational-database: + image: mariadb:latest + env_file: + - ./coldfront.env + volumes: + - relational-database-data:/var/lib/mysql + + in-memory-database: + image: redis:latest + volumes: + - in-memory-database-data:/data + + +volumes: + relational-database-data: + in-memory-database-data: + static-files-directory: +``` + +!!! note "Compose File" + This docker compose file is provided solely as an example of how docker compose could be used to deploy ColdFront, and to give a text-based description of a ColdFront deployment. This file is not complete or suitable for use without modification. It does not demonstrate the configuration of ColdFront when using plugins. The final version of any configuration file to deploy ColdFront will require deployment-specific modifications. diff --git a/docs/pages/images/container_topology.png b/docs/pages/images/container_topology.png new file mode 100644 index 0000000000000000000000000000000000000000..d360f872406ba1ead565a97530e5c004b6136e4d GIT binary patch literal 198835 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

Kb~IsR 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

B3witV;`E* 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 Date: Thu, 17 Jul 2025 16:57:54 -0400 Subject: [PATCH 037/110] Patched Dockerfile to work with podman. This was minimally tested. Signed-off-by: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index eb67c47d26..17330ac492 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ FROM cfimage RUN apt-get clean && rm -rf /var/lib/apt/lists/* +RUN groupadd -r python && useradd -r -g python python COPY --from=builder --chown=python:python /python /python COPY --from=builder /app /app ENV PATH="/app/.venv/bin:$PATH" From 41619d5b86ddaf349d13f6a0219c143b180f35f3 Mon Sep 17 00:00:00 2001 From: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:55:36 -0400 Subject: [PATCH 038/110] Tests for the expires_in property of the Allocation model. Signed-off-by: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> --- .../core/allocation/tests/test_models.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/coldfront/core/allocation/tests/test_models.py b/coldfront/core/allocation/tests/test_models.py index cfa4352b9d..0363a74302 100644 --- a/coldfront/core/allocation/tests/test_models.py +++ b/coldfront/core/allocation/tests/test_models.py @@ -5,6 +5,7 @@ """Unit tests for the allocation models""" import datetime +from unittest.mock import patch from django.contrib.auth.models import User from django.core.exceptions import ValidationError @@ -227,3 +228,62 @@ def test_project_pi_changed_changes_str(self): self.assertNotEqual(original_string, new_string) self.assertEqual(new_string, expected_new_string) + + +class AllocationModelExpiresInTests(TestCase): + mocked_today = datetime.date(2025, 1, 1) + three_years_after_mocked_today = datetime.date(2028, 1, 1) + four_years_after_mocked_today = datetime.date(2029, 1, 1) + + def test_end_date_is_today_returns_zero(self): + """Test that the expires_in method returns 0 when the end date is today.""" + allocation: Allocation = AllocationFactory(end_date=timezone.now().date()) + self.assertEqual(allocation.expires_in, 0) + + def test_end_date_tomorrow_returns_one(self): + """Test that the expires_in method returns 1 when the end date is tomorrow.""" + tomorrow: datetime.date = (timezone.now() + datetime.timedelta(days=1)).date() + allocation: Allocation = AllocationFactory(end_date=tomorrow) + self.assertEqual(allocation.expires_in, 1) + + def test_end_date_yesterday_returns_negative_one(self): + """Test that the expires_in method returns -1 when the end date is yesterday.""" + yesterday: datetime.date = (timezone.now() - datetime.timedelta(days=1)).date() + allocation: Allocation = AllocationFactory(end_date=yesterday) + self.assertEqual(allocation.expires_in, -1) + + def test_end_date_one_week_ago_returns_negative_seven(self): + """Test that the expires_in method returns -7 when the end date is one week ago.""" + days_in_a_week: int = 7 + one_week_ago: datetime.date = (timezone.now() - datetime.timedelta(days=days_in_a_week)).date() + allocation: Allocation = AllocationFactory(end_date=one_week_ago) + self.assertEqual(allocation.expires_in, -days_in_a_week) + + def test_end_date_in_one_week_returns_seven(self): + """Test that the expires_in method returns 7 when the end date is in one week.""" + days_in_a_week: int = 7 + one_week_from_now: datetime.date = (timezone.now() + datetime.timedelta(days=days_in_a_week)).date() + allocation: Allocation = AllocationFactory(end_date=one_week_from_now) + self.assertEqual(allocation.expires_in, days_in_a_week) + + def test_end_date_in_three_years_without_leap_day_returns_days_including_no_leap_day(self): + """Test that the expires_in method returns the correct number of days in three years when those years did not have a leap year.""" + days_in_three_years_excluding_leap_year = 365 * 3 + + with patch("coldfront.core.allocation.models.datetime") as mock_datetime: + mock_datetime.date.today.return_value = self.mocked_today + + allocation: Allocation = AllocationFactory(end_date=self.three_years_after_mocked_today) + + self.assertEqual(allocation.expires_in, days_in_three_years_excluding_leap_year) + + def test_end_date_in_four_years_returns_days_including_leap_day(self): + """Test that the expires_in method accounts for the extra day of a leap year.""" + days_in_four_years_including_leap_year = (365 * 4) + 1 + + with patch("coldfront.core.allocation.models.datetime") as mock_datetime: + mock_datetime.date.today.return_value = self.mocked_today + + allocation: Allocation = AllocationFactory(end_date=self.four_years_after_mocked_today) + + self.assertEqual(allocation.expires_in, days_in_four_years_including_leap_year) From f3d7265566840fb5c0ef7dfb30c59019ef767791 Mon Sep 17 00:00:00 2001 From: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> Date: Wed, 18 Jun 2025 17:15:15 -0400 Subject: [PATCH 039/110] Remediated mark_safe calls to prevent XSS attacks. Signed-off-by: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> --- coldfront/core/allocation/models.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index 3e6ef9bf9d..636c7d7022 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -10,8 +10,9 @@ from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models -from django.utils.html import mark_safe +from django.utils.html import escape, format_html from django.utils.module_loading import import_string +from django.utils.safestring import SafeString from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords @@ -147,16 +148,17 @@ def expires_in(self): return (self.end_date - datetime.date.today()).days @property - def get_information(self): + def get_information(self) -> SafeString: """ Returns: - str: the allocation's attribute type, usage out of total value, and usage out of total value as a percentage + SafeString: the allocation's attribute type, usage out of total value, and usage out of total value as a percentage """ - html_string = "" + html_string = escape("") for attribute in self.allocationattribute_set.all(): if attribute.allocation_attribute_type.name in ALLOCATION_ATTRIBUTE_VIEW_LIST: - html_string += "%s: %s
" % (attribute.allocation_attribute_type.name, attribute.value) + html_substring = format_html("{}: {}
", attribute.allocation_attribute_type.name, attribute.value) + html_string += html_substring if hasattr(attribute, "allocationattributeusage"): try: @@ -175,15 +177,16 @@ def get_information(self): "Allocation attribute '%s' == 0 but has a usage", attribute.allocation_attribute_type.name ) - string = "{}: {}/{} ({} %)
".format( + html_substring = format_html( + "{}: {}/{} ({} %)
", attribute.allocation_attribute_type.name, attribute.allocationattributeusage.value, attribute.value, percent, ) - html_string += string + html_string += html_substring - return mark_safe(html_string) + return html_string @property def get_resources_as_string(self): From 3f4028969d95170006524a61c1836197cc74de0e Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Tue, 22 Jul 2025 15:33:20 -0400 Subject: [PATCH 040/110] Bump version --- AUTHORS.md | 4 ++++ CHANGELOG.md | 17 ++++++++++++++++- coldfront/__init__.py | 2 +- pyproject.toml | 2 +- uv.lock | 35 +++++++++++++++++++---------------- 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index 346975cc70..b36b5c0b71 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -31,3 +31,7 @@ - Cecilia Lau - Ria Gupta - Shreyas Sridhar +- David Simpson +- Eric Butcher +- Matthew Kusz +- John LaGrone diff --git a/CHANGELOG.md b/CHANGELOG.md index 150b14aa6b..134b754ed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # ColdFront Changelog +## [1.1.7] - 2025-07-22 + +- Automatically change default Slurm account if removal causes conflicts [#597](https://github.com/ubccr/coldfront/pull/597) +- Fix allocation request list displays incorrect date for allocation renewals [#647](https://github.com/ubccr/coldfront/issues/647) +- Add allocation limits for a resource [#667](https://github.com/ubccr/coldfront/pull/667) +- Add REST API [#632](https://github.com/ubccr/coldfront/pull/632) +- Migrate to UV [#677](https://github.com/ubccr/coldfront/pull/677) +- Add EULA enforcement [#671](https://github.com/ubccr/coldfront/pull/671) +- Contiguous Internal Project ID [#646](https://github.com/ubccr/coldfront/pull/646) +- Add auto-compute allocation plugin [#698](https://github.com/ubccr/coldfront/pull/698) +- Add project openldap plugin [#696](https://github.com/ubccr/coldfront/pull/696) +- Add institution feature [#670](https://github.com/ubccr/coldfront/pull/670) +- Update Dockerfile [#715](https://github.com/ubccr/coldfront/pull/715) + ## [1.1.6] - 2024-03-27 - Upgrade to Django 4.2 LTS [#601](https://github.com/ubccr/coldfront/pull/601) @@ -148,4 +162,5 @@ [1.1.4]: https://github.com/ubccr/coldfront/releases/tag/v1.1.4 [1.1.5]: https://github.com/ubccr/coldfront/releases/tag/v1.1.5 [1.1.6]: https://github.com/ubccr/coldfront/releases/tag/v1.1.6 -[Unreleased]: https://github.com/ubccr/coldfront/compare/v1.1.6...HEAD +[1.1.7]: https://github.com/ubccr/coldfront/releases/tag/v1.1.7 +[Unreleased]: https://github.com/ubccr/coldfront/compare/v1.1.7...HEAD diff --git a/coldfront/__init__.py b/coldfront/__init__.py index 104addcbbb..d53cbb860f 100644 --- a/coldfront/__init__.py +++ b/coldfront/__init__.py @@ -5,7 +5,7 @@ import os import sys -__version__ = "1.1.6" +__version__ = "1.1.7" VERSION = __version__ diff --git a/pyproject.toml b/pyproject.toml index 5d0280df51..33bf9873f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "coldfront" -version = "1.1.6" +version = "1.1.7" requires-python = ">=3.9" authors = [ { name = "Andrew E. Bruno" }, diff --git a/uv.lock b/uv.lock index d55ad80d66..e05176eb35 100644 --- a/uv.lock +++ b/uv.lock @@ -246,7 +246,7 @@ wheels = [ [[package]] name = "coldfront" -version = "1.1.6" +version = "1.1.7" source = { editable = "." } dependencies = [ { name = "crispy-bootstrap4" }, @@ -444,16 +444,16 @@ wheels = [ [[package]] name = "django" -version = "4.2.21" +version = "4.2.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/bb/2fad5edc1af2945cb499a2e322ac28e4714fc310bd5201ed1f5a9f73a342/django-4.2.21.tar.gz", hash = "sha256:b54ac28d6aa964fc7c2f7335138a54d78980232011e0cd2231d04eed393dcb0d", size = 10424638 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/20/02242739714eb4e53933d6c0fe2c57f41feb449955b0aa39fc2da82b8f3c/django-4.2.23.tar.gz", hash = "sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4", size = 10448384 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/4f/aeaa3098da18b625ed672f3da6d1cd94e188d1b2cc27c2c841b2f9666282/django-4.2.21-py3-none-any.whl", hash = "sha256:1d658c7bf5d31c7d0cac1cab58bc1f822df89255080fec81909256c30e6180b3", size = 7993839 }, + { url = "https://files.pythonhosted.org/packages/cb/44/314e8e4612bd122dd0424c88b44730af68eafbee88cc887a86586b7a1f2a/django-4.2.23-py3-none-any.whl", hash = "sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803", size = 7993904 }, ] [[package]] @@ -626,11 +626,14 @@ wheels = [ [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, ] [[package]] @@ -770,14 +773,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.6.1" +version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, ] [[package]] @@ -1540,11 +1543,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.13.1" +version = "4.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 }, + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 }, ] [[package]] @@ -1616,9 +1619,9 @@ wheels = [ [[package]] name = "zipp" -version = "3.21.0" +version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, ] From ed76d6a556ddc49e5832882a15cf18433c36c2ba Mon Sep 17 00:00:00 2001 From: Cecilia Lau Date: Tue, 15 Jul 2025 12:40:17 -0400 Subject: [PATCH 041/110] Add allocation attribute edit page Squashed commits: add allocation edit page add indicator for changed allocation attributes add allocation attribute change signals escape js for validation string fix errors and formatting add tests remove confirm and edit buttons when no attributes to edit improve code readability and maintainability Signed-off-by: Cecilia Lau --- coldfront/core/allocation/forms.py | 18 +++ coldfront/core/allocation/signals.py | 3 + ...allocation_allocationattribute_delete.html | 2 +- .../allocation/allocation_attribute_edit.html | 81 +++++++++++++ .../allocation/allocation_detail.html | 6 +- coldfront/core/allocation/tests/test_views.py | 49 ++++++++ coldfront/core/allocation/urls.py | 5 + coldfront/core/allocation/views.py | 107 ++++++++++++++++++ 8 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 coldfront/core/allocation/templates/allocation/allocation_attribute_edit.html diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py index 8068ba43a1..ba3591dd5e 100644 --- a/coldfront/core/allocation/forms.py +++ b/coldfront/core/allocation/forms.py @@ -226,6 +226,24 @@ def clean(self): allocation_attribute.clean() +class AllocationAttributeEditForm(forms.Form): + attribute_pk = forms.IntegerField(required=False, disabled=True) + name = forms.CharField(max_length=150, required=False, disabled=True) + orig_value = forms.CharField(max_length=150, required=False, disabled=True) + value = forms.CharField(max_length=150, required=False, disabled=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["attribute_pk"].widget = forms.HiddenInput() + + def clean(self): + cleaned_data = super().clean() + allocation_attribute = AllocationAttribute.objects.get(pk=cleaned_data.get("attribute_pk")) + + allocation_attribute.value = cleaned_data.get("value") + allocation_attribute.clean() + + class AllocationChangeForm(forms.Form): EXTENSION_CHOICES = [(0, "No Extension")] for choice in ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS: diff --git a/coldfront/core/allocation/signals.py b/coldfront/core/allocation/signals.py index fb70bbc86d..f9926de57c 100644 --- a/coldfront/core/allocation/signals.py +++ b/coldfront/core/allocation/signals.py @@ -21,3 +21,6 @@ allocation_change_created = django.dispatch.Signal() # providing_args=["allocation_pk", "allocation_change_pk"] + +allocation_attribute_changed = django.dispatch.Signal() +# providing_args=["attribute_pk", "allocation_pk"] diff --git a/coldfront/core/allocation/templates/allocation/allocation_allocationattribute_delete.html b/coldfront/core/allocation/templates/allocation/allocation_allocationattribute_delete.html index c3ceb29512..1161af4f74 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_allocationattribute_delete.html +++ b/coldfront/core/allocation/templates/allocation/allocation_allocationattribute_delete.html @@ -54,7 +54,7 @@

Delete allocation attributes from allocation for project: {{allocation.proje Back to Allocation
- No users to remove! + No attributes to remove!
{% endif %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_attribute_edit.html b/coldfront/core/allocation/templates/allocation/allocation_attribute_edit.html new file mode 100644 index 0000000000..1a00dfdbea --- /dev/null +++ b/coldfront/core/allocation/templates/allocation/allocation_attribute_edit.html @@ -0,0 +1,81 @@ +{% extends "common/base.html" %} {% load crispy_forms_tags %} {% load static %} +{% block title %} Allocation Change Detail {% endblock %} {% block content %} + +

+ Edit attributes for {{ allocation.get_parent_resource }} for project: {{ allocation.project.title }} +

+
+
+
+
+

+ Allocation + Attributes +

+
+
+ {% if attributes %} {% csrf_token %} +
+ + + + + + + + + {% for form in formset %} + + + + + {% endfor %} + +
AttributeSet New Value
{{form.name.value}} + {{form.value}} + + + Value changed + +
+
+ {% else %} + + {% endif %} + + {% if attributes %} + + {% endif %} + Cancel + {{ formset.management_form }} +
+
+
+ + +{% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index 369cf81b63..c6802c1612 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -214,6 +214,11 @@

EULA

Allocation Attributes

{% if request.user.is_superuser %} + {% if attributes %} + + Edit Allocation Attributes + + {% endif %} Add Allocation Attribute @@ -299,7 +304,6 @@

Alloc {% else %} {{ change_request.status.name }} {% endif %} - {% if change_request.notes %} {{change_request.notes}} {% else %} diff --git a/coldfront/core/allocation/tests/test_views.py b/coldfront/core/allocation/tests/test_views.py index e01b870c79..4180a31d82 100644 --- a/coldfront/core/allocation/tests/test_views.py +++ b/coldfront/core/allocation/tests/test_views.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import logging +from http import HTTPStatus from django.test import TestCase from django.urls import reverse @@ -193,6 +194,51 @@ def test_allocationchangeview_post_no_change(self): self.assertEqual(len(AllocationChangeRequest.objects.all()), 0) +class AllocationAttributeEditViewTest(AllocationViewBaseTest): + """Tests for AllocationAttributeEditView""" + + def setUp(self): + self.client.force_login(self.admin_user, backend=BACKEND) + self.url = f"/allocation/{self.allocation.pk}/allocationattribute/edit" + self.post_data = { + "attributeform-0-value": self.allocation.get_attribute("Storage Quota (TB)"), + "attributeform-INITIAL_FORMS": "1", + "attributeform-MAX_NUM_FORMS": "1", + "attributeform-MIN_NUM_FORMS": "0", + "attributeform-TOTAL_FORMS": "1", + } + + def test_allocationattributeeditview_access(self): + """Test get request""" + self.allocation_access_tstbase(self.url) + utils.test_user_cannot_access(self, self.pi_user, self.url) + utils.test_user_cannot_access(self, self.allocation_user, self.url) + + def test_allocationattributeeditview_post_change_attr(self): + """Test post request to change attribute""" + quota_orig = 100 + quota_new = 200 + + self.assertEqual(self.allocation.get_attribute("Storage Quota (TB)"), quota_orig) + + self.post_data["attributeform-0-value"] = quota_new + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + + self.assertEqual(self.allocation.get_attribute("Storage Quota (TB)"), quota_new) + + def test_allocationattributeeditview_post_no_change(self): + """Test post request with no change""" + quota_orig = 100 + + self.assertEqual(self.allocation.get_attribute("Storage Quota (TB)"), quota_orig) + + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, HTTPStatus.OK) + + self.assertEqual(self.allocation.get_attribute("Storage Quota (TB)"), quota_orig) + + class AllocationDetailViewTest(AllocationViewBaseTest): """Tests for AllocationDetailView""" @@ -214,12 +260,15 @@ def test_allocationdetail_requestchange_button(self): def test_allocationattribute_button_visibility(self): """Test visibility of "Add Attribute" button for different user types""" # admin + utils.page_contains_for_user(self, self.admin_user, self.url, "Edit Allocation Attribute") utils.page_contains_for_user(self, self.admin_user, self.url, "Add Allocation Attribute") utils.page_contains_for_user(self, self.admin_user, self.url, "Delete Allocation Attribute") # pi + utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Edit Allocation Attribute") utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Add Allocation Attribute") utils.page_does_not_contain_for_user(self, self.pi_user, self.url, "Delete Allocation Attribute") # allocation user + utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Edit Allocation Attribute") utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Add Allocation Attribute") utils.page_does_not_contain_for_user(self, self.allocation_user, self.url, "Delete Allocation Attribute") diff --git a/coldfront/core/allocation/urls.py b/coldfront/core/allocation/urls.py index e9fd08f740..ac269dd413 100644 --- a/coldfront/core/allocation/urls.py +++ b/coldfront/core/allocation/urls.py @@ -32,6 +32,11 @@ name="allocation-attribute-add", ), path("/change-request", allocation_views.AllocationChangeView.as_view(), name="allocation-change"), + path( + "/allocationattribute/edit", + allocation_views.AllocationAttributeEditView.as_view(), + name="allocation-attribute-edit", + ), path( "/allocationattribute/delete", allocation_views.AllocationAttributeDeleteView.as_view(), diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 02c129d267..2956c75fa3 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -30,6 +30,7 @@ AllocationAttributeChangeForm, AllocationAttributeCreateForm, AllocationAttributeDeleteForm, + AllocationAttributeEditForm, AllocationAttributeUpdateForm, AllocationChangeForm, AllocationChangeNoteForm, @@ -58,6 +59,7 @@ from coldfront.core.allocation.signals import ( allocation_activate, allocation_activate_user, + allocation_attribute_changed, allocation_change_approved, allocation_change_created, allocation_disable, @@ -1891,6 +1893,11 @@ def post(self, request, *args, **kwargs): for attribute_change in attribute_change_list: attribute_change.allocation_attribute.value = attribute_change.new_value attribute_change.allocation_attribute.save() + allocation_attribute_changed.send( + sender=self.__class__, + attribute_pk=attribute_change.allocation_attribute.pk, + allocation_pk=attribute_change.allocation.pk, + ) messages.success( request, @@ -2118,6 +2125,106 @@ def post(self, request, *args, **kwargs): return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) +class AllocationAttributeEditView(LoginRequiredMixin, UserPassesTestMixin, FormView): + formset_class = AllocationAttributeEditForm + template_name = "allocation/allocation_attribute_edit.html" + + def test_func(self): + """UserPassesTestMixin Tests""" + user = self.request.user + if user.is_superuser or user.is_staff: + return True + + messages.error(self.request, "You do not have permission to edit this allocation's attributes.") + + return False + + def get_allocation_attributes_to_change(self, allocation_obj): + attributes_to_change = allocation_obj.allocationattribute_set.all() + + attributes_to_change = [ + { + "attribute_pk": attribute.pk, + "name": attribute.allocation_attribute_type.name, + "orig_value": attribute.value, + "value": attribute.value, + } + for attribute in attributes_to_change + ] + + return attributes_to_change + + def get(self, request, *args, **kwargs): + context = {} + allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) + context["allocation"] = allocation_obj + + if not allocation_attributes_to_change: + return render(request, self.template_name, context) + + AllocAttrChangeFormsetFactory = formset_factory( + self.formset_class, + max_num=len(allocation_attributes_to_change), + ) + formset = AllocAttrChangeFormsetFactory( + initial=allocation_attributes_to_change, + prefix="attributeform", + ) + context["formset"] = formset + context["attributes"] = allocation_attributes_to_change + return render(request, self.template_name, context) + + def post(self, request, *args, **kwargs): + attribute_changes_to_make = set() + + pk = self.kwargs.get("pk") + allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) + + ok_redirect = HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + if not allocation_attributes_to_change: + return ok_redirect + + AllocAttrChangeFormsetFactory = formset_factory( + self.formset_class, + max_num=len(allocation_attributes_to_change), + ) + formset = AllocAttrChangeFormsetFactory( + request.POST, + initial=allocation_attributes_to_change, + prefix="attributeform", + ) + if not formset.is_valid(): + attribute_errors = "" + for error in formset.errors: + if error: + attribute_errors += error.get("__all__") + messages.error(request, attribute_errors) + error_redirect = HttpResponseRedirect(reverse("allocation-attribute-edit", kwargs={"pk": pk})) + return error_redirect + + for entry in formset: + formset_data = entry.cleaned_data + value = formset_data.get("value") + + if value != "": + allocation_attribute = AllocationAttribute.objects.get(pk=formset_data.get("attribute_pk")) + if allocation_attribute.value != value: + attribute_changes_to_make.add((allocation_attribute, value)) + + for allocation_attribute, value in attribute_changes_to_make: + allocation_attribute.value = value + allocation_attribute.save() + allocation_attribute_changed.send( + sender=self.__class__, + attribute_pk=allocation_attribute.pk, + allocation_pk=pk, + ) + + return ok_redirect + + class AllocationChangeDeleteAttributeView(LoginRequiredMixin, UserPassesTestMixin, View): login_url = "/" From 2433844f9d4a262726a28ed79de6b5e4275277fb Mon Sep 17 00:00:00 2001 From: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:39:56 -0400 Subject: [PATCH 042/110] Removed '| safe' usages in templates. Signed-off-by: Eric Butcher <107886303+Eric-Butcher@users.noreply.github.com> --- .../templates/allocation/allocation_create.html | 13 +++++++++---- .../templates/allocation/allocation_detail.html | 5 ++++- .../templates/allocation/allocation_renew.html | 5 ++++- coldfront/core/allocation/views.py | 4 ++-- .../portal/templates/portal/allocation_summary.html | 8 ++++++-- .../portal/templates/portal/center_summary.html | 8 ++++++-- .../project/templates/project/project_detail.html | 3 ++- coldfront/core/utils/templatetags/common_tags.py | 3 ++- .../system_monitor/system_monitor_div.html | 6 ++++-- pyproject.toml | 2 +- 10 files changed, 40 insertions(+), 17 deletions(-) diff --git a/coldfront/core/allocation/templates/allocation/allocation_create.html b/coldfront/core/allocation/templates/allocation/allocation_create.html index aba1fef3bf..5a4567b48a 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_create.html +++ b/coldfront/core/allocation/templates/allocation/allocation_create.html @@ -60,11 +60,16 @@

+{{ resources_form_default_quantities|json_script:"resources-form-default-quantities" }} +{{ resources_form_label_texts|json_script:"resources-form-label-texts" }} +{{ resources_with_accounts|json_script:"resources-with-accounts" }} +{{ resources_with_eula|json_script:"resources-with-eula" }} + diff --git a/coldfront/core/portal/templates/portal/center_summary.html b/coldfront/core/portal/templates/portal/center_summary.html index 2de21d120c..adc38e987d 100644 --- a/coldfront/core/portal/templates/portal/center_summary.html +++ b/coldfront/core/portal/templates/portal/center_summary.html @@ -87,6 +87,10 @@

{% settings_value 'CENTER_NAME' %} Scientific Impact

+{{ grants_agency_chart_data|json_script:"grants-agency-chart-data" }} +{{ publication_by_year_bar_chart_data|json_script:"publication-by-year-bar-chart-data" }} + + diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index df2adc82f3..7c164c37d3 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -728,6 +728,23 @@ def dispatch(self, request, *args, **kwargs): else: return super().dispatch(request, *args, **kwargs) + def get_initial_data(self, project_obj): + allocation_objs = project_obj.allocation_set.filter( + resources__is_allocatable=True, + is_locked=False, + status__name__in=["Active", "New", "Renewal Requested", "Payment Pending", "Payment Requested", "Paid"], + ) + return [ + { + "pk": allocation_obj.pk, + "resource": allocation_obj.get_parent_resource.name, + "details": allocation_obj.get_information, + "resource_type": allocation_obj.get_parent_resource.resource_type.name, + "status": allocation_obj.status.name, + } + for allocation_obj in allocation_objs + ] + def post(self, request, *args, **kwargs): user_search_string = request.POST.get("q") search_by = request.POST.get("search_by") @@ -767,9 +784,12 @@ def post(self, request, *args, **kwargs): context["div_allocation_class"] = div_allocation_class ### - allocation_form = ProjectAddUsersToAllocationForm(request.user, project_obj.pk, prefix="allocationform") + initial_data = self.get_initial_data(project_obj) + allocation_formset = formset_factory(ProjectAddUsersToAllocationForm, max_num=len(initial_data)) + allocation_formset = allocation_formset(initial=initial_data, prefix="allocationform") + context["pk"] = pk - context["allocation_form"] = allocation_form + context["allocation_formset"] = allocation_formset return render(request, self.template_name, context) @@ -800,6 +820,23 @@ def dispatch(self, request, *args, **kwargs): else: return super().dispatch(request, *args, **kwargs) + def get_initial_data(self, project_obj): + allocation_objs = project_obj.allocation_set.filter( + resources__is_allocatable=True, + is_locked=False, + status__name__in=["Active", "New", "Renewal Requested", "Payment Pending", "Payment Requested", "Paid"], + ) + return [ + { + "pk": allocation_obj.pk, + "resource": allocation_obj.get_parent_resource.name, + "details": allocation_obj.get_information, + "resource_type": allocation_obj.get_parent_resource.resource_type.name, + "status": allocation_obj.status.name, + } + for allocation_obj in allocation_objs + ] + def post(self, request, *args, **kwargs): user_search_string = request.POST.get("q") search_by = request.POST.get("search_by") @@ -820,20 +857,31 @@ def post(self, request, *args, **kwargs): formset = formset_factory(ProjectAddUserForm, max_num=len(matches)) formset = formset(request.POST, initial=matches, prefix="userform") - allocation_form = ProjectAddUsersToAllocationForm( - request.user, project_obj.pk, request.POST, prefix="allocationform" + initial_data = self.get_initial_data(project_obj) + allocation_formset = formset_factory( + ProjectAddUsersToAllocationForm, + max_num=len(initial_data), + ) + allocation_formset = allocation_formset( + request.POST, + initial=initial_data, + prefix="allocationform", ) added_users_count = 0 - if formset.is_valid() and allocation_form.is_valid(): + if formset.is_valid() and allocation_formset.is_valid(): project_user_active_status_choice = ProjectUserStatusChoice.objects.get(name="Active") allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get(name="Active") if ALLOCATION_EULA_ENABLE: allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get(name="PendingEULA") - allocation_form_data = allocation_form.cleaned_data["allocation"] - if "__select_all__" in allocation_form_data: - allocation_form_data.remove("__select_all__") + allocations_selected_objs = Allocation.objects.filter( + pk__in=[ + allocation_form.cleaned_data.get("pk") + for allocation_form in allocation_formset + if allocation_form.cleaned_data.get("selected") + ] + ) for form in formset: user_form_data = form.cleaned_data if user_form_data["selected"]: @@ -864,7 +912,7 @@ def post(self, request, *args, **kwargs): # project signals project_activate_user.send(sender=self.__class__, project_user_pk=project_user_obj.pk) - for allocation in Allocation.objects.filter(pk__in=allocation_form_data): + for allocation in allocations_selected_objs: has_eula = allocation.get_eula() user_status_choice = allocation_user_active_status_choice if allocation.allocationuser_set.filter(user=user_obj).exists(): @@ -894,8 +942,8 @@ def post(self, request, *args, **kwargs): for error in formset.errors: messages.error(request, error) - if not allocation_form.is_valid(): - for error in allocation_form.errors: + if not allocation_formset.is_valid(): + for error in allocation_formset.errors: messages.error(request, error) return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": pk})) From eaab2369f5436c1c452a37333845858ea042b525 Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Fri, 5 Sep 2025 15:18:42 +0000 Subject: [PATCH 050/110] add local_urls to match local_settings Signed-off-by: Simon Leary --- coldfront/config/urls.py | 20 ++++++++++++++++++++ docs/pages/config.md | 13 +++++++++++++ 2 files changed, 33 insertions(+) diff --git a/coldfront/config/urls.py b/coldfront/config/urls.py index 78d958977a..86bcb0bd95 100644 --- a/coldfront/config/urls.py +++ b/coldfront/config/urls.py @@ -6,6 +6,8 @@ ColdFront URL Configuration """ +import environ +import split_settings from django.conf import settings from django.contrib import admin from django.core import serializers @@ -14,6 +16,7 @@ from django.views.generic import TemplateView import coldfront.core.portal.views as portal_views +from coldfront.config.env import ENV, PROJECT_ROOT admin.site.site_header = "ColdFront Administration" admin.site.site_title = "ColdFront Administration" @@ -60,3 +63,20 @@ def export_as_json(modeladmin, request, queryset): admin.site.add_action(export_as_json, "export_as_json") + +# Local urls overrides +local_urls = [ + # Local urls relative to coldfront.config package + "local_urls.py", + # System wide urls for production deployments + "/etc/coldfront/local_urls.py", + # Local urls relative to coldfront project root + PROJECT_ROOT("local_urls.py"), +] + +if ENV.str("COLDFRONT_URLS", default="") != "": + # Local urls from path specified via environment variable + local_urls.append(environ.Path(ENV.str("COLDFRONT_URLS"))()) + +for lu in local_urls: + split_settings.tools.include(split_settings.tools.optional(lu)) diff --git a/docs/pages/config.md b/docs/pages/config.md index 9649dbe577..53a6f0d529 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -34,6 +34,19 @@ You can also specify the path to `local_settings.py` using the COLDFRONT_CONFIG=/opt/coldfront/mysettings.py ``` +For [URL configurations](https://docs.djangoproject.com/en/dev/topics/http/urls/), you can create a python file to override ColdFront URLs: + +- `local_urls.py` relative to coldfront.config package +- `/etc/coldfront/local_urls.py` +- `local_urls.py` in the ColdFront project root + +You can also specify the path to `local_urls.py` using the +`COLDFRONT_URLS` environment variable. For example: + +``` +COLDFRONT_URLS=/opt/coldfront/myurls.py +``` + ## Simple Example Here's a very simple example demonstrating how to configure ColdFront using From 250a1f04754bdd8ee072322071c4e260a42f39d2 Mon Sep 17 00:00:00 2001 From: Chris Barnett Date: Mon, 15 Sep 2025 13:56:01 -0400 Subject: [PATCH 051/110] cast "total_amount_awarded" to FloatField before Sum applied added simple test to show that center-summary renders Signed-off-by: Chris Barnett --- coldfront/core/portal/tests/test_views.py | 35 +++++++++++++++++++++++ coldfront/core/portal/views.py | 7 +++-- 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 coldfront/core/portal/tests/test_views.py diff --git a/coldfront/core/portal/tests/test_views.py b/coldfront/core/portal/tests/test_views.py new file mode 100644 index 0000000000..8cad2fd30d --- /dev/null +++ b/coldfront/core/portal/tests/test_views.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging + +from django.test import TestCase + +logging.disable(logging.CRITICAL) + + +class PortalViewBaseTest(TestCase): + """Base class for portal view tests.""" + + @classmethod + def setUpTestData(cls): + """Test Data setup for all portal view tests.""" + pass + + +class CenterSummaryViewTest(PortalViewBaseTest): + """Tests for center summary view""" + + @classmethod + def setUpTestData(cls): + """Set up users and project for testing""" + cls.url = "/center-summary" + super(PortalViewBaseTest, cls).setUpTestData() + + def test_centersummary_renders(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Active Allocations and Users") + self.assertContains(response, "Resources and Allocations Summary") + self.assertNotContains(response, "We're having a bit of system trouble at the moment. Please check back soon!") diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py index 7effdf07ea..63a81004e3 100644 --- a/coldfront/core/portal/views.py +++ b/coldfront/core/portal/views.py @@ -7,7 +7,8 @@ from django.conf import settings from django.contrib.humanize.templatetags.humanize import intcomma -from django.db.models import Count, Q, Sum +from django.db.models import Count, FloatField, Q, Sum +from django.db.models.functions import Cast from django.shortcuts import render from django.views.decorators.cache import cache_page @@ -137,7 +138,9 @@ def center_summary(request): # Grants Card total_grants_by_agency_sum = list( - Grant.objects.values("funding_agency__name").annotate(total_amount=Sum("total_amount_awarded")) + Grant.objects.values("funding_agency__name").annotate( + total_amount=Sum(Cast("total_amount_awarded", FloatField())) + ) ) total_grants_by_agency_count = list( From 2e76b9e1b5d0a51fba3931f5fe16873a5bb50783 Mon Sep 17 00:00:00 2001 From: simonLeary42 <71396965+simonLeary42@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:09:36 -0400 Subject: [PATCH 052/110] Fix exception handling in OpenLDAP sync command Signed-off-by: Simon Leary --- .../management/commands/project_openldap_sync.py | 1 + 1 file changed, 1 insertion(+) diff --git a/coldfront/plugins/project_openldap/management/commands/project_openldap_sync.py b/coldfront/plugins/project_openldap/management/commands/project_openldap_sync.py index ef2d83e026..0b69d98c30 100644 --- a/coldfront/plugins/project_openldap/management/commands/project_openldap_sync.py +++ b/coldfront/plugins/project_openldap/management/commands/project_openldap_sync.py @@ -144,6 +144,7 @@ def handle_missing_project_in_openldap_archive(self, project, project_dn, sync=F self.stdout.write( f"Exception creating vars for OpenLDAP archive action for Project {project.project_code}: {e}" ) + return # if sync not permitted, notify, DN is passed to function here if not sync: From 81a61bb56e470a77ed7597b6124c349fba114e94 Mon Sep 17 00:00:00 2001 From: David Simpson <> Date: Mon, 8 Sep 2025 12:55:12 +0100 Subject: [PATCH 053/110] Adds accelerator hours, slurm attrs, slurm account and fairshare naming control Signed-off-by: David Simpson --- .../config/plugins/auto_compute_allocation.py | 17 ++++ .../plugins/auto_compute_allocation/README.md | 69 +++++++++++-- .../fairshare_institution_name.py | 41 ++++++++ .../slurm_account_name.py | 56 +++++++++++ .../plugins/auto_compute_allocation/tasks.py | 96 +++++++++++++++---- .../plugins/auto_compute_allocation/utils.py | 6 +- 6 files changed, 255 insertions(+), 30 deletions(-) create mode 100644 coldfront/plugins/auto_compute_allocation/fairshare_institution_name.py create mode 100644 coldfront/plugins/auto_compute_allocation/slurm_account_name.py diff --git a/coldfront/config/plugins/auto_compute_allocation.py b/coldfront/config/plugins/auto_compute_allocation.py index 9e3a153659..38b42ac536 100644 --- a/coldfront/config/plugins/auto_compute_allocation.py +++ b/coldfront/config/plugins/auto_compute_allocation.py @@ -9,6 +9,10 @@ "coldfront.plugins.auto_compute_allocation", ] +AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS = ENV.int("AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS", default=0) +AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING = ENV.int( + "AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING", default=0 +) AUTO_COMPUTE_ALLOCATION_CORE_HOURS = ENV.int("AUTO_COMPUTE_ALLOCATION_CORE_HOURS", default=0) AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING = ENV.int("AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING", default=0) AUTO_COMPUTE_ALLOCATION_END_DELTA = ENV.int("AUTO_COMPUTE_ALLOCATION_END_DELTA", default=365) @@ -18,3 +22,16 @@ AUTO_COMPUTE_ALLOCATION_CLUSTERS = ENV.tuple("AUTO_COMPUTE_ALLOCATION_CLUSTERS", default=()) # example auto|Cluster| results in auto|Cluster|CDF0001 where CDF0001 is an example project_code for pk=1 AUTO_COMPUTE_ALLOCATION_DESCRIPTION = ENV.str("AUTO_COMPUTE_ALLOCATION_DESCRIPTION", default="auto|Cluster|") +# Tuple for slurm_specs attribute - a tuple is required with each element a slurm attr e.g. AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE=('GrpTRESMins=cpu=999;mem=999;gres/gpu=999',) +# NOTE: the semi-colon is required [as an internal delimiter] instead of comma, this gets converted to comma +AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE = ENV.tuple("AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE", default=()) +# Tuple same format as above, see note +AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING = ENV.tuple( + "AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING", default=() +) +AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT = ENV.str( + "AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT", default="" +) +AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT = ENV.str( + "AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT", default="" +) diff --git a/coldfront/plugins/auto_compute_allocation/README.md b/coldfront/plugins/auto_compute_allocation/README.md index 72a2c4bd05..d22c44b154 100644 --- a/coldfront/plugins/auto_compute_allocation/README.md +++ b/coldfront/plugins/auto_compute_allocation/README.md @@ -24,11 +24,24 @@ Further down in the documentation, all variables are described in a table. As well as controlling the end date of the generated allocation, the changeable and locked attributes can be toggled. -Optionally core hours can be assigned to new projects with ``AUTO_COMPUTE_ALLOCATION_CORE_HOURS`` and specifically training projects with ``AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING``. These are activated by providing an integer greater than 0. +Optionally **gauges** for accelerator and core hours can be assigned to new projects by providing an integer greater than 0. + +- ``AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS`` +- ``AUTO_COMPUTE_ALLOCATION_CORE_HOURS`` + +...specifically for training (field of science = training) projects with: + +- ``AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING`` +- ``AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING`` + A variable can be used to filter which Cluster resources the allocation can work with ``AUTO_COMPUTE_ALLOCATION_CLUSTERS``. -Finally an optional usage, is to enable an institute based fairshare attribute. This requires the _institution feature_ has been enabled correctly, such that a match is found (for the submitting PI). If a match isn't found then this attribute can't be set and the code handles. +An optional usage, is to enable an **institution based fairshare attribute** - ``AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION``. This requires the _institution feature_ has been enabled correctly, such that a match is found (for the submitting PI). If a match isn't found then this attribute can't be set and the code handles. + +The **slurm account can be named** and its naming controlled via ``AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT``. Similarly ``AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT`` gives some control over the output of the **institutional fairshare naming/value**. + +**slurm_attributes can be added** with ``AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE`` and ``AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING``. ### Design - signals and actions @@ -86,21 +99,59 @@ All variables for this plugin are currently **optional**. | Option | Type | Default | Description | |--- | --- | --- | --- | -| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS` | int | 0 | Optional, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added | -| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING` | int | 0 | Optional, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added. This applies to projects which select 'Training' as their field of science discipline. | +| `AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS` | int | 0 | Optional, **not a slurm attribute, enables guage**, number of accelerator hours to provide on the allocation, if 0 then this functionality is not triggered and no accelerator hours will be added | +| `AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING` | int | 0 | Optional, **not a slurm attribute, enables guage**, number of accelerator hours to provide on the allocation, if 0 then this functionality is not triggered and no accelerator hours will be added. This applies to projects which select 'Training' as their field of science discipline. | +| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS` | int | 0 | Optional, **not a slurm attribute, enables guage**, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added | +| `AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING` | int | 0 | Optional, **not a slurm attribute, enables guage**, number of core hours to provide on the allocation, if 0 then this functionality is not triggered and no core hours will be added. This applies to projects which select 'Training' as their field of science discipline. | | `AUTO_COMPUTE_ALLOCATION_END_DELTA` | int | 365 | Optional, number of days from creation of the allocation to expiry, default 365 to align with default project duration of 1 year | | `AUTO_COMPUTE_ALLOCATION_CHANGEABLE` | bool | True | Optional, allows the allocation to have a request logged to change - this might be useful for an extension | | `AUTO_COMPUTE_ALLOCATION_LOCKED` | bool | False | Optional, prevents the allocation from being modified by admin - this might be useful for an extensions | | `AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION` | bool | False | Optional, provides an institution based slurm fairshare attribute, requires that the _institution feature_ is setup correctly | | `AUTO_COMPUTE_ALLOCATION_CLUSTERS` | tuple | empty () | Optional, filter for clusters to automatically allocate on - example value ``AUTO_COMPUTE_ALLOCATION_CLUSTERS=(Cluster1,Cluster4)`` | -| ``AUTO_COMPUTE_ALLOCATION_DESCRIPTION`` | str | "auto\|Cluster\|" | Optionally control the produced description for the allocation and its delimiters within. The _project_code_ will always be appended. Example resultant description: ``auto\|Cluster\|CDF0001`` | +| `AUTO_COMPUTE_ALLOCATION_DESCRIPTION` | str | "auto\|Cluster\|" | Optionally control the produced description for the allocation and its delimiters within. The _project_code_ will always be appended. Example resultant description: ``auto\|Cluster\|CDF0001`` | +| `AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE` | tuple | empty () | Optional, a tuple of slurm_attributes to add to the allocation. **Note each element needs an internal delimiter of a semi-colon `;` rather than a comma, if a comma is present in your intended element string**.

An example is `AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE=('GrpTRESMins=cpu=999;mem=999;gres/gpu=999',)` which defines a single element tuple and therefore 1x slurm attribute. More could be added. Note the internal semi-colon `;` delimiter instead of a comma with the string. | +| `AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING` | tuple | empty () | Optional, a tuple of slurm_attributes to add to the allocation for a training project. **Note each element needs an internal delimiter of a semi-colon `;` rather than a comma, if a comma is present in your intended element string**.

An example is `AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING=('GrpTRESMins=cpu=999;mem=999;gres/gpu=999',)` which defines a single element tuple and therefore 1x slurm attribute. More could be added. Note the internal semi-colon `;` delimiter instead of a comma with the string. | +| `AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT` | str | empty "" | Optional, variable to define how the fairshare attribute will be named.

**If not defined then the default format `{institution}` will be used**.| +| `AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT` | str | empty "" | Optional, variable to define how the slurm_account_name attribute will be named.

**If not defined then the default format `{project_code}_{PI_First_Initial}_{PI_Last_Name_Formatted}_{allocation_id}` will be used**.

If you just want `project_code` then use `AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT="{project_code}"`.| + + +#### AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT - detail + +This table shows the possible values that can be used for the ENV var ``AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT`` string. + +| AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT | value | comment | +|--- | --- | --- | +| | `allocation_id` | the allocation id - useful as will be a distinct number (pk) - this will not necessarily be in sequence but is unique | +| | `institution_abbr_upper_lower` | the institution's capital letters extracted and joined as lowercase - to make an abbreviation | +| | `institution_abbr_upper_upper` | the institution's capital letters extracted and joined as uppercase - to make an abbreviation | +| | `institution` | institution with spaces converted to '_' | +| | `institution_formatted` | institution lowercase with spaces converted to '_' | +| | `PI_first_initial` | PI last name initial lowercase| +| | `PI_first_name` | PI first name lowercase | +| | `PI_last_initial` | PI first initial lowercase | +| | `PI_last_name_formatted` | PI last name lowercase with spaces converted to '_' | +| | `PI_last_name` | PI last name lowercase | +| | `project_code` | the project_code | +| | `project_id` | the project_id (pk) wont be distinct within multiple allocations in the same project | + + +#### AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT - detail + +This table shows the possible values that can be used for the ENV var ``AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT`` string. + +| AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT | value | comment | +|--- | --- | --- | +| | `institution_abbr_upper_lower` | the institution's capital letters extracted and joined as lowercase - to make an abbreviation | +| | `institution_abbr_upper_upper` | the institution's capital letters extracted and joined as uppercase - to make an abbreviation | +| | `institution` | institution with spaces converted to '_' | +| | `institution_formatted` | institution lowercase with spaces converted to '_' | ## Example settings -- 10k core hours for new project -- 100 core hours for a new training project +- show a gauge for 10k core hours for new project +- show a gauge for 100 core hours for a new training project - default 365 end delta - no need to set variable ``` @@ -113,5 +164,5 @@ AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING=100 Future work could include: -- accelerator allocation -- some more specific logic to map to partitions +- slurm parent accounts +- a seperate plugin for storage allocations - ``auto_storage_allocation`` diff --git a/coldfront/plugins/auto_compute_allocation/fairshare_institution_name.py b/coldfront/plugins/auto_compute_allocation/fairshare_institution_name.py new file mode 100644 index 0000000000..899e752d90 --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/fairshare_institution_name.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coldfront auto_compute_allocation plugin fairshare_institution_name.py""" + +import logging + +from coldfront.core.utils.common import import_from_settings + +AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT = import_from_settings( + "AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT" +) + +logger = logging.getLogger(__name__) + + +def generate_fairshare_institution_name(project_obj): + """Method to generate a fairshare_institution_name using predefined variables""" + + # Get the uppercase characters from institution + institution_abbr_raw = "".join([c for c in project_obj.institution if c.isupper()]) + + FAIRSHARE_INSTITUTION_NAME_VARS = { + "institution_abbr_upper_lower": institution_abbr_raw.lower(), + "institution_abbr_upper_upper": institution_abbr_raw, + "institution": project_obj.institution.replace(" ", ""), + "institution_formatted": project_obj.institution.replace(" ", "_").lower(), + } + + # if a faishare institution name format is defined as a string (use env var), else use format suggested + if AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT: + gen_fairshare_institution_name = AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT.format( + **FAIRSHARE_INSTITUTION_NAME_VARS + ) + else: + gen_fairshare_institution_name = "{institution}".format(**FAIRSHARE_INSTITUTION_NAME_VARS) + + logger.info(f"Generated fairshare institution name {gen_fairshare_institution_name}") + + return gen_fairshare_institution_name diff --git a/coldfront/plugins/auto_compute_allocation/slurm_account_name.py b/coldfront/plugins/auto_compute_allocation/slurm_account_name.py new file mode 100644 index 0000000000..b5cf0c93f3 --- /dev/null +++ b/coldfront/plugins/auto_compute_allocation/slurm_account_name.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Coldfront auto_compute_allocation plugin slurm_account_name.py""" + +import logging + +from coldfront.core.utils.common import import_from_settings + +AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT = import_from_settings( + "AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT" +) + +logger = logging.getLogger(__name__) + + +def generate_slurm_account_name(allocation_obj, project_obj): + """Method to generate a slurm_account_name using predefined variables""" + + # define valid vars for naming slurm account in dictionary + SLURM_ACCOUNT_NAME_VARS = { + "allocation_id": allocation_obj.pk, + "PI_first_initial": project_obj.pi.first_name[0].lower(), + "PI_first_name": project_obj.pi.first_name.lower(), + "PI_last_initial": project_obj.pi.last_name[0].lower(), + "PI_last_name_formatted": project_obj.pi.last_name.replace(" ", "_").lower(), + "PI_last_name": project_obj.pi.last_name.lower(), + "project_code": project_obj.project_code, + "project_id": project_obj.pk, + } + + if hasattr(project_obj, "institution"): + # Get the uppercase characters from institution + institution_abbr_raw = "".join([c for c in project_obj.institution if c.isupper()]) + + SLURM_ACCOUNT_NAME_VARS.update( + { + "institution_abbr_upper_lower": institution_abbr_raw.lower(), + "institution_abbr_upper_upper": institution_abbr_raw, + "institution": project_obj.institution.replace(" ", "_"), + "institution_formatted": project_obj.institution.replace(" ", "_").lower(), + } + ) + + # if a slurm account name format is defined as a string (use env var), else use format suggested + if AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT: + gen_slurm_account_name = AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT.format(**SLURM_ACCOUNT_NAME_VARS) + else: + gen_slurm_account_name = "{project_code}_{PI_first_initial}_{PI_last_name_formatted}_{allocation_id}".format( + **SLURM_ACCOUNT_NAME_VARS + ) + + logger.info(f"Generated slurm account name {gen_slurm_account_name}") + + return gen_slurm_account_name diff --git a/coldfront/plugins/auto_compute_allocation/tasks.py b/coldfront/plugins/auto_compute_allocation/tasks.py index 03a789d3e0..f79340b11b 100644 --- a/coldfront/plugins/auto_compute_allocation/tasks.py +++ b/coldfront/plugins/auto_compute_allocation/tasks.py @@ -8,10 +8,11 @@ from coldfront.core.allocation.models import AllocationAttributeType from coldfront.core.utils.common import import_from_settings +from coldfront.plugins.auto_compute_allocation.slurm_account_name import generate_slurm_account_name from coldfront.plugins.auto_compute_allocation.utils import ( allocation_auto_compute, allocation_auto_compute_attribute_create, - allocation_auto_compute_fairshare_institute, + allocation_auto_compute_fairshare_institution, allocation_auto_compute_pi, get_cluster_resources_tuple, ) @@ -19,9 +20,17 @@ logger = logging.getLogger(__name__) # Environment variables for auto_compute_allocation in tasks.py +AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS = import_from_settings("AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS") +AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING = import_from_settings( + "AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING" +) AUTO_COMPUTE_ALLOCATION_CORE_HOURS = import_from_settings("AUTO_COMPUTE_ALLOCATION_CORE_HOURS") AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING = import_from_settings("AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING") AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION = import_from_settings("AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION") +AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE = import_from_settings("AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE") +AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING = import_from_settings( + "AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING" +) # automatically create a compute allocation, called by project_new signal @@ -47,10 +56,19 @@ def add_auto_compute_allocation(project_obj): project_code = project_obj.project_code auto_allocation_clusters = get_cluster_resources_tuple() + if len(auto_allocation_clusters) == 0: + raise Exception("No auto_allocation_clusters found - no resources of type Cluster configured!") + + # accelerator hours + allocation_attribute_type_obj_accelerator_hours = AllocationAttributeType.objects.get( + name="Accelerator Usage (Hours)" + ) # core hours allocation_attribute_type_obj_core_hours = AllocationAttributeType.objects.get(name="Core Usage (Hours)") # slurm account name allocation_attribute_type_obj_slurm_account_name = AllocationAttributeType.objects.get(name="slurm_account_name") + # slurm specs + allocation_attribute_type_obj_slurm_specs = AllocationAttributeType.objects.get(name="slurm_specs") # slurm user specs allocation_attribute_type_obj_slurm_user_specs = AllocationAttributeType.objects.get(name="slurm_user_specs") @@ -73,12 +91,15 @@ def add_auto_compute_allocation(project_obj): except Exception as e: logger.error("Failed to add PI to auto_compute_allocation: %s", e) + # get the format of the slurm account name + local_slurm_account_name = generate_slurm_account_name(allocation_obj, project_obj) + try: # add the slurm account name allocation_auto_compute_attribute_create( allocation_attribute_type_obj_slurm_account_name, allocation_obj, - project_code, + local_slurm_account_name, ) except Exception as e: logger.error("Failed to add slurm account name to auto_compute_allocation: %s", e) @@ -95,22 +116,59 @@ def add_auto_compute_allocation(project_obj): except Exception as e: logger.error("Failed to add fairshare value to auto_compute_allocation: %s", e) - # add core hours non-training project - if AUTO_COMPUTE_ALLOCATION_CORE_HOURS > 0 and project_obj.field_of_science.description != "Training": - core_hours_quantity = AUTO_COMPUTE_ALLOCATION_CORE_HOURS - allocation_auto_compute_attribute_create( - allocation_attribute_type_obj_core_hours, - allocation_obj, - core_hours_quantity, - ) - # add core hours training project - elif AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING > 0 and project_obj.field_of_science.description == "Training": - core_hours_quantity = AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING - allocation_auto_compute_attribute_create( - allocation_attribute_type_obj_core_hours, - allocation_obj, - core_hours_quantity, - ) + if project_obj.field_of_science.description != "Training": + # 1a) add accelerator hours non-training project - for gauge to appear + if AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS > 0: + accelerator_hours_quantity = AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_accelerator_hours, + allocation_obj, + accelerator_hours_quantity, + ) + # 1b) add core hours non-training project - for gauge to appear + if AUTO_COMPUTE_ALLOCATION_CORE_HOURS > 0: + core_hours_quantity = AUTO_COMPUTE_ALLOCATION_CORE_HOURS + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_core_hours, + allocation_obj, + core_hours_quantity, + ) + # 1c) add slurm attrs non-training project + if len(AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE) > 0: + for slurm_attr in AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE: + new_slurm_attr = slurm_attr.replace(";", ",").replace("'", "") + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_slurm_specs, + allocation_obj, + new_slurm_attr, + ) + + if project_obj.field_of_science.description == "Training": + # 2a) add accelerator hours training project - for gauge to appear + if AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING > 0: + accelerator_hours_quantity = AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_accelerator_hours, + allocation_obj, + accelerator_hours_quantity, + ) + # 2b) add core hours training project - for gauge to appear + if AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING > 0: + core_hours_quantity = AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_core_hours, + allocation_obj, + core_hours_quantity, + ) + # 2c) add slurm attrs training project + if len(AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING) > 0: + for slurm_attr in AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING: + new_slurm_attr = slurm_attr.replace(";", ",").replace("'", "") + allocation_auto_compute_attribute_create( + allocation_attribute_type_obj_slurm_specs, + allocation_obj, + new_slurm_attr, + ) if AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION: - allocation_auto_compute_fairshare_institute(project_obj, allocation_obj) + allocation_auto_compute_fairshare_institution(project_obj, allocation_obj) diff --git a/coldfront/plugins/auto_compute_allocation/utils.py b/coldfront/plugins/auto_compute_allocation/utils.py index 653cf97117..f6a71414d4 100644 --- a/coldfront/plugins/auto_compute_allocation/utils.py +++ b/coldfront/plugins/auto_compute_allocation/utils.py @@ -17,6 +17,7 @@ ) from coldfront.core.resource.models import Resource, ResourceType from coldfront.core.utils.common import import_from_settings +from coldfront.plugins.auto_compute_allocation.fairshare_institution_name import generate_fairshare_institution_name # Environment variables for auto_compute_allocation in utils.py AUTO_COMPUTE_ALLOCATION_END_DELTA = import_from_settings("AUTO_COMPUTE_ALLOCATION_END_DELTA") @@ -94,7 +95,7 @@ def allocation_auto_compute_attribute_create(allocation_attribute_type_obj, allo return allocation_attribute_obj -def allocation_auto_compute_fairshare_institute(project_obj, allocation_obj): +def allocation_auto_compute_fairshare_institution(project_obj, allocation_obj): """method to add an institutional fair share value for slurm association - slurm specs""" if not hasattr(project_obj, "institution"): logger.info("Enable institution feature to set per institution fairshare in the auto_compute_allocation plugin") @@ -114,7 +115,8 @@ def allocation_auto_compute_fairshare_institute(project_obj, allocation_obj): ) return None - fairshare_institution = project_obj.institution + # get the format for the fairshare institution + fairshare_institution = generate_fairshare_institution_name(project_obj) allocation_attribute_type_obj = AllocationAttributeType.objects.get(name="slurm_specs") fairshare_value = f"Fairshare={fairshare_institution}" From 6f09a38aa155e3df3e8d0037b2ad376be0f9a1d9 Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Tue, 7 Oct 2025 16:16:31 +0000 Subject: [PATCH 054/110] remove "Inactive (Renewed)" status Signed-off-by: Simon Leary --- coldfront/plugins/freeipa/tasks.py | 1 - docs/pages/howto/allocations/statuses.md | 7 ------- 2 files changed, 8 deletions(-) diff --git a/coldfront/plugins/freeipa/tasks.py b/coldfront/plugins/freeipa/tasks.py index ad85cd06a3..0af4897ce6 100644 --- a/coldfront/plugins/freeipa/tasks.py +++ b/coldfront/plugins/freeipa/tasks.py @@ -64,7 +64,6 @@ def remove_user_group(allocation_user_pk): if allocation_user.allocation.status.name not in [ "Active", "Pending", - "Inactive (Renewed)", ]: logger.warning("Allocation is not active or pending. Will not remove groups.") return diff --git a/docs/pages/howto/allocations/statuses.md b/docs/pages/howto/allocations/statuses.md index 3e68255839..36ce68e340 100644 --- a/docs/pages/howto/allocations/statuses.md +++ b/docs/pages/howto/allocations/statuses.md @@ -28,13 +28,6 @@ When an allocation is in 'expired' status: - It's important to point out that unless these plugins are run to properly remove access, or the center is using some other mechanism for granting access to a resource, the allocation users' access will still be active on the systems, despite this allocation status being 'expired' - Emails are sent to all allocation users letting them know the allocation has expired - -#### Inactive (Renewed) -- When an allocation is renewed, a new allocation is created and the original allocation is set to this status -- Changes can not be made to this allocation -- It remains for historical purposes - - #### New - This is the status an allocation is placed in when first created - An email gets sent to 'EMAIL_TICKET_SYSTEM_ADDRESS' configured in coldfront.env From 9f2a9ff6837d08cd398d8e9f370245306be4728f Mon Sep 17 00:00:00 2001 From: simonLeary42 <71396965+simonLeary42@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:56:11 -0400 Subject: [PATCH 055/110] project_openldap: fix log message --- coldfront/plugins/project_openldap/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coldfront/plugins/project_openldap/tasks.py b/coldfront/plugins/project_openldap/tasks.py index 66f9350d11..18959f6cbd 100644 --- a/coldfront/plugins/project_openldap/tasks.py +++ b/coldfront/plugins/project_openldap/tasks.py @@ -73,7 +73,7 @@ def add_project(project_obj): logger.info("Adding OpenLDAP project posixgroup entry - DN: %s", posixgroup_dn) logger.info("Adding OpenLDAP project posixgroup entry - GID: %s", gid_int) logger.info( - "Adding OpenLDAP project posixgroup entry - GID: %s", + "Adding OpenLDAP project posixgroup entry - description: %s", openldap_posixgroup_description, ) From 75d792773f9a6a57c1a56c35e422cd118f3c1524 Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Thu, 2 Oct 2025 17:48:54 +0000 Subject: [PATCH 056/110] rename functions Signed-off-by: Simon Leary --- .../commands/project_openldap_check_setup.py | 6 +-- .../commands/project_openldap_sync.py | 38 +++++++++---------- coldfront/plugins/project_openldap/tasks.py | 20 +++++----- coldfront/plugins/project_openldap/utils.py | 28 +++++++------- 4 files changed, 45 insertions(+), 47 deletions(-) diff --git a/coldfront/plugins/project_openldap/management/commands/project_openldap_check_setup.py b/coldfront/plugins/project_openldap/management/commands/project_openldap_check_setup.py index 6d70857d2d..4d499081c1 100644 --- a/coldfront/plugins/project_openldap/management/commands/project_openldap_check_setup.py +++ b/coldfront/plugins/project_openldap/management/commands/project_openldap_check_setup.py @@ -11,7 +11,7 @@ from coldfront.core.utils.common import import_from_settings from coldfront.plugins.project_openldap.utils import ( PROJECT_OPENLDAP_BIND_USER, - ldapsearch_check_project_ou, + ldapsearch_check_ou, ) """ Coldfront project_openldap plugin - django management command - project_openldap_check_setup.py """ @@ -160,7 +160,7 @@ def check_setup_ldapsearch(self): self.stdout.write(self.style.SUCCESS(f" {PROJECT_OPENLDAP_OU} is set to {PROJECT_OPENLDAP_OU}")) self.stdout.write(self.style.SUCCESS(" ldapsearch...")) try: - ldapsearch_check_project_ou_result = ldapsearch_check_project_ou(PROJECT_OPENLDAP_OU) + ldapsearch_check_project_ou_result = ldapsearch_check_ou(PROJECT_OPENLDAP_OU) if ldapsearch_check_project_ou_result and not isinstance(ldapsearch_check_project_ou_result, Exception): self.stdout.write( self.style.SUCCESS( @@ -186,7 +186,7 @@ def check_setup_ldapsearch(self): ) self.stdout.write(self.style.SUCCESS(" ldapsearch...")) try: - ldapsearch_check_project_ou_result = ldapsearch_check_project_ou(PROJECT_OPENLDAP_ARCHIVE_OU) + ldapsearch_check_project_ou_result = ldapsearch_check_ou(PROJECT_OPENLDAP_ARCHIVE_OU) if ldapsearch_check_project_ou_result and not isinstance(ldapsearch_check_project_ou_result, Exception): self.stdout.write( self.style.SUCCESS( diff --git a/coldfront/plugins/project_openldap/management/commands/project_openldap_sync.py b/coldfront/plugins/project_openldap/management/commands/project_openldap_sync.py index ef2d83e026..70cfba319d 100644 --- a/coldfront/plugins/project_openldap/management/commands/project_openldap_sync.py +++ b/coldfront/plugins/project_openldap/management/commands/project_openldap_sync.py @@ -24,11 +24,10 @@ # this script relies HEAVILY on utils.py from coldfront.plugins.project_openldap.utils import ( - add_members_to_openldap_project_posixgroup, + add_members_to_openldap_posixgroup, add_per_project_ou_to_openldap, - add_project_posixgroup_to_openldap, + add_posixgroup_to_openldap, allocate_project_openldap_gid, - archive_project_in_openldap, construct_dn_archived_str, construct_dn_str, construct_ou_archived_dn_str, @@ -37,10 +36,11 @@ construct_project_ou_description, construct_project_posixgroup_description, ldapsearch_check_project_dn, - ldapsearch_get_project_description, - ldapsearch_get_project_memberuids, - remove_members_from_openldap_project_posixgroup, - update_project_posixgroup_in_openldap, + ldapsearch_get_description, + ldapsearch_get_posixgroup_memberuids, + move_dn_in_openldap, + remove_members_from_openldap_posixgroup, + update_posixgroup_description_in_openldap, ) # NOTE: functions starting with 'local_' or 'handle_' are local to this script @@ -170,7 +170,7 @@ def handle_missing_project_in_openldap_archive(self, project, project_dn, sync=F # create posixgroup self.stdout.write(f"Adding OpenLDAP project archive posixgroup entry - DN: {archive_posixgroup_dn}") - add_project_posixgroup_to_openldap( + add_posixgroup_to_openldap( archive_posixgroup_dn, archive_openldap_posixgroup_description, archive_gid, @@ -200,7 +200,7 @@ def handle_project_in_openldap_but_not_archive( # current_dn (ou_dn), relative_dn, ARCHIVE_OU need supplied - where relative_dn is the project's own ou try: relative_dn = construct_per_project_ou_relative_dn_str(project) - archive_project_in_openldap(project_ou_dn, relative_dn, PROJECT_OPENLDAP_ARCHIVE_OU, write=True) + move_dn_in_openldap(project_ou_dn, relative_dn, PROJECT_OPENLDAP_ARCHIVE_OU, write=True) self.stdout.write( f"Moving project to archive OU, DN: {archive_dn} in OpenLDAP - SYNC is {sync} - WRITING TO Openldap" ) @@ -247,12 +247,12 @@ def handle_description_update( PROJECT_STATUS_CHOICE_ACTIVE, ]: # fetch current description from project_dn - fetched_description = ldapsearch_get_project_description(project_dn) + fetched_description = ldapsearch_get_description(project_dn) if new_description == fetched_description: self.stdout.write("Description is up-to-date.") if new_description != fetched_description: if sync: - update_project_posixgroup_in_openldap(project_dn, new_description, write=True) + update_posixgroup_description_in_openldap(project_dn, new_description, write=True) self.stdout.write(f"{new_description}") else: # line up description output @@ -262,7 +262,7 @@ def handle_description_update( if project.status_id in [PROJECT_STATUS_CHOICE_ARCHIVED]: # fetch current description from archive DN - fetched_description = ldapsearch_get_project_description(archive_dn) + fetched_description = ldapsearch_get_description(archive_dn) if new_description == fetched_description: self.stdout.write("Description is up-to-date.") if new_description != fetched_description: @@ -277,7 +277,7 @@ def handle_description_update( "WRITE_TO_ARCHIVE is required to make changes, please supply: -z or --writearchive" ) if sync and write_to_archive: - update_project_posixgroup_in_openldap(archive_dn, new_description, write=True) + update_posixgroup_description_in_openldap(archive_dn, new_description, write=True) self.stdout.write(f"{new_description}") # get active users from the coldfront django project @@ -289,7 +289,7 @@ def local_get_cf_django_members(self, project_pk): return tuple(usernames) def local_get_openldap_members(self, dn): - entries = ldapsearch_get_project_memberuids(dn) + entries = ldapsearch_get_posixgroup_memberuids(dn) if entries is None: return @@ -348,7 +348,7 @@ def sync_members( if sync: if ldapsearch_project_result: try: - remove_members_from_openldap_project_posixgroup(member_change_dn, missing_in_cf, write=True) + remove_members_from_openldap_posixgroup(member_change_dn, missing_in_cf, write=True) self.stdout.write(f"SYNC {sync} - Removed members {missing_in_cf}") except Exception as e: self.stdout.write( @@ -361,7 +361,7 @@ def sync_members( ) elif write_to_archive: try: - remove_members_from_openldap_project_posixgroup(member_change_dn, missing_in_cf, write=True) + remove_members_from_openldap_posixgroup(member_change_dn, missing_in_cf, write=True) self.stdout.write(f"SYNC {sync} - Removed members {missing_in_cf}") except Exception as e: self.stdout.write( @@ -377,7 +377,7 @@ def sync_members( if sync: if ldapsearch_project_result: try: - add_members_to_openldap_project_posixgroup(member_change_dn, missing_in_openldap, write=True) + add_members_to_openldap_posixgroup(member_change_dn, missing_in_openldap, write=True) self.stdout.write(f"SYNC {sync} - Added members {missing_in_openldap}") except Exception as e: self.stdout.write( @@ -390,9 +390,7 @@ def sync_members( ) elif write_to_archive: try: - add_members_to_openldap_project_posixgroup( - member_change_dn, missing_in_openldap, write=True - ) + add_members_to_openldap_posixgroup(member_change_dn, missing_in_openldap, write=True) self.stdout.write(f"SYNC {sync} - Added members {missing_in_openldap}") except Exception as e: self.stdout.write( diff --git a/coldfront/plugins/project_openldap/tasks.py b/coldfront/plugins/project_openldap/tasks.py index 66f9350d11..01e782fc9a 100644 --- a/coldfront/plugins/project_openldap/tasks.py +++ b/coldfront/plugins/project_openldap/tasks.py @@ -9,19 +9,19 @@ from coldfront.core.project.models import ProjectUser from coldfront.core.utils.common import import_from_settings from coldfront.plugins.project_openldap.utils import ( - add_members_to_openldap_project_posixgroup, + add_members_to_openldap_posixgroup, add_per_project_ou_to_openldap, - add_project_posixgroup_to_openldap, + add_posixgroup_to_openldap, allocate_project_openldap_gid, - archive_project_in_openldap, construct_dn_str, construct_ou_dn_str, construct_per_project_ou_relative_dn_str, construct_project_ou_description, construct_project_posixgroup_description, + move_dn_in_openldap, remove_dn_from_openldap, - remove_members_from_openldap_project_posixgroup, - update_project_posixgroup_in_openldap, + remove_members_from_openldap_posixgroup, + update_posixgroup_description_in_openldap, ) # Setup logging @@ -77,7 +77,7 @@ def add_project(project_obj): openldap_posixgroup_description, ) - add_project_posixgroup_to_openldap(posixgroup_dn, openldap_posixgroup_description, gid_int) + add_posixgroup_to_openldap(posixgroup_dn, openldap_posixgroup_description, gid_int) # Coldfront archive project action @@ -99,7 +99,7 @@ def remove_project(project_obj): else: relative_dn = construct_per_project_ou_relative_dn_str(project_obj) logger.info(f"Project OU {ou_dn} is going to be ARCHIVED in OpenLDAP at {PROJECT_OPENLDAP_ARCHIVE_OU}...") - archive_project_in_openldap(ou_dn, relative_dn, PROJECT_OPENLDAP_ARCHIVE_OU) + move_dn_in_openldap(ou_dn, relative_dn, PROJECT_OPENLDAP_ARCHIVE_OU) def update_project(project_obj): @@ -110,7 +110,7 @@ def update_project(project_obj): logger.info("Modifying OpenLDAP entry: %s", dn) logger.info("Modifying OpenLDAP with description: %s", openldap_description) - update_project_posixgroup_in_openldap(dn, openldap_description) + update_posixgroup_description_in_openldap(dn, openldap_description) def add_user_project(project_user_pk): @@ -126,7 +126,7 @@ def add_user_project(project_user_pk): list_memberuids = [] list_memberuids.append(final_user_username) - add_members_to_openldap_project_posixgroup(dn, list_memberuids) + add_members_to_openldap_posixgroup(dn, list_memberuids) def remove_user_project(project_user_pk): @@ -142,4 +142,4 @@ def remove_user_project(project_user_pk): list_memberuids = [] list_memberuids.append(final_user_username) - remove_members_from_openldap_project_posixgroup(dn, list_memberuids) + remove_members_from_openldap_posixgroup(dn, list_memberuids) diff --git a/coldfront/plugins/project_openldap/utils.py b/coldfront/plugins/project_openldap/utils.py index 383fed6056..c25d2bc2a1 100644 --- a/coldfront/plugins/project_openldap/utils.py +++ b/coldfront/plugins/project_openldap/utils.py @@ -60,7 +60,7 @@ def openldap_connection(server_opt, bind_user, bind_password): return None -def add_members_to_openldap_project_posixgroup(dn, list_memberuids, write=True): +def add_members_to_openldap_posixgroup(dn, list_memberuids, write=True): """Add members to a posixgroup in OpenLDAP""" member_uid = tuple(list_memberuids) conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) @@ -81,7 +81,7 @@ def add_members_to_openldap_project_posixgroup(dn, list_memberuids, write=True): conn.unbind() -def remove_members_from_openldap_project_posixgroup(dn, list_memberuids, write=True): +def remove_members_from_openldap_posixgroup(dn, list_memberuids, write=True): """Remove members from a posixgroup in OpenLDAP""" member_uids_tuple = tuple(list_memberuids) conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) @@ -131,8 +131,8 @@ def add_per_project_ou_to_openldap(project_obj, dn, openldap_ou_description, wri conn.unbind() -def add_project_posixgroup_to_openldap(dn, openldap_description, gid_int, write=True): - """Add a project to OpenLDAP - write a posixGroup""" +def add_posixgroup_to_openldap(dn, openldap_description, gid_int, write=True): + """Add a posixGroup to OpenLDAP""" conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) if not conn: @@ -159,7 +159,7 @@ def add_project_posixgroup_to_openldap(dn, openldap_description, gid_int, write= # Remove a DN - e.g. DELETE a project OU or posixgroup in OpenLDAP def remove_dn_from_openldap(dn, write=True): - """Remove a project from OpenLDAP - delete a posixGroup""" + """Remove a DN from OpenLDAP""" conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) if not conn: @@ -179,7 +179,7 @@ def remove_dn_from_openldap(dn, write=True): # Update the project title in OpenLDAP -def update_project_posixgroup_in_openldap(dn, openldap_description, write=True): +def update_posixgroup_description_in_openldap(dn, openldap_description, write=True): """Update the description of a posixGroup in OpenLDAP""" conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) @@ -199,8 +199,8 @@ def update_project_posixgroup_in_openldap(dn, openldap_description, write=True): # MOVE the project to an archive OU - defined as env var -def archive_project_in_openldap(current_dn, relative_dn, archive_ou, write=True): - """Move a project to the archive OU in OpenLDAP""" +def move_dn_in_openldap(current_dn, relative_dn, destination_ou, write=True): + """Move a DN to another OU in OpenLDAP""" conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) if not conn: @@ -210,7 +210,7 @@ def archive_project_in_openldap(current_dn, relative_dn, archive_ou, write=True) return None try: - conn.modify_dn(current_dn, relative_dn, new_superior=archive_ou) + conn.modify_dn(current_dn, relative_dn, new_superior=destination_ou) conn.unbind() except Exception as exc_log: logger.info(exc_log) @@ -236,7 +236,7 @@ def ldapsearch_check_project_dn(dn): # check bind user can see the Project OU or Archive OU - is also used in system setup check script -def ldapsearch_check_project_ou(OU): +def ldapsearch_check_ou(OU): """Test that ldapsearch can see an OU""" conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) @@ -253,8 +253,8 @@ def ldapsearch_check_project_ou(OU): conn.unbind() -def ldapsearch_get_project_memberuids(dn): - """Get memberUids from a project's posixGroup""" +def ldapsearch_get_posixgroup_memberuids(dn): + """Get memberUids from a posixGroup""" conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) if not conn: @@ -271,8 +271,8 @@ def ldapsearch_get_project_memberuids(dn): conn.unbind() -def ldapsearch_get_project_description(dn): - """Get description from a project's posixGroup""" +def ldapsearch_get_description(dn): + """Get description from an openldap entry""" conn = openldap_connection(server, PROJECT_OPENLDAP_BIND_USER, PROJECT_OPENLDAP_BIND_PASSWORD) if not conn: From 961ac5d3f0347891e1e86c507f343f54cdea2de5 Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Fri, 10 Oct 2025 20:06:30 +0000 Subject: [PATCH 057/110] use SETTINGS_EXPORT --- coldfront/config/core.py | 9 ++++++--- coldfront/config/plugins/system_monitor.py | 11 ++++++++--- .../templates/allocation/allocation_detail.html | 4 ++-- .../templates/allocation/allocation_request_list.html | 6 +++--- coldfront/core/allocation/views.py | 5 ----- .../templates/project/project_archived_list.html | 2 +- .../project/templates/project/project_detail.html | 4 ++-- .../core/project/templates/project/project_list.html | 4 ++-- coldfront/core/project/views.py | 3 --- coldfront/core/resource/views.py | 2 -- .../templates/system_monitor/system_monitor_div.html | 10 +++++----- coldfront/plugins/system_monitor/utils.py | 4 ---- 12 files changed, 29 insertions(+), 35 deletions(-) diff --git a/coldfront/config/core.py b/coldfront/config/core.py index b4421880ee..bdfa8356d3 100644 --- a/coldfront/config/core.py +++ b/coldfront/config/core.py @@ -57,13 +57,16 @@ SETTINGS_EXPORT += [ "ALLOCATION_ACCOUNT_ENABLED", - "CENTER_HELP_URL", + "ALLOCATION_DEFAULT_ALLOCATION_LENGTH", + "ALLOCATION_ENABLE_ALLOCATION_RENEWAL", "ALLOCATION_EULA_ENABLE", - "RESEARCH_OUTPUT_ENABLE", + "CENTER_HELP_URL", "GRANT_ENABLE", - "PUBLICATION_ENABLE", "INVOICE_ENABLED", "PROJECT_ENABLE_PROJECT_REVIEW", + "PROJECT_INSTITUTION_EMAIL_MAP", + "PUBLICATION_ENABLE", + "RESEARCH_OUTPUT_ENABLE", ] ADMIN_COMMENTS_SHOW_EMPTY = ENV.bool("ADMIN_COMMENTS_SHOW_EMPTY", default=True) diff --git a/coldfront/config/plugins/system_monitor.py b/coldfront/config/plugins/system_monitor.py index ac3cda3912..73c90e8db1 100644 --- a/coldfront/config/plugins/system_monitor.py +++ b/coldfront/config/plugins/system_monitor.py @@ -2,12 +2,17 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -from coldfront.config.base import INSTALLED_APPS +from coldfront.config.base import INSTALLED_APPS, SETTINGS_EXPORT from coldfront.config.env import ENV INSTALLED_APPS += ["coldfront.plugins.system_monitor"] SYSTEM_MONITOR_PANEL_TITLE = ENV.str("SYSMON_TITLE", default="HPC Cluster Status") SYSTEM_MONITOR_ENDPOINT = ENV.str("SYSMON_ENDPOINT") -SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK = ENV.str("SYSMON_LINK") -SYSTEM_MONITOR_DISPLAY_XDMOD_LINK = ENV.str("SYSMON_XDMOD_LINK") +SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK = ENV.str("SYSMON_LINK", default=None) +SYSTEM_MONITOR_DISPLAY_XDMOD_LINK = ENV.str("SYSMON_XDMOD_LINK", default=None) + +SETTINGS_EXPORT += [ + "SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK", + "SYSTEM_MONITOR_DISPLAY_XDMOD_LINK", +] diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index 55d01f00ce..8f76a1e7f5 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -107,7 +107,7 @@

Allocation Information

Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}} - Not renewable - {% elif is_allowed_to_update_project and ALLOCATION_ENABLE_ALLOCATION_RENEWAL and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} + {% elif is_allowed_to_update_project and settings.ALLOCATION_ENABLE_ALLOCATION_RENEWAL and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}} - Click to renew @@ -477,4 +477,4 @@

Notificatio } }) -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_request_list.html b/coldfront/core/allocation/templates/allocation/allocation_request_list.html index fc478429fb..8dc76fbf7e 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_request_list.html +++ b/coldfront/core/allocation/templates/allocation/allocation_request_list.html @@ -19,7 +19,7 @@

Allocation Requests

- By default, activating an allocation will make it active for {{ ALLOCATION_DEFAULT_ALLOCATION_LENGTH }} days. + By default, activating an allocation will make it active for {{ settings.ALLOCATION_DEFAULT_ALLOCATION_LENGTH }} days.

{% if allocation_list %} @@ -32,7 +32,7 @@

Allocation Requests

Project Title PI Resource - {% if PROJECT_ENABLE_PROJECT_REVIEW %} + {% if settings.PROJECT_ENABLE_PROJECT_REVIEW %} Project Review Status {% endif %} Status @@ -48,7 +48,7 @@

Allocation Requests

{{allocation.project.pi.first_name}} {{allocation.project.pi.last_name}} ({{allocation.project.pi.username}}) {{allocation.get_parent_resource}} - {% if PROJECT_ENABLE_PROJECT_REVIEW %} + {% if settings.PROJECT_ENABLE_PROJECT_REVIEW %} {{allocation.project|convert_status_to_icon}} {% endif %} {{allocation.status}} diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index 841858292c..9a996c8ca9 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -184,7 +184,6 @@ def get_context_data(self, **kwargs): notes = noteset.all() if self.request.user.is_superuser else noteset.filter(is_private=False) context["notes"] = notes - context["ALLOCATION_ENABLE_ALLOCATION_RENEWAL"] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL return context def get(self, request, *args, **kwargs): @@ -1218,8 +1217,6 @@ def get_context_data(self, **kwargs): context["allocation_renewal_dates"] = allocation_renewal_dates context["allocation_status_active"] = AllocationStatusChoice.objects.get(name="Active") context["allocation_list"] = allocation_list - context["PROJECT_ENABLE_PROJECT_REVIEW"] = PROJECT_ENABLE_PROJECT_REVIEW - context["ALLOCATION_DEFAULT_ALLOCATION_LENGTH"] = ALLOCATION_DEFAULT_ALLOCATION_LENGTH return context @@ -1469,7 +1466,6 @@ def get_context_data(self, **kwargs): notes = allocation_obj.allocationusernote_set.filter(is_private=False) context["notes"] = notes - context["ALLOCATION_ENABLE_ALLOCATION_RENEWAL"] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL return context def get(self, request, *args, **kwargs): @@ -1949,7 +1945,6 @@ def get_context_data(self, **kwargs): ] ) context["allocation_change_list"] = allocation_change_list - context["PROJECT_ENABLE_PROJECT_REVIEW"] = PROJECT_ENABLE_PROJECT_REVIEW return context diff --git a/coldfront/core/project/templates/project/project_archived_list.html b/coldfront/core/project/templates/project/project_archived_list.html index 3ef395c163..6b2aa1d481 100644 --- a/coldfront/core/project/templates/project/project_archived_list.html +++ b/coldfront/core/project/templates/project/project_archived_list.html @@ -86,7 +86,7 @@

Archived Projects


Description: {{ project.description }} {{ project.field_of_science.description }} {{ project.status.name }} - {% if PROJECT_INSTITUTION_EMAIL_MAP %} + {% if settings.PROJECT_INSTITUTION_EMAIL_MAP %}

Institution: {{ project.institution }}

{% endif %} diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index acbd285176..5f209f7c70 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -81,7 +81,7 @@

project review pending {% endif %}

- {% if PROJECT_INSTITUTION_EMAIL_MAP %} + {% if settings.PROJECT_INSTITUTION_EMAIL_MAP %}

Institution: {{ project.institution }}

{% endif %}

Created: {{ project.created|date:"M. d, Y" }}

@@ -225,7 +225,7 @@

Allocation Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}}
Not renewable
- {% elif is_allowed_to_update_project and ALLOCATION_ENABLE_ALLOCATION_RENEWAL and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %} + {% elif is_allowed_to_update_project and settings.ALLOCATION_ENABLE_ALLOCATION_RENEWAL and allocation.status.name == 'Active' and allocation.expires_in <= 60 and allocation.expires_in >= 0 %}
Expires in {{allocation.expires_in}} day{{allocation.expires_in|pluralize}}
Click to renew diff --git a/coldfront/core/project/templates/project/project_list.html b/coldfront/core/project/templates/project/project_list.html index 2045548a18..da2e283456 100644 --- a/coldfront/core/project/templates/project/project_list.html +++ b/coldfront/core/project/templates/project/project_list.html @@ -71,7 +71,7 @@

Projects

Sort Status asc Sort Status desc - {% if PROJECT_INSTITUTION_EMAIL_MAP %} + {% if settings.PROJECT_INSTITUTION_EMAIL_MAP %} Institution Sort Institution asc @@ -92,7 +92,7 @@

Projects

{{ project.title }} {{ project.field_of_science.description }} {{ project.status.name }} - {% if PROJECT_INSTITUTION_EMAIL_MAP %} + {% if settings.PROJECT_INSTITUTION_EMAIL_MAP %} {{ project.institution }} {% endif %} diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index df2adc82f3..4696e5b702 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -215,8 +215,6 @@ def get_context_data(self, **kwargs): context["guage_data"] = guage_data context["attributes_with_usage"] = attributes_with_usage context["project_users"] = project_users - context["ALLOCATION_ENABLE_ALLOCATION_RENEWAL"] = ALLOCATION_ENABLE_ALLOCATION_RENEWAL - context["PROJECT_INSTITUTION_EMAIL_MAP"] = PROJECT_INSTITUTION_EMAIL_MAP try: context["ondemand_url"] = settings.ONDEMAND_URL @@ -360,7 +358,6 @@ def get_context_data(self, **kwargs): context["filter_parameters"] = filter_parameters context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - context["PROJECT_INSTITUTION_EMAIL_MAP"] = PROJECT_INSTITUTION_EMAIL_MAP project_list = context.get("project_list") paginator = Paginator(project_list, self.paginate_by) diff --git a/coldfront/core/resource/views.py b/coldfront/core/resource/views.py index 7f9e4aca3a..fd03e8b483 100644 --- a/coldfront/core/resource/views.py +++ b/coldfront/core/resource/views.py @@ -15,7 +15,6 @@ from django.views.generic import ListView, TemplateView from django.views.generic.edit import CreateView -from coldfront.config.core import ALLOCATION_EULA_ENABLE from coldfront.core.resource.forms import ResourceAttributeCreateForm, ResourceAttributeDeleteForm, ResourceSearchForm from coldfront.core.resource.models import Resource, ResourceAttribute @@ -308,7 +307,6 @@ def get_context_data(self, **kwargs): context["filter_parameters"] = filter_parameters context["filter_parameters_with_order_by"] = filter_parameters_with_order_by - context["ALLOCATION_EULA_ENABLE"] = ALLOCATION_EULA_ENABLE resource_list = context.get("resource_list") paginator = Paginator(resource_list, self.paginate_by) diff --git a/coldfront/plugins/system_monitor/templates/system_monitor/system_monitor_div.html b/coldfront/plugins/system_monitor/templates/system_monitor/system_monitor_div.html index 5ad27fe865..01bc8487d7 100644 --- a/coldfront/plugins/system_monitor/templates/system_monitor/system_monitor_div.html +++ b/coldfront/plugins/system_monitor/templates/system_monitor/system_monitor_div.html @@ -6,8 +6,8 @@

{{system_monitor_panel_title}}


{% if last_updated %}
- {% if SYSTEM_MONITOR_DISPLAY_XDMOD_LINK %} - {% endif %} + {% if settings.SYSTEM_MONITOR_DISPLAY_XDMOD_LINK %} + {% endif %}
@@ -18,10 +18,10 @@

{{system_monitor_panel_title}}

- {% if SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK %} + {% if settings.SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK %}
- {% if SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK %} - More status info + {% if settings.SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK %} + More status info
{% endif %}
{% endif %} diff --git a/coldfront/plugins/system_monitor/utils.py b/coldfront/plugins/system_monitor/utils.py index 98ec886c19..f8f8270994 100644 --- a/coldfront/plugins/system_monitor/utils.py +++ b/coldfront/plugins/system_monitor/utils.py @@ -20,10 +20,6 @@ def get_system_monitor_context(): context["utilization_data"] = system_monitor_data.get("utilization_data") context["jobs_data"] = system_monitor_data.get("jobs_data") context["system_monitor_panel_title"] = system_monitor_panel_title - context["SYSTEM_MONITOR_DISPLAY_XDMOD_LINK"] = import_from_settings("SYSTEM_MONITOR_DISPLAY_XDMOD_LINK", None) - context["SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK"] = import_from_settings( - "SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK", None - ) return context From 86d6603beab557a6d9b927bf52187da4dc6d5e40 Mon Sep 17 00:00:00 2001 From: David Simpson <> Date: Fri, 10 Oct 2025 11:37:01 +0100 Subject: [PATCH 058/110] Add PI to posixgroup on creation of project Signed-off-by: David Simpson <> --- coldfront/plugins/project_openldap/tasks.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/coldfront/plugins/project_openldap/tasks.py b/coldfront/plugins/project_openldap/tasks.py index 01e782fc9a..b2eec3d256 100644 --- a/coldfront/plugins/project_openldap/tasks.py +++ b/coldfront/plugins/project_openldap/tasks.py @@ -79,6 +79,9 @@ def add_project(project_obj): add_posixgroup_to_openldap(posixgroup_dn, openldap_posixgroup_description, gid_int) + # 3) add the PI to the posixgroup + add_members_to_openldap_posixgroup(posixgroup_dn, [project_obj.pi.username]) + # Coldfront archive project action def remove_project(project_obj): From 4ce8fb4e9d55a066b1478ea0eaac8434962a1a67 Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Thu, 16 Oct 2025 21:14:02 +0000 Subject: [PATCH 059/110] resource description in form --- .../templates/allocation/allocation_create.html | 11 +++++++++++ coldfront/core/allocation/views.py | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/coldfront/core/allocation/templates/allocation/allocation_create.html b/coldfront/core/allocation/templates/allocation/allocation_create.html index 5a4567b48a..1bb9154227 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_create.html +++ b/coldfront/core/allocation/templates/allocation/allocation_create.html @@ -61,17 +61,20 @@
{{ resources_form_default_quantities|json_script:"resources-form-default-quantities" }} +{{ resources_form_descriptions|json_script:"resources-form-description" }} {{ resources_form_label_texts|json_script:"resources-form-label-texts" }} {{ resources_with_accounts|json_script:"resources-with-accounts" }} {{ resources_with_eula|json_script:"resources-with-eula" }} {% endblock %} - diff --git a/coldfront/core/allocation/templates/allocation/allocation_change_list.html b/coldfront/core/allocation/templates/allocation/allocation_change_list.html index 7afd32432e..d32f73b2ba 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_change_list.html +++ b/coldfront/core/allocation/templates/allocation/allocation_change_list.html @@ -28,6 +28,7 @@

Allocation Change Requests

# Requested Project Title + Allocation ID PI Resource Extension @@ -40,6 +41,7 @@

Allocation Change Requests

{{change.pk}} {{ change.created|date:"M. d, Y" }} {{change.allocation.project.title|truncatechars:50}} + {{change.allocation.pk}} {{change.allocation.project.pi.first_name}} {{change.allocation.project.pi.last_name}} ({{change.allocation.project.pi.username}}) {{change.allocation.get_parent_resource}} From 02c73b77ce433de07b761a8ed78f55cf7f4457a7 Mon Sep 17 00:00:00 2001 From: Matthew Kusz Date: Wed, 12 Nov 2025 11:42:54 -0500 Subject: [PATCH 081/110] Reduce database queries on various pages Signed-off-by: Matthew Kusz --- coldfront/core/allocation/forms.py | 2 +- coldfront/core/allocation/models.py | 14 +- .../allocation/allocation_detail.html | 14 +- coldfront/core/allocation/views.py | 92 ++++---- coldfront/core/grant/views.py | 10 +- coldfront/core/portal/views.py | 18 +- coldfront/core/project/forms.py | 7 +- coldfront/core/project/models.py | 18 +- .../templates/project/project_detail.html | 32 ++- coldfront/core/project/views.py | 203 +++++++++++------- coldfront/core/resource/views.py | 20 +- .../core/utils/templatetags/common_tags.py | 10 +- 12 files changed, 263 insertions(+), 177 deletions(-) diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py index ba3591dd5e..d24018efdc 100644 --- a/coldfront/core/allocation/forms.py +++ b/coldfront/core/allocation/forms.py @@ -133,7 +133,7 @@ class AllocationSearchForm(forms.Form): ) resource_name = forms.ModelMultipleChoiceField( label="Resource Name", - queryset=Resource.objects.filter(is_allocatable=True).order_by(Lower("name")), + queryset=Resource.objects.select_related("resource_type").filter(is_allocatable=True).order_by(Lower("name")), required=False, ) allocation_attribute_name = forms.ModelChoiceField( diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index ae8ec6fa59..b41b6fe2fa 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -155,7 +155,9 @@ def get_information(self) -> SafeString: """ html_string = escape("") - for attribute in self.allocationattribute_set.all(): + for attribute in self.allocationattribute_set.select_related( + "allocation_attribute_type", "allocationattributeusage" + ).all(): if attribute.allocation_attribute_type.name in ALLOCATION_ATTRIBUTE_VIEW_LIST: html_substring = format_html("{}: {}
", attribute.allocation_attribute_type.name, attribute.value) html_string += html_substring @@ -212,15 +214,15 @@ def get_parent_resource(self): Returns: Resource: the parent resource for the allocation """ - - if self.resources.count() == 1: - return self.resources.first() + resources = self.resources.select_related("resource_type") + if len(resources) == 1: + return resources.first() else: - parent = self.resources.order_by(*ALLOCATION_RESOURCE_ORDERING).first() + parent = resources.order_by(*ALLOCATION_RESOURCE_ORDERING).first() if parent: return parent # Fallback - return self.resources.first() + return resources.first() def get_attribute(self, name, expand=True, typed=True, extra_allocations=[]): """ diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index 8f76a1e7f5..594eff08a6 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -54,16 +54,18 @@

Allocation Information

{{ allocation.project }} - Resource{{ allocation.resources.all|pluralize }} in allocation: + {% with allocation.get_resources_as_list as resources_as_list %} + Resource{{ resources_as_list|pluralize }} in allocation: - {% if allocation.get_resources_as_list %} - {% for resource in allocation.get_resources_as_list %} + {% if resources_as_list %} + {% for resource in resources_as_list %} {{ resource }}
{% endfor %} - {% else %} - None - {% endif %} + {% else %} + None + {% endif %} + {% endwith %} {% if request.user.is_superuser %} diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index ef38fa92ec..74c0a7561e 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -121,12 +121,16 @@ def test_func(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) pk = self.kwargs.get("pk") - allocation_obj = get_object_or_404(Allocation, pk=pk) - allocation_users = allocation_obj.allocationuser_set.exclude( - status__name__in=[ - "Removed", - ] - ).order_by("user__username") + allocation_obj = get_object_or_404(Allocation.objects.select_related("status", "project"), pk=pk) + allocation_users = ( + allocation_obj.allocationuser_set.select_related("user", "status") + .exclude( + status__name__in=[ + "Removed", + ] + ) + .order_by("user__username") + ) if ALLOCATION_EULA_ENABLE: user_in_allocation = allocation_users.filter(user=self.request.user).exists() @@ -145,10 +149,11 @@ def get_context_data(self, **kwargs): # set visible usage attributes alloc_attr_set = allocation_obj.get_attribute_set(self.request.user) + alloc_attr_set = alloc_attr_set.select_related("allocation_attribute_type", "allocationattributeusage") attributes_with_usage = [a for a in alloc_attr_set if hasattr(a, "allocationattributeusage")] attributes = alloc_attr_set - allocation_changes = allocation_obj.allocationchangerequest_set.all().order_by("-pk") + allocation_changes = allocation_obj.allocationchangerequest_set.select_related("status").all().order_by("-pk") guage_data = [] invalid_attributes = [] @@ -181,7 +186,7 @@ def get_context_data(self, **kwargs): self.request.user, ProjectPermission.UPDATE ) - noteset = allocation_obj.allocationusernote_set + noteset = allocation_obj.allocationusernote_set.select_related("author") notes = noteset.all() if self.request.user.is_superuser else noteset.filter(is_private=False) context["notes"] = notes @@ -189,7 +194,7 @@ def get_context_data(self, **kwargs): def get(self, request, *args, **kwargs): pk = self.kwargs.get("pk") - allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_obj = get_object_or_404(Allocation.objects.select_related("status"), pk=pk) initial_data = { "status": allocation_obj.status, @@ -212,7 +217,7 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): pk = self.kwargs.get("pk") - allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_obj = get_object_or_404(Allocation.objects.select_related("status", "project", "project__pi"), pk=pk) allocation_users = allocation_obj.allocationuser_set.exclude(status__name__in=["Removed"]).order_by( "user__username" ) @@ -454,7 +459,7 @@ def get_queryset(self): self.request.user.is_superuser or self.request.user.has_perm("allocation.can_view_all_allocations") ): allocations = ( - Allocation.objects.prefetch_related( + Allocation.objects.select_related( "project", "project__pi", "status", @@ -464,7 +469,7 @@ def get_queryset(self): ) else: allocations = ( - Allocation.objects.prefetch_related( + Allocation.objects.select_related( "project", "project__pi", "status", @@ -529,7 +534,7 @@ def get_queryset(self): else: allocations = ( - Allocation.objects.prefetch_related( + Allocation.objects.select_related( "project", "project__pi", "status", @@ -779,7 +784,7 @@ def test_func(self): return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) + allocation_obj = get_object_or_404(Allocation.objects.select_related("status"), pk=self.kwargs.get("pk")) message = None if allocation_obj.is_locked and not self.request.user.is_superuser: @@ -829,7 +834,7 @@ def get_users_to_add(self, allocation_obj): def get(self, request, *args, **kwargs): pk = self.kwargs.get("pk") - allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_obj = get_object_or_404(Allocation.objects.select_related("project", "project__pi"), pk=pk) users_to_add = self.get_users_to_add(allocation_obj) context = {} @@ -859,7 +864,7 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): pk = self.kwargs.get("pk") - allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_obj = get_object_or_404(Allocation.objects.select_related("project", "project__pi"), pk=pk) users_to_add = self.get_users_to_add(allocation_obj) @@ -880,8 +885,8 @@ def post(self, request, *args, **kwargs): user_obj = get_user_model().objects.get(username=user_form_data.get("username")) - if allocation_obj.allocationuser_set.filter(user=user_obj).exists(): - allocation_user_obj = allocation_obj.allocationuser_set.get(user=user_obj) + allocation_user_obj = allocation_obj.allocationuser_set.filter(user=user_obj).first() + if allocation_user_obj: if ALLOCATION_EULA_ENABLE and not user_obj.userprofile.is_pi and allocation_obj.get_eula(): allocation_user_obj.status = allocation_user_pending_status_choice send_email_template( @@ -948,7 +953,7 @@ def test_func(self): return False def dispatch(self, request, *args, **kwargs): - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) + allocation_obj = get_object_or_404(Allocation.objects.select_related("status"), pk=self.kwargs.get("pk")) message = None if allocation_obj.is_locked and not self.request.user.is_superuser: @@ -1008,7 +1013,7 @@ def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs): pk = self.kwargs.get("pk") - allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_obj = get_object_or_404(Allocation.objects.select_related("project"), pk=pk) users_to_remove = self.get_users_to_remove(allocation_obj) @@ -1058,7 +1063,7 @@ def test_func(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) pk = self.kwargs.get("pk") - allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_obj = get_object_or_404(Allocation.objects.select_related("project", "project__pi"), pk=pk) context["allocation"] = allocation_obj return context @@ -1090,7 +1095,9 @@ def test_func(self): return False def get_allocation_attributes_to_delete(self, allocation_obj): - allocation_attributes_to_delete = AllocationAttribute.objects.filter(allocation=allocation_obj) + allocation_attributes_to_delete = AllocationAttribute.objects.select_related( + "allocation_attribute_type" + ).filter(allocation=allocation_obj) allocation_attributes_to_delete = [ { "pk": attribute.pk, @@ -1104,7 +1111,7 @@ def get_allocation_attributes_to_delete(self, allocation_obj): def get(self, request, *args, **kwargs): pk = self.kwargs.get("pk") - allocation_obj = get_object_or_404(Allocation, pk=pk) + allocation_obj = get_object_or_404(Allocation.objects.select_related("project"), pk=pk) allocation_attributes_to_delete = self.get_allocation_attributes_to_delete(allocation_obj) context = {} @@ -1125,16 +1132,17 @@ def post(self, request, *args, **kwargs): formset = formset_factory(AllocationAttributeDeleteForm, max_num=len(allocation_attributes_to_delete)) formset = formset(request.POST, initial=allocation_attributes_to_delete, prefix="attributeform") - attributes_deleted_count = 0 - if formset.is_valid(): + selected_attributes = [] for form in formset: form_data = form.cleaned_data - if form_data["selected"]: - attributes_deleted_count += 1 + if form_data.get("selected"): + selected_attributes.append(form_data.get("pk")) - allocation_attribute = AllocationAttribute.objects.get(pk=form_data["pk"]) - allocation_attribute.delete() + attributes_deleted_count = len(selected_attributes) + if attributes_deleted_count: + attribute_objs = AllocationAttribute.objects.filter(pk__in=selected_attributes) + attribute_objs.delete() messages.success(request, f"Deleted {attributes_deleted_count} attributes from allocation.") else: @@ -1203,7 +1211,9 @@ def test_func(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - allocation_list = Allocation.objects.filter( + allocation_list = Allocation.objects.select_related( + "status", "project", "project__pi", "project__status" + ).filter( status__name__in=[ "New", "Renewal Requested", @@ -1214,7 +1224,7 @@ def get_context_data(self, **kwargs): allocation_renewal_dates = {} for allocation in allocation_list.filter(status__name="Renewal Requested"): - allocation_history = allocation.history.all().order_by("-history_date") + allocation_history = allocation.history.select_related("status").all().order_by("-history_date") for history in allocation_history: if history.status.name != "Renewal Requested": break @@ -1945,7 +1955,9 @@ def test_func(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - allocation_change_list = AllocationChangeRequest.objects.filter( + allocation_change_list = AllocationChangeRequest.objects.select_related( + "allocation", "allocation__project", "allocation__project__pi" + ).filter( status__name__in=[ "Pending", ] @@ -2141,7 +2153,7 @@ def test_func(self): return False def get_allocation_attributes_to_change(self, allocation_obj): - attributes_to_change = allocation_obj.allocationattribute_set.all() + attributes_to_change = allocation_obj.allocationattribute_set.select_related("allocation_attribute_type").all() attributes_to_change = [ { @@ -2157,7 +2169,7 @@ def get_allocation_attributes_to_change(self, allocation_obj): def get(self, request, *args, **kwargs): context = {} - allocation_obj = get_object_or_404(Allocation, pk=self.kwargs.get("pk")) + allocation_obj = get_object_or_404(Allocation.objects.select_related("project"), pk=self.kwargs.get("pk")) allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) context["allocation"] = allocation_obj @@ -2177,8 +2189,6 @@ def get(self, request, *args, **kwargs): return render(request, self.template_name, context) def post(self, request, *args, **kwargs): - attribute_changes_to_make = set() - pk = self.kwargs.get("pk") allocation_obj = get_object_or_404(Allocation, pk=pk) allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) @@ -2205,14 +2215,18 @@ def post(self, request, *args, **kwargs): error_redirect = HttpResponseRedirect(reverse("allocation-attribute-edit", kwargs={"pk": pk})) return error_redirect + attribute_changes_to_make = set() + attribute_changes_to_make_pks = dict() for entry in formset: formset_data = entry.cleaned_data value = formset_data.get("value") if value != "": - allocation_attribute = AllocationAttribute.objects.get(pk=formset_data.get("attribute_pk")) - if allocation_attribute.value != value: - attribute_changes_to_make.add((allocation_attribute, value)) + attribute_changes_to_make_pks[formset_data.get("attribute_pk")] = value + + for allocation_attribute in AllocationAttribute.objects.filter(pk__in=attribute_changes_to_make_pks): + if allocation_attribute.value != attribute_changes_to_make_pks.get("value"): + attribute_changes_to_make.add((allocation_attribute, value)) for allocation_attribute, value in attribute_changes_to_make: allocation_attribute.value = value diff --git a/coldfront/core/grant/views.py b/coldfront/core/grant/views.py index ed6f075d53..8f550b5690 100644 --- a/coldfront/core/grant/views.py +++ b/coldfront/core/grant/views.py @@ -213,7 +213,7 @@ def test_func(self): messages.error(self.request, "You do not have permission to view all grants.") def get_grants(self): - grants = Grant.objects.prefetch_related("project", "project__pi").all().order_by("-total_amount_awarded") + grants = Grant.objects.select_related("project", "project__pi").all().order_by("-total_amount_awarded") grants = [ { "pk": grant.pk, @@ -272,7 +272,9 @@ def post(self, request, *args, **kwargs): for form in formset: form_data = form.cleaned_data if form_data["selected"]: - grant = get_object_or_404(Grant, pk=form_data["pk"]) + grant = get_object_or_404( + Grant.objects.select_related("project", "project__pi"), pk=form_data["pk"] + ) row = [ grant.title, @@ -292,9 +294,7 @@ def post(self, request, *args, **kwargs): grants_selected_count += 1 if grants_selected_count == 0: - grants = ( - Grant.objects.prefetch_related("project", "project__pi").all().order_by("-total_amount_awarded") - ) + grants = Grant.objects.select_related("project", "project__pi").all().order_by("-total_amount_awarded") for grant in grants: row = [ grant.title, diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py index 63a81004e3..3fbfc97242 100644 --- a/coldfront/core/portal/views.py +++ b/coldfront/core/portal/views.py @@ -33,7 +33,8 @@ def home(request): if request.user.is_authenticated: template_name = "portal/authorized_home.html" project_list = ( - Project.objects.filter( + Project.objects.select_related("status") + .filter( ( Q(pi=request.user) & Q( @@ -63,7 +64,8 @@ def home(request): ) allocation_list = ( - Allocation.objects.filter( + Allocation.objects.select_related("status", "project") + .filter( Q( status__name__in=[ "Active", @@ -211,12 +213,12 @@ def allocation_by_fos(request): @cache_page(60 * 15) def allocation_summary(request): - allocation_resources = [ - allocation.get_parent_resource.parent_resource - if allocation.get_parent_resource.parent_resource - else allocation.get_parent_resource - for allocation in Allocation.objects.filter(status__name="Active") - ] + allocation_resources = [] + for allocation in Allocation.objects.filter(status__name="Active"): + parent_resource = allocation.get_parent_resource + allocation_resources.append( + parent_resource.parent_resource if parent_resource.parent_resource else parent_resource + ) allocations_count_by_resource = dict(Counter(allocation_resources)) diff --git a/coldfront/core/project/forms.py b/coldfront/core/project/forms.py index f3e663f1aa..2beb2b274e 100644 --- a/coldfront/core/project/forms.py +++ b/coldfront/core/project/forms.py @@ -128,10 +128,9 @@ class Meta: def __init__(self, *args, **kwargs): super(ProjectAttributeAddForm, self).__init__(*args, **kwargs) - user = (kwargs.get("initial")).get("user") - self.fields["proj_attr_type"].queryset = self.fields["proj_attr_type"].queryset.order_by(Lower("name")) - if not user.is_superuser: - self.fields["proj_attr_type"].queryset = self.fields["proj_attr_type"].queryset.filter(is_private=False) + user = kwargs.get("initial").get("user") + queryset = self.fields["proj_attr_type"].queryset.select_related("attribute_type").order_by(Lower("name")) + self.fields["proj_attr_type"].queryset = queryset if user.is_superuser else queryset.filter(is_private=False) class ProjectAttributeDeleteForm(forms.Form): diff --git a/coldfront/core/project/models.py b/coldfront/core/project/models.py index afb4c3e0cd..cd7427d88d 100644 --- a/coldfront/core/project/models.py +++ b/coldfront/core/project/models.py @@ -128,9 +128,9 @@ def last_project_review(self): Returns: ProjectReview: the last project review that was created for this project """ - - if self.projectreview_set.exists(): - return self.projectreview_set.order_by("-created")[0] + project_review_query = self.projectreview_set.order_by("-created") + if project_review_query: + return project_review_query.first() else: return None @@ -140,9 +140,9 @@ def latest_grant(self): Returns: Grant: the most recent grant for this project, or None if there are no grants """ - - if self.grant_set.exists(): - return self.grant_set.order_by("-modified")[0] + grant_query = self.grant_set.order_by("-modified") + if grant_query: + return grant_query.first() else: return None @@ -152,9 +152,9 @@ def latest_publication(self): Returns: Publication: the most recent publication for this project, or None if there are no publications """ - - if self.publication_set.exists(): - return self.publication_set.order_by("-created")[0] + publication_query = self.publication_set.order_by("-created") + if publication_query: + return publication_query.first() else: return None diff --git a/coldfront/core/project/templates/project/project_detail.html b/coldfront/core/project/templates/project/project_detail.html index 5f209f7c70..174783d7ac 100644 --- a/coldfront/core/project/templates/project/project_detail.html +++ b/coldfront/core/project/templates/project/project_detail.html @@ -196,16 +196,19 @@

Allocation {% for allocation in allocations %} - {{ allocation.get_parent_resource.name }} - {{ allocation.get_parent_resource.resource_type.name }} + {% with allocation.get_parent_resource as parent_resource %} + {{ parent_resource.name }} + {{ parent_resource.resource_type.name }} {% if user_allocation_status|get_value_by_index:forloop.counter0 == 'PendingEULA' %} Review and Accept EULA to activate {% else %} - {% if allocation.get_information != '' %} - {{allocation.get_information}} + {% with allocation.get_information as allocation_information %} + {% if allocation_information != '' %} + {{allocation_information}} {% else %} {{allocation.description|default_if_none:""}} {% endif %} + {% endwith %} {% endif %} {% if allocation.status.name == 'Active' %} {% if user_allocation_status|get_value_by_index:forloop.counter0 == 'PendingEULA' %} @@ -232,9 +235,10 @@

Allocation {% endif %} - {% if allocation.get_parent_resource.get_ondemand_status == 'Yes' and ondemand_url %} + {% if parent_resource.get_ondemand_status == 'Yes' and ondemand_url %} ondemand cta {% endif %} + {% endwith %} {% endfor %} @@ -322,9 +326,11 @@

{{attribute}}

Grants

{{grants.count}}
- {% if project.latest_grant.modified %} - Last Updated: {{project.latest_grant.modified|date:"M. d, Y"}} + {% with project.latest_grant as latest_grant %} + {% if latest_grant.modified %} + Last Updated: {{latest_grant.modified|date:"M. d, Y"}} {% endif %} + {% endwith %} {% if project.status.name != 'Archived' and is_allowed_to_update_project %} Add Grant {% if grants %} @@ -386,9 +392,11 @@

Publications

{{publications.count}}
- {% if project.latest_publication.created %} - Last Updated: {{project.latest_publication.created|date:"M. d, Y"}} + {% with project.latest_publication as latest_publication %} + {% if latest_publication.created %} + Last Updated: {{latest_publication.created|date:"M. d, Y"}} {% endif %} + {% endwith %} {% if project.status.name != 'Archived' and is_allowed_to_update_project %} Add Publication {% if publications %} @@ -489,7 +497,8 @@

Notificatio

- {% if project.projectusermessage_set.all %} + {% with project.projectusermessage_set.all as all_messages %} + {% if all_messages %}
@@ -500,7 +509,7 @@

Notificatio

- {% for message in project.projectusermessage_set.all %} + {% for message in all_messages %} {% if not message.is_private or request.user.is_superuser %} @@ -515,6 +524,7 @@

Notificatio {% else %} {% endif %} + {% endwith %} diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index c8f106596d..644b3d274c 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -111,10 +111,12 @@ def test_func(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # Can the user update the project? + project_obj = self.get_object(Project.objects.select_related("status")) + project_user = project_obj.projectuser_set.select_related("role").filter(user=self.request.user) if self.request.user.is_superuser: context["is_allowed_to_update_project"] = True - elif self.object.projectuser_set.filter(user=self.request.user).exists(): - project_user = self.object.projectuser_set.get(user=self.request.user) + elif project_user: + project_user = project_user.first() if project_user.role.name == "Manager": context["is_allowed_to_update_project"] = True else: @@ -122,30 +124,24 @@ def get_context_data(self, **kwargs): else: context["is_allowed_to_update_project"] = False - pk = self.kwargs.get("pk") - project_obj = get_object_or_404(Project, pk=pk) - + attributes_query = project_obj.projectattribute_set.select_related("proj_attr_type", "projectattributeusage") if self.request.user.is_superuser: attributes_with_usage = [ attribute - for attribute in project_obj.projectattribute_set.all().order_by("proj_attr_type__name") + for attribute in attributes_query.all().order_by("proj_attr_type__name") if hasattr(attribute, "projectattributeusage") ] - attributes = [ - attribute for attribute in project_obj.projectattribute_set.all().order_by("proj_attr_type__name") - ] + attributes = [attribute for attribute in attributes_query.all().order_by("proj_attr_type__name")] else: attributes_with_usage = [ attribute - for attribute in project_obj.projectattribute_set.filter(proj_attr_type__is_private=False) + for attribute in attributes_query.filter(proj_attr_type__is_private=False) if hasattr(attribute, "projectattributeusage") ] - attributes = [ - attribute for attribute in project_obj.projectattribute_set.filter(proj_attr_type__is_private=False) - ] + attributes = [attribute for attribute in attributes_query.filter(proj_attr_type__is_private=False)] guage_data = [] invalid_attributes = [] @@ -168,22 +164,25 @@ def get_context_data(self, **kwargs): attributes_with_usage.remove(a) # Only show 'Active Users' - project_users = self.object.projectuser_set.filter(status__name="Active").order_by("user__username") + project_users = ( + project_obj.projectuser_set.select_related("user", "role", "status") + .filter(status__name="Active") + .order_by("user__username") + ) context["mailto"] = "mailto:" + ",".join([user.user.email for user in project_users]) + allocations = Allocation.objects.select_related("status").prefetch_related("resources") if self.request.user.is_superuser or self.request.user.has_perm("allocation.can_view_all_allocations"): - allocations = ( - Allocation.objects.prefetch_related("resources").filter(project=self.object).order_by("-end_date") - ) + allocations = allocations.filter(project=project_obj).order_by("-end_date") else: - if self.object.status.name in [ + if project_obj.status.name in [ "Active", "New", ]: allocations = ( - Allocation.objects.filter( - Q(project=self.object) + allocations.filter( + Q(project=project_obj) & Q(project__projectuser__user=self.request.user) & Q( project__projectuser__status__name__in=[ @@ -197,17 +196,20 @@ def get_context_data(self, **kwargs): .order_by("-end_date") ) else: - allocations = Allocation.objects.prefetch_related("resources").filter(project=self.object) + allocations = allocations.filter(project=project_obj) user_status = [] for allocation in allocations: - if allocation.allocationuser_set.filter(user=self.request.user).exists(): - user_status.append(allocation.allocationuser_set.get(user=self.request.user).status.name) + allocation_user = allocation.allocationuser_set.select_related("status").filter(user=self.request.user) + if allocation_user: + user_status.append(allocation_user.first().status.name) - context["publications"] = Publication.objects.filter(project=self.object, status="Active").order_by("-year") - context["research_outputs"] = ResearchOutput.objects.filter(project=self.object).order_by("-created") - context["grants"] = Grant.objects.filter( - project=self.object, status__name__in=["Active", "Pending", "Archived"] + context["publications"] = ( + Publication.objects.select_related("source").filter(project=project_obj, status="Active").order_by("-year") + ) + context["research_outputs"] = ResearchOutput.objects.filter(project=project_obj).order_by("-created") + context["grants"] = Grant.objects.select_related("status").filter( + project=project_obj, status__name__in=["Active", "Pending", "Archived"] ) context["allocations"] = allocations context["user_allocation_status"] = user_status @@ -254,12 +256,38 @@ def get_queryset(self): if data.get("show_all_projects") and ( self.request.user.is_superuser or self.request.user.has_perm("project.can_view_all_projects") ): - projects = projects.filter(status__name__in=["New", "Active"]) + projects = ( + Project.objects.select_related( + "pi", + "field_of_science", + "status", + ) + .filter( + status__name__in=[ + "New", + "Active", + ] + ) + .order_by(order_by) + ) else: - projects = projects.filter( - Q(status__name__in=["New", "Active"]) - & Q(projectuser__user=self.request.user) - & Q(projectuser__status__name="Active") + projects = ( + Project.objects.select_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "New", + "Active", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) ) # Last Name @@ -283,10 +311,23 @@ def get_queryset(self): projects = projects.filter(field_of_science__description__icontains=data.get("field_of_science")) else: - projects = projects.filter( - Q(status__name__in=["New", "Active"]) - & Q(projectuser__user=self.request.user) - & Q(projectuser__status__name="Active") + projects = ( + Project.objects.select_related( + "pi", + "field_of_science", + "status", + ) + .filter( + Q( + status__name__in=[ + "New", + "Active", + ] + ) + & Q(projectuser__user=self.request.user) + & Q(projectuser__status__name="Active") + ) + .order_by(order_by) ) return projects.order_by(order_by).distinct() @@ -645,7 +686,7 @@ def test_func(self): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_obj = get_object_or_404(Project.objects.select_related("status"), pk=self.kwargs.get("pk")) if project_obj.status.name not in [ "Active", "New", @@ -682,7 +723,7 @@ def test_func(self): return True def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get("pk")) + project_obj = get_object_or_404(Project.objects.select_related("status"), pk=self.kwargs.get("pk")) if project_obj.status.name not in [ "Active", "New", @@ -693,21 +734,24 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def get_initial_data(self, project_obj): - allocation_objs = project_obj.allocation_set.filter( + allocation_objs = project_obj.allocation_set.select_related("status").filter( resources__is_allocatable=True, is_locked=False, status__name__in=["Active", "New", "Renewal Requested", "Payment Pending", "Payment Requested", "Paid"], ) - return [ - { - "pk": allocation_obj.pk, - "resource": allocation_obj.get_parent_resource.name, - "details": allocation_obj.get_information, - "resource_type": allocation_obj.get_parent_resource.resource_type.name, - "status": allocation_obj.status.name, - } - for allocation_obj in allocation_objs - ] + initial_data = [] + for allocation_obj in allocation_objs: + resource = allocation_obj.get_parent_resource + initial_data.append( + { + "pk": allocation_obj.pk, + "resource": resource.name, + "details": allocation_obj.get_information, + "resource_type": resource.resource_type.name, + "status": allocation_obj.status.name, + } + ) + return initial_data def post(self, request, *args, **kwargs): user_search_string = request.POST.get("q") @@ -716,15 +760,19 @@ def post(self, request, *args, **kwargs): project_obj = get_object_or_404(Project, pk=pk) - users_to_exclude = [ele.user.username for ele in project_obj.projectuser_set.filter(status__name="Active")] + users_to_exclude = [ + ele.user.username + for ele in project_obj.projectuser_set.select_related("user").filter(status__name="Active") + ] cobmined_user_search_obj = CombinedUserSearch(user_search_string, search_by, users_to_exclude) context = cobmined_user_search_obj.search() matches = context.get("matches") + user_role = ProjectUserRoleChoice.objects.get(name="User") for match in matches: - match.update({"role": ProjectUserRoleChoice.objects.get(name="User")}) + match.update({"role": user_role}) if matches: formset = formset_factory(ProjectAddUserForm, max_num=len(matches)) @@ -785,21 +833,24 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def get_initial_data(self, project_obj): - allocation_objs = project_obj.allocation_set.filter( + allocation_objs = project_obj.allocation_set.select_related("status").filter( resources__is_allocatable=True, is_locked=False, status__name__in=["Active", "New", "Renewal Requested", "Payment Pending", "Payment Requested", "Paid"], ) - return [ - { - "pk": allocation_obj.pk, - "resource": allocation_obj.get_parent_resource.name, - "details": allocation_obj.get_information, - "resource_type": allocation_obj.get_parent_resource.resource_type.name, - "status": allocation_obj.status.name, - } - for allocation_obj in allocation_objs - ] + initial_data = [] + for allocation_obj in allocation_objs: + resource = allocation_obj.get_parent_resource + initial_data.append( + { + "pk": allocation_obj.pk, + "resource": resource.name, + "details": allocation_obj.get_information, + "resource_type": resource.resource_type.name, + "status": allocation_obj.status.name, + } + ) + return initial_data def post(self, request, *args, **kwargs): user_search_string = request.POST.get("q") @@ -815,8 +866,9 @@ def post(self, request, *args, **kwargs): context = cobmined_user_search_obj.search() matches = context.get("matches") + project_user_role = ProjectUserRoleChoice.objects.get(name="User") for match in matches: - match.update({"role": ProjectUserRoleChoice.objects.get(name="User")}) + match.update({"role": project_user_role}) formset = formset_factory(ProjectAddUserForm, max_num=len(matches)) formset = formset(request.POST, initial=matches, prefix="userform") @@ -852,16 +904,17 @@ def post(self, request, *args, **kwargs): added_users_count += 1 # Will create local copy of user if not already present in local database - user_obj, _ = User.objects.get_or_create(username=user_form_data.get("username")) - user_obj.first_name = user_form_data.get("first_name") - user_obj.last_name = user_form_data.get("last_name") - user_obj.email = user_form_data.get("email") - user_obj.save() + user_obj, created = User.objects.get_or_create(username=user_form_data.get("username")) + if created: + user_obj.first_name = user_form_data.get("first_name") + user_obj.last_name = user_form_data.get("last_name") + user_obj.email = user_form_data.get("email") + user_obj.save() role_choice = user_form_data.get("role") # Is the user already in the project? - if project_obj.projectuser_set.filter(user=user_obj).exists(): - project_user_obj = project_obj.projectuser_set.get(user=user_obj) + project_user_obj = project_obj.projectuser_set.filter(user=user_obj).first() + if project_user_obj: project_user_obj.role = role_choice project_user_obj.status = project_user_active_status_choice project_user_obj.save() @@ -879,8 +932,9 @@ def post(self, request, *args, **kwargs): for allocation in allocations_selected_objs: has_eula = allocation.get_eula() user_status_choice = allocation_user_active_status_choice - if allocation.allocationuser_set.filter(user=user_obj).exists(): - allocation_user_obj = allocation.allocationuser_set.get(user=user_obj) + allocation_user_query = allocation.allocationuser_set.filter(user=user_obj) + if allocation_user_query: + allocation_user_obj = allocation_user_query.first() if ( ALLOCATION_EULA_ENABLE and has_eula @@ -1474,10 +1528,9 @@ def test_func(self): messages.error(self.request, "You do not have permission to add project attributes.") def get_avail_attrs(self, project_obj): + avail_attrs = ProjectAttribute.objects.select_related("proj_attr_type").filter(project=project_obj) if not self.request.user.is_superuser: - avail_attrs = ProjectAttribute.objects.filter(project=project_obj, proj_attr_type__is_private=False) - else: - avail_attrs = ProjectAttribute.objects.filter(project=project_obj) + avail_attrs = avail_attrs.filter(proj_attr_type__is_private=False) avail_attrs_dicts = [ {"pk": attr.pk, "selected": False, "name": str(attr.proj_attr_type), "value": attr.value} for attr in avail_attrs diff --git a/coldfront/core/resource/views.py b/coldfront/core/resource/views.py index fd03e8b483..be34e145c8 100644 --- a/coldfront/core/resource/views.py +++ b/coldfront/core/resource/views.py @@ -209,19 +209,21 @@ def get_queryset(self): order_by = direction + order_by resource_search_form = ResourceSearchForm(self.request.GET) - + resources = Resource.objects.select_related( + "parent_resource", "parent_resource__resource_type", "resource_type" + ).all() if resource_search_form.is_valid(): data = resource_search_form.cleaned_data if order_by == "name": direction = self.request.GET.get("direction") if direction == "asc": - resources = Resource.objects.all().order_by(Lower("name")) + resources = resources.order_by(Lower("name")) elif direction == "des": - resources = Resource.objects.all().order_by(Lower("name")).reverse() + resources = resources.order_by(Lower("name")).reverse() else: - resources = Resource.objects.all().order_by(order_by) + resources = resources.order_by(order_by) else: - resources = Resource.objects.all().order_by(order_by) + resources = resources.order_by(order_by) if data.get("show_allocatable_resources"): resources = resources.filter(is_allocatable=True) @@ -264,13 +266,13 @@ def get_queryset(self): if order_by == "name": direction = self.request.GET.get("direction") if direction == "asc": - resources = Resource.objects.all().order_by(Lower("name")) + resources = resources.order_by(Lower("name")) elif direction == "des": - resources = Resource.objects.all().order_by(Lower("name").reverse()) + resources = resources.order_by(Lower("name").reverse()) else: - resources = Resource.objects.all().order_by(order_by) + resources = resources.order_by(order_by) else: - resources = Resource.objects.all().order_by(order_by) + resources = resources.order_by(order_by) return resources.distinct() def get_context_data(self, **kwargs): diff --git a/coldfront/core/utils/templatetags/common_tags.py b/coldfront/core/utils/templatetags/common_tags.py index 5c66feedbe..ed02ada66d 100644 --- a/coldfront/core/utils/templatetags/common_tags.py +++ b/coldfront/core/utils/templatetags/common_tags.py @@ -41,15 +41,17 @@ def convert_boolean_to_icon(boolean): @register.filter def convert_status_to_icon(project): - if project.last_project_review: - status = project.last_project_review.status.name + last_project_review = project.last_project_review + needs_review = project.needs_review + if last_project_review: + status = last_project_review.status.name if status == "Pending": return mark_safe('

') elif status == "Completed": return mark_safe('

') - elif project.needs_review and not project.last_project_review: + elif needs_review and not last_project_review: return mark_safe('

') - elif not project.needs_review: + elif not needs_review: return mark_safe('

') From 126b81e6dfe5cf751029b1c9462cfdf5d62c48a1 Mon Sep 17 00:00:00 2001 From: simonLeary42 <71396965+simonLeary42@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:35:42 -0600 Subject: [PATCH 082/110] fix plural of "match" --- .../project/templates/project/add_user_search_results.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coldfront/core/project/templates/project/add_user_search_results.html b/coldfront/core/project/templates/project/add_user_search_results.html index 683380a618..a38e7d70fb 100644 --- a/coldfront/core/project/templates/project/add_user_search_results.html +++ b/coldfront/core/project/templates/project/add_user_search_results.html @@ -7,7 +7,7 @@ Found {{number_of_usernames_found}} of {{number_of_usernames_searched}} usernames searched. {% elif matches %} - Found {{matches|length}} match{{matches|length|pluralize}}. + Found {{matches|length}} match{{matches|length|pluralize:"es"}}. {% endif %}
{% if usernames_not_found %} @@ -129,4 +129,4 @@ }); -{% comment %} if eula box is in focus, then required {% endcomment %} \ No newline at end of file +{% comment %} if eula box is in focus, then required {% endcomment %} From 41aa10d373829c9c90fcdce17f95da97f22f6dcd Mon Sep 17 00:00:00 2001 From: Matthew Kusz Date: Thu, 20 Nov 2025 14:40:18 -0500 Subject: [PATCH 083/110] Add additional access checks for existing project tests and fix comments Signed-off-by: Matthew Kusz --- coldfront/core/project/tests/test_views.py | 39 ++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/coldfront/core/project/tests/test_views.py b/coldfront/core/project/tests/test_views.py index 0aa02eff99..cf95c7d6e3 100644 --- a/coldfront/core/project/tests/test_views.py +++ b/coldfront/core/project/tests/test_views.py @@ -49,7 +49,7 @@ def project_access_tstbase(self, url): """ # If not logged in, can't see page; redirect to login page. utils.test_logged_out_redirect_to_login(self, url) - # after login, pi and admin can access create page + # If logged in as admin, can access page utils.test_user_can_access(self, self.admin_user, url) @@ -64,7 +64,7 @@ def setUpTestData(cls): def test_projectdetail_access(self): """Test project detail page access""" - # logged-out user gets redirected, admin can access create page + # logged-out user gets redirected, admin can access detail page self.project_access_tstbase(self.url) # pi and projectuser can access utils.test_user_can_access(self, self.pi_user.user, self.url) @@ -205,8 +205,9 @@ def setUpTestData(cls): def test_project_attribute_update_access(self): """Test access to project attribute update page""" self.project_access_tstbase(self.url) + # pi can access project attribute update page utils.test_user_can_access(self, self.pi_user.user, self.url) - # project user, pi, and nonproject user cannot access update page + # project user and nonproject user cannot access project attribute update page utils.test_user_cannot_access(self, self.project_user.user, self.url) utils.test_user_cannot_access(self, self.nonproject_user, self.url) @@ -310,6 +311,11 @@ def setUp(self): def test_projectremoveusersview_access(self): """test access to project remove users page""" self.project_access_tstbase(self.url) + # pi can access remove users page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot remove users page + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectUpdateViewTest(ProjectViewTestBase): @@ -322,6 +328,11 @@ def setUp(self): def test_projectupdateview_access(self): """test access to project update page""" self.project_access_tstbase(self.url) + # pi can access project update page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot access project update page + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectReviewListViewTest(ProjectViewTestBase): @@ -334,6 +345,10 @@ def setUp(self): def test_projectreviewlistview_access(self): """test access to project review list page""" self.project_access_tstbase(self.url) + # pi, projectuser and nonproject user cannot access review list page + utils.test_user_cannot_access(self, self.pi_user.user, self.url) + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectArchivedListViewTest(ProjectViewTestBase): @@ -346,6 +361,10 @@ def setUp(self): def test_projectarchivedlistview_access(self): """test access to project archived list page""" self.project_access_tstbase(self.url) + # all other users can access archive list page + utils.test_user_can_access(self, self.pi_user.user, self.url) + utils.test_user_can_access(self, self.project_user.user, self.url) + utils.test_user_can_access(self, self.nonproject_user, self.url) class ProjectNoteCreateViewTest(ProjectViewTestBase): @@ -358,6 +377,10 @@ def setUp(self): def test_projectnotecreateview_access(self): """test access to project note create page""" self.project_access_tstbase(self.url) + # pi, projectuser and nonproject user cannot access note create page + utils.test_user_cannot_access(self, self.pi_user.user, self.url) + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectAddUsersSearchView(ProjectViewTestBase): @@ -370,6 +393,11 @@ def setUp(self): def test_projectadduserssearchview_access(self): """test access to project add users search page""" self.project_access_tstbase(self.url) + # pi can access add users search page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot access add users search page + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) class ProjectUserDetailViewTest(ProjectViewTestBase): @@ -382,3 +410,8 @@ def setUp(self): def test_projectuserdetailview_access(self): """test access to project user detail page""" self.project_access_tstbase(self.url) + # pi can access user detail page + utils.test_user_can_access(self, self.pi_user.user, self.url) + # project user and nonproject user cannot access user detail page + utils.test_user_cannot_access(self, self.project_user.user, self.url) + utils.test_user_cannot_access(self, self.nonproject_user, self.url) From 825be7c4a733c1ed0bf9e77a826d17fcb0ab5fb8 Mon Sep 17 00:00:00 2001 From: simonLeary42 <71396965+simonLeary42@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:31:58 -0500 Subject: [PATCH 084/110] add missing allocation statuses --- .../allocation/management/commands/add_allocation_defaults.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coldfront/core/allocation/management/commands/add_allocation_defaults.py b/coldfront/core/allocation/management/commands/add_allocation_defaults.py index 9f7dc019ba..f63de84a7a 100644 --- a/coldfront/core/allocation/management/commands/add_allocation_defaults.py +++ b/coldfront/core/allocation/management/commands/add_allocation_defaults.py @@ -22,6 +22,7 @@ def handle(self, *args, **options): for choice in ( "Active", + "Approved", "Denied", "Expired", "New", @@ -29,6 +30,7 @@ def handle(self, *args, **options): "Payment Pending", "Payment Requested", "Payment Declined", + "Pending", "Renewal Requested", "Revoked", "Unpaid", From 2836daf37fa0e1ea07f8aeb29899c899b5bc5bfb Mon Sep 17 00:00:00 2001 From: Cecilia Lau Date: Thu, 27 Nov 2025 13:43:12 -0500 Subject: [PATCH 085/110] Add add_user(), remove_user(), and get_absolute_url() to models Squashed commits: - Add remove_user() and get_absolute_url() - Add remove_user() to Project model - Remove get_absolute_url() for AllocationUserNote and add get_absolute_url() for Project - Update views to use models' get_absolute_url() and remove_user() - Add add_user() and activate_user() to Allocation model - add add_user() to Project model - Add Allocation tests for activate_user(), add_user(), and remove_user() - Add tests for AllocationAddUsersView and AllocationRemoveUsersView - Remove Allocation.activate_user() - Fix Allocation.add_user() signal sending logic - Add kwarg for suppressing exception in Allocation.remove_user() and revert removing user when Allocation is disabled - Merge branch 'main' into allocation_model_funcs Signed-off-by: Cecilia Lau --- coldfront/core/allocation/models.py | 78 ++++++++++ .../core/allocation/tests/test_models.py | 63 ++++++++ coldfront/core/allocation/tests/test_views.py | 98 ++++++++++++- coldfront/core/allocation/views.py | 135 ++++-------------- coldfront/core/project/models.py | 66 +++++++++ coldfront/core/project/views.py | 112 +++------------ 6 files changed, 346 insertions(+), 206 deletions(-) diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index b41b6fe2fa..6cc15b3daf 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -7,9 +7,11 @@ from ast import literal_eval from enum import Enum +from django.contrib.auth import get_user_model from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models +from django.urls import reverse from django.utils.html import escape, format_html from django.utils.module_loading import import_string from django.utils.safestring import SafeString @@ -17,9 +19,12 @@ from simple_history.models import HistoricalRecords import coldfront.core.attribute_expansion as attribute_expansion +from coldfront.config.core import ALLOCATION_EULA_ENABLE +from coldfront.core.allocation.signals import allocation_activate_user, allocation_remove_user from coldfront.core.project.models import Project, ProjectPermission from coldfront.core.resource.models import Resource from coldfront.core.utils.common import import_from_settings +from coldfront.core.utils.mail import build_link, send_email_template logger = logging.getLogger(__name__) @@ -27,6 +32,8 @@ ALLOCATION_FUNCS_ON_EXPIRE = import_from_settings("ALLOCATION_FUNCS_ON_EXPIRE", []) ALLOCATION_RESOURCE_ORDERING = import_from_settings("ALLOCATION_RESOURCE_ORDERING", ["-is_allocatable", "name"]) +EMAIL_SENDER = import_from_settings("EMAIL_SENDER") + class AllocationPermission(Enum): """An allocation permission stores the user and manager fields of a project.""" @@ -355,6 +362,74 @@ def get_eula(self): else: return None + def add_user(self, user, signal_sender=None): + """ + Adds a user to the allocation. + + If EULAs are enabled and this allocation has an associated EULA, marks the user + as "PendingEULA" and sends the user an email asking them to agree to the EULA. + Otherwise, marks the user as "Active." Also sends the `allocation_activate_user` + signal if the allocation status is "Active." + + Params: + user (User): User to add. + signal_sender (str): Sender for the `allocation_activate_user` signal. + """ + user_status = "Active" + + is_pending_eula = ALLOCATION_EULA_ENABLE and self.get_eula() and not user.userprofile.is_pi + if is_pending_eula: + user_status = "PendingEULA" + user_status_obj = AllocationUserStatusChoice.objects.get(name=user_status) + + allocation_user, _created = self.allocationuser_set.update_or_create( + user=user, defaults={"status": user_status_obj} + ) + + if is_pending_eula: + send_email_template( + f"Agree to EULA for {self.get_parent_resource.__str__()}", + "email/allocation_agree_to_eula.txt", + { + "resource": self.get_parent_resource, + "url": build_link(reverse("allocation-review-eula", kwargs={"pk": self.pk})), + }, + EMAIL_SENDER, + [user.email], + ) + + if self.status.name == "Active" and allocation_user.status.name == "Active": + allocation_activate_user.send(sender=signal_sender, allocation_user_pk=allocation_user.pk) + + def remove_user(self, user, signal_sender=None, ignore_user_not_found=True): + """ + Marks an `AllocationUser` as 'Removed' and sends the `allocation_remove_user` signal. + + Params: + user (User|AllocationUser): User to remove. + signal_sender (str): Sender for the `allocation_remove_user` signal. + ignore_user_not_found (bool): + """ + if isinstance(user, AllocationUser): + allocation_user = user + elif isinstance(user, get_user_model()): + try: + allocation_user = self.allocationuser_set.get(user=user) + except AllocationUser.DoesNotExist: + if ignore_user_not_found: + logger.warn( + f"Cannot remove user={str(user)} for allocation pk={self.pk} - AllocationUser not found." + ) + return + else: + raise + allocation_user.status = AllocationUserStatusChoice.objects.get(name="Removed") + allocation_user.save() + allocation_remove_user.send(sender=signal_sender, allocation_user_pk=allocation_user.pk) + + def get_absolute_url(self): + return reverse("allocation-detail", kwargs={"pk": self.pk}) + class AllocationAdminNote(TimeStampedModel): """An allocation admin note is a note that an admin makes on an allocation. @@ -724,6 +799,9 @@ def get_parent_resource(self): def __str__(self): return "%s (%s)" % (self.get_parent_resource.name, self.allocation.project.pi) + def get_absolute_url(self): + return reverse("allocation-change-detail", kwargs={"pk": self.pk}) + class AllocationAttributeChangeRequest(TimeStampedModel): """An allocation attribute change request represents a request from a PI/ manager to change their allocation attribute. diff --git a/coldfront/core/allocation/tests/test_models.py b/coldfront/core/allocation/tests/test_models.py index 997a1e8c00..06d96fec43 100644 --- a/coldfront/core/allocation/tests/test_models.py +++ b/coldfront/core/allocation/tests/test_models.py @@ -16,6 +16,7 @@ from coldfront.core.allocation.models import ( Allocation, AllocationStatusChoice, + AllocationUser, ) from coldfront.core.project.models import Project from coldfront.core.test_helpers.factories import ( @@ -24,6 +25,8 @@ AllocationAttributeTypeFactory, AllocationFactory, AllocationStatusChoiceFactory, + AllocationUserFactory, + AllocationUserStatusChoiceFactory, ProjectFactory, ResourceFactory, UserFactory, @@ -45,6 +48,66 @@ def test_allocation_str(self): self.assertEqual(str(self.allocation), allocation_str) +class AllocationModelUserMethodTests(TestCase): + """tests for Allocation model add_user, and remove_user methods""" + + @classmethod + def setUpTestData(cls): + """Set up project to test model properties and methods""" + active_ausc = AllocationUserStatusChoiceFactory(name="Active") + removed_ausc = AllocationUserStatusChoiceFactory(name="Removed") + cls.allocation = AllocationFactory(status=AllocationStatusChoiceFactory(name="Active")) + cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) + cls.user = UserFactory() + cls.allocation_user_active = AllocationUserFactory(allocation=cls.allocation, status=active_ausc) + cls.allocation_user_removed = AllocationUserFactory(allocation=cls.allocation, status=removed_ausc) + + @patch("coldfront.core.allocation.signals.allocation_activate_user.send") + def test_active_allocation_add_user(self, mock): + """Test that allocation add_user method activates the given user and sends the allocation_activate_user signal""" + self.allocation.add_user(user=self.user, signal_sender="test") + self.assertEqual(mock.call_args.kwargs.get("sender"), "test") + self.allocation.add_user(user=self.allocation_user_active.user, signal_sender="test") + mock.assert_called_with(sender="test", allocation_user_pk=self.allocation_user_active.pk) + self.allocation.add_user(user=self.allocation_user_removed.user, signal_sender="test") + mock.assert_called_with(sender="test", allocation_user_pk=self.allocation_user_removed.pk) + + self.assertEqual(AllocationUser.objects.get(user__pk=self.user.pk).status.name, "Active") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_active.pk).status.name, "Active") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_removed.pk).status.name, "Active") + + @patch("coldfront.core.allocation.signals.allocation_activate_user.send") + def test_inactive_allocation_add_user(self, mock): + """Test that allocation add_user method activates the given user and the allocation_activate_user signal is not sent""" + self.allocation.status = AllocationStatusChoiceFactory(name="Pending") + self.allocation.save() + + self.allocation.add_user(user=self.user, signal_sender="test") + mock.assert_not_called() + self.allocation.add_user(user=self.allocation_user_active.user, signal_sender="test") + mock.assert_not_called() + self.allocation.add_user(user=self.allocation_user_removed.user, signal_sender="test") + mock.assert_not_called() + + self.assertEqual(AllocationUser.objects.get(user__pk=self.user.pk).status.name, "Active") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_active.pk).status.name, "Active") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_removed.pk).status.name, "Active") + + @patch("coldfront.core.allocation.signals.allocation_remove_user.send") + def test_remove_user(self, mock): + """Test that allocation remove_user method removes the given user and sends the allocation_remove_user signal""" + self.allocation.remove_user(user=self.user, signal_sender="test") + mock.assert_not_called() + self.allocation.remove_user(user=self.allocation_user_active.user, signal_sender="test") + mock.assert_called_with(sender="test", allocation_user_pk=self.allocation_user_active.pk) + self.allocation.remove_user(user=self.allocation_user_removed.user, signal_sender="test") + mock.assert_called_with(sender="test", allocation_user_pk=self.allocation_user_removed.pk) + + self.assertFalse(AllocationUser.objects.filter(user__pk=self.user.pk).exists()) + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_active.pk).status.name, "Removed") + self.assertEqual(AllocationUser.objects.get(pk=self.allocation_user_removed.pk).status.name, "Removed") + + class AllocationModelCleanMethodTests(TestCase): """tests for Allocation model clean method""" diff --git a/coldfront/core/allocation/tests/test_views.py b/coldfront/core/allocation/tests/test_views.py index ea976f178b..d09a5606ba 100644 --- a/coldfront/core/allocation/tests/test_views.py +++ b/coldfront/core/allocation/tests/test_views.py @@ -31,6 +31,7 @@ AllocationFactory, AllocationStatusChoiceFactory, AllocationUserFactory, + AllocationUserStatusChoiceFactory, ProjectFactory, ProjectStatusChoiceFactory, ProjectUserFactory, @@ -55,6 +56,7 @@ def setUpTestData(cls): pi_user: User = UserFactory() pi_user.userprofile.is_pi = True AllocationStatusChoiceFactory(name="New") + AllocationUserStatusChoiceFactory(name="Removed") cls.project: Project = ProjectFactory(pi=pi_user, status=ProjectStatusChoiceFactory(name="Active")) cls.allocation: Allocation = AllocationFactory(project=cls.project, end_date=date.today()) cls.allocation.resources.add(ResourceFactory(name="holylfs07/tier1")) @@ -63,7 +65,7 @@ def setUpTestData(cls): cls.allocation_user: User = allocation_user.user ProjectUserFactory(project=cls.project, user=allocation_user.user) # create project user that isn't an allocationuser - proj_nonallocation_user: ProjectUser = ProjectUserFactory() + proj_nonallocation_user: ProjectUser = ProjectUserFactory(project=cls.project) cls.proj_nonallocation_user = proj_nonallocation_user.user cls.admin_user: User = UserFactory(is_staff=True, is_superuser=True) manager_role: ProjectUserRoleChoice = ProjectUserRoleChoiceFactory(name="Manager") @@ -438,8 +440,19 @@ def test_allocationcreateview_post_zeroquantity(self): class AllocationAddUsersViewTest(AllocationViewBaseTest): """Tests for the AllocationAddUsersView""" - def setUp(self): - self.url = f"/allocation/{self.allocation.pk}/add-users" + @classmethod + def setUpTestData(cls): + """Setup POST data""" + super().setUpTestData() + cls.post_data = { + "userform-0-selected": True, + "userform-TOTAL_FORMS": "1", + "userform-INITIAL_FORMS": "1", + "userform-MIN_NUM_FORMS": "0", + "userform-MAX_NUM_FORMS": "1", + "end_date_extension": 0, + } + cls.url = f"/allocation/{cls.allocation.pk}/add-users" def test_allocationaddusersview_access(self): """Test access to AllocationAddUsersView""" @@ -458,15 +471,90 @@ def test_allocationaddusersview_access(self): user_response = self.client.get(self.url) self.assertTrue(no_permission in str(user_response.content)) + def test_allocationaddusersview_post_user(self): + """Test that posting to AllocationAddUsersView as unpriviliged user fails""" + self.client.force_login(self.allocation_user, backend=BACKEND) + self.assertEqual(len(self.allocation.allocationuser_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 403) + + def test_allocationaddusersview_post_pi(self): + """Test that posting to AllocationAddUsersView as a PI works""" + self.client.force_login(self.pi_user, backend=BACKEND) + self.assertEqual(len(self.allocation.allocationuser_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Added 1 user to allocation.") + self.assertEqual(len(self.allocation.allocationuser_set.all()), 2) + + def test_allocationaddusersview_post_admin(self): + """Test that posting to AllocationAddUsersView as a superuser works""" + self.client.force_login(self.admin_user, backend=BACKEND) + self.assertEqual(len(self.allocation.allocationuser_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Added 1 user to allocation.") + self.assertEqual(len(self.allocation.allocationuser_set.all()), 2) + class AllocationRemoveUsersViewTest(AllocationViewBaseTest): """Tests for the AllocationRemoveUsersView""" - def setUp(self): - self.url = f"/allocation/{self.allocation.pk}/remove-users" + @classmethod + def setUpTestData(cls): + """Setup POST data""" + super().setUpTestData() + cls.post_data = { + "userform-0-selected": True, + "userform-TOTAL_FORMS": "1", + "userform-INITIAL_FORMS": "1", + "userform-MIN_NUM_FORMS": "0", + "userform-MAX_NUM_FORMS": "1", + "end_date_extension": 0, + } + cls.url = f"/allocation/{cls.allocation.pk}/remove-users" def test_allocationremoveusersview_access(self): + """Test access to AllocationRemoveUsersView""" self.allocation_access_tstbase(self.url) + no_permission = "You do not have permission to remove users from allocation." + + self.client.force_login(self.admin_user, backend=BACKEND) + admin_response = self.client.get(self.url) + self.assertTrue(no_permission not in str(admin_response.content)) + + self.client.force_login(self.pi_user, backend=BACKEND) + pi_response = self.client.get(self.url) + self.assertTrue(no_permission not in str(pi_response.content)) + + self.client.force_login(self.allocation_user, backend=BACKEND) + user_response = self.client.get(self.url) + self.assertTrue(no_permission in str(user_response.content)) + + def test_allocationremoveusersview_post_user(self): + """Test that posting to AllocationRemoveUsersView as unpriviliged user fails""" + self.client.force_login(self.allocation_user, backend=BACKEND) + self.assertEqual(len(self.allocation.allocationuser_set.all()), 1) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 403) + + def test_allocationremoveusersview_post_pi(self): + """Test that posting to AllocationRemoveUsersView as a PI works""" + self.client.force_login(self.pi_user, backend=BACKEND) + self.assertTrue(self.allocation.allocationuser_set.filter(status__name="Active").exists()) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Removed 1 user from allocation.") + self.assertEqual(len(self.allocation.allocationuser_set.filter(status__name="Removed")), 1) + + def test_allocationremoveusersview_post_admin(self): + """Test that posting to AllocationRemoveUsersView as a superuser works""" + self.client.force_login(self.admin_user, backend=BACKEND) + self.assertTrue(self.allocation.allocationuser_set.filter(status__name="Active").exists()) + response = self.client.post(self.url, data=self.post_data, follow=True) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Removed 1 user from allocation.") + self.assertEqual(len(self.allocation.allocationuser_set.filter(status__name="Removed")), 1) class AllocationChangeListViewTest(AllocationViewBaseTest): diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index c144cfc688..d98f80893d 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -17,7 +17,7 @@ from django.db.models.query import QuerySet from django.forms import formset_factory from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.utils.html import format_html from django.views import View @@ -68,15 +68,13 @@ allocation_remove_user, ) from coldfront.core.allocation.utils import generate_guauge_data_from_usage, get_user_resources -from coldfront.core.project.models import Project, ProjectPermission, ProjectUser, ProjectUserStatusChoice +from coldfront.core.project.models import Project, ProjectPermission from coldfront.core.resource.models import Resource from coldfront.core.utils.common import get_domain_url, import_from_settings from coldfront.core.utils.mail import ( - build_link, send_allocation_admin_email, send_allocation_customer_email, send_allocation_eula_customer_email, - send_email_template, ) ALLOCATION_ENABLE_ALLOCATION_RENEWAL = import_from_settings("ALLOCATION_ENABLE_ALLOCATION_RENEWAL", True) @@ -224,7 +222,7 @@ def post(self, request, *args, **kwargs): if not self.request.user.is_superuser: messages.success(request, "You do not have permission to update the allocation") - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + return redirect(allocation_obj) initial_data = { "status": allocation_obj.status, @@ -333,7 +331,7 @@ def post(self, request, *args, **kwargs): ) return HttpResponseRedirect(reverse("allocation-request-list")) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + return redirect(allocation_obj) class AllocationEULAView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): @@ -800,7 +798,7 @@ def dispatch(self, request, *args, **kwargs): message = f"You cannot add users to an allocation with status {allocation_obj.status.name}." if message: messages.error(request, message) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) return super().dispatch(request, *args, **kwargs) def get_users_to_add(self, allocation_obj): @@ -874,62 +872,12 @@ def post(self, request, *args, **kwargs): users_added_count = 0 if formset.is_valid(): - allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get(name="Active") - if ALLOCATION_EULA_ENABLE: - allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get(name="PendingEULA") - for form in formset: user_form_data = form.cleaned_data if user_form_data["selected"]: users_added_count += 1 - user_obj = get_user_model().objects.get(username=user_form_data.get("username")) - - allocation_user_obj = allocation_obj.allocationuser_set.filter(user=user_obj).first() - if allocation_user_obj: - if ALLOCATION_EULA_ENABLE and not user_obj.userprofile.is_pi and allocation_obj.get_eula(): - allocation_user_obj.status = allocation_user_pending_status_choice - send_email_template( - f"Agree to EULA for {allocation_obj.get_parent_resource.__str__()}", - "email/allocation_agree_to_eula.txt", - { - "resource": allocation_obj.get_parent_resource, - "url": build_link( - reverse("allocation-review-eula", kwargs={"pk": allocation_obj.pk}), - domain_url=get_domain_url(self.request), - ), - }, - self.request.user.email, - [user_obj], - ) - else: - allocation_user_obj.status = allocation_user_active_status_choice - allocation_user_obj.save() - else: - if ALLOCATION_EULA_ENABLE and not user_obj.userprofile.is_pi and allocation_obj.get_eula(): - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, user=user_obj, status=allocation_user_pending_status_choice - ) - send_email_template( - f"Agree to EULA for {allocation_obj.get_parent_resource.__str__()}", - "email/allocation_agree_to_eula.txt", - { - "resource": allocation_obj.get_parent_resource, - "url": build_link( - reverse("allocation-review-eula", kwargs={"pk": allocation_obj.pk}), - domain_url=get_domain_url(self.request), - ), - }, - self.request.user.email, - [user_obj], - ) - else: - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation_obj, user=user_obj, status=allocation_user_active_status_choice - ) - - if allocation_user_obj.status == allocation_user_active_status_choice: - allocation_activate_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) + allocation_obj.add_user(user_obj, signal_sender=self.__class__) user_plural = "user" if users_added_count == 1 else "users" messages.success(request, f"Added {users_added_count} {user_plural} to allocation.") @@ -937,7 +885,7 @@ def post(self, request, *args, **kwargs): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + return redirect(allocation_obj) class AllocationRemoveUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): @@ -966,7 +914,7 @@ def dispatch(self, request, *args, **kwargs): message = f"You cannot remove users from a allocation with status {allocation_obj.status.name}." if message: messages.error(request, message) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) return super().dispatch(request, *args, **kwargs) def get_users_to_remove(self, allocation_obj): @@ -1023,7 +971,6 @@ def post(self, request, *args, **kwargs): remove_users_count = 0 if formset.is_valid(): - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get(name="Removed") for form in formset: user_form_data = form.cleaned_data if user_form_data["selected"]: @@ -1033,10 +980,7 @@ def post(self, request, *args, **kwargs): if allocation_obj.project.pi == user_obj: continue - allocation_user_obj = allocation_obj.allocationuser_set.get(user=user_obj) - allocation_user_obj.status = allocation_user_removed_status_choice - allocation_user_obj.save() - allocation_remove_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) + allocation_obj.remove_user(user_obj, signal_sender=self.__class__) user_plural = "user" if remove_users_count == 1 else "users" messages.success(request, f"Removed {remove_users_count} {user_plural} from allocation.") @@ -1044,7 +988,7 @@ def post(self, request, *args, **kwargs): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + return redirect(allocation_obj) class AllocationAttributeCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): @@ -1081,6 +1025,7 @@ def get_form(self, form_class=None): return form def get_success_url(self): + # can probably be replaced with `return self.object.allocation.get_absolute_url()` return reverse("allocation-detail", kwargs={"pk": self.kwargs.get("pk")}) @@ -1149,7 +1094,7 @@ def post(self, request, *args, **kwargs): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + return redirect(allocation_obj) class AllocationNoteCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): @@ -1190,6 +1135,7 @@ def get_form(self, form_class=None): return form def get_success_url(self): + # can probably be replaced with `return self.object.allocation.get_absolute_url()` return reverse("allocation-detail", kwargs={"pk": self.kwargs.get("pk")}) @@ -1256,21 +1202,21 @@ def dispatch(self, request, *args, **kwargs): request, "Allocation renewal is disabled. Request a new allocation to this resource if you want to continue using it after the active until date.", ) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) if allocation_obj.status.name not in [ "Active", ]: messages.error(request, f"You cannot renew a allocation with status {allocation_obj.status.name}.") - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) if allocation_obj.project.needs_review: messages.error(request, "You cannot renew your allocation because you have to review your project first.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": allocation_obj.project.pk})) + return redirect(allocation_obj.project) if allocation_obj.expires_in > 60: messages.error(request, "It is too soon to review your allocation.") - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) return super().dispatch(request, *args, **kwargs) @@ -1327,8 +1273,6 @@ def post(self, request, *args, **kwargs): formset = formset(request.POST, initial=users_in_allocation, prefix="userform") allocation_renewal_requested_status_choice = AllocationStatusChoice.objects.get(name="Renewal Requested") - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get(name="Removed") - project_user_remove_status_choice = ProjectUserStatusChoice.objects.get(name="Removed") allocation_obj.status = allocation_renewal_requested_status_choice allocation_obj.save() @@ -1341,36 +1285,10 @@ def post(self, request, *args, **kwargs): user_status = user_form_data.get("user_status") if user_status == "keep_in_project_only": - allocation_user_obj = allocation_obj.allocationuser_set.get(user=user_obj) - allocation_user_obj.status = allocation_user_removed_status_choice - allocation_user_obj.save() - - allocation_remove_user.send(sender=self.__class__, allocation_user_pk=allocation_user_obj.pk) + allocation_obj.remove_user(user_obj, signal_sender=self.__class__) elif user_status == "remove_from_project": - for active_allocation in allocation_obj.project.allocation_set.filter( - status__name__in=( - "Active", - "Denied", - "New", - "Paid", - "Payment Pending", - "Payment Requested", - "Payment Declined", - "Renewal Requested", - "Unpaid", - ) - ): - allocation_user_obj = active_allocation.allocationuser_set.get(user=user_obj) - allocation_user_obj.status = allocation_user_removed_status_choice - allocation_user_obj.save() - allocation_remove_user.send( - sender=self.__class__, allocation_user_pk=allocation_user_obj.pk - ) - - project_user_obj = ProjectUser.objects.get(project=allocation_obj.project, user=user_obj) - project_user_obj.status = project_user_remove_status_choice - project_user_obj.save() + allocation_obj.project.remove_user(user_obj, signal_sender=self.__class__) send_allocation_admin_email( allocation_obj, @@ -1383,7 +1301,7 @@ def post(self, request, *args, **kwargs): if not formset.is_valid(): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": allocation_obj.project.pk})) + return redirect(allocation_obj.project) class AllocationInvoiceListView(LoginRequiredMixin, UserPassesTestMixin, ListView): @@ -1996,18 +1914,18 @@ def dispatch(self, request, *args, **kwargs): messages.error( request, "You cannot request a change to this allocation because you have to review your project first." ) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) if allocation_obj.project.status.name not in [ "Active", "New", ]: messages.error(request, "You cannot request a change to an allocation in an archived project.") - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) if allocation_obj.is_locked: messages.error(request, "You cannot request a change to a locked allocation.") - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) if allocation_obj.status.name not in [ "Active", @@ -2019,7 +1937,7 @@ def dispatch(self, request, *args, **kwargs): messages.error( request, f'You cannot request a change to an allocation with status "{allocation_obj.status.name}".' ) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": allocation_obj.pk})) + return redirect(allocation_obj) return super().dispatch(request, *args, **kwargs) @@ -2145,7 +2063,7 @@ def post(self, request, *args, **kwargs): url_path=reverse("allocation-change-list"), domain_url=get_domain_url(self.request), ) - return HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + return redirect(allocation_obj) class AllocationAttributeEditView(LoginRequiredMixin, UserPassesTestMixin, FormView): @@ -2203,7 +2121,8 @@ def post(self, request, *args, **kwargs): allocation_obj = get_object_or_404(Allocation, pk=pk) allocation_attributes_to_change = self.get_allocation_attributes_to_change(allocation_obj) - ok_redirect = HttpResponseRedirect(reverse("allocation-detail", kwargs={"pk": pk})) + ok_redirect = redirect(allocation_obj) + if not allocation_attributes_to_change: return ok_redirect diff --git a/coldfront/core/project/models.py b/coldfront/core/project/models.py index cd7427d88d..9ee49c79a2 100644 --- a/coldfront/core/project/models.py +++ b/coldfront/core/project/models.py @@ -5,14 +5,17 @@ import datetime from enum import Enum +from django.contrib.auth import get_user_model from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models +from django.urls import reverse from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords from coldfront.core.field_of_science.models import FieldOfScience +from coldfront.core.project.signals import project_activate_user, project_remove_user from coldfront.core.utils.common import import_from_settings from coldfront.core.utils.validate import AttributeValidator @@ -243,6 +246,69 @@ def __str__(self): def natural_key(self): return (self.title,) + self.pi.natural_key() + def add_user(self, user, role_choice, signal_sender=None): + """ + Adds a user to the project. + + If a ProjectUser already exists, its role will be set to "Active" and its role updated. + Otherwise, creates a new ProjectUser. + + Params: + user (User): User to add. + role_choice (ProjetUserRoleChoice): Role to give the project user. + signal_sender (str): Sender for the `project_activate_user` signal. + """ + user_status_obj = ProjectUserStatusChoice.objects.get(name="Active") + + project_user, _created = self.projectuser_set.update_or_create( + user=user, + defaults={ + "status": user_status_obj, + "role": role_choice, + }, + ) + + project_activate_user.send(sender=signal_sender, project_user_pk=project_user.pk) + + def remove_user(self, user, signal_sender=None): + """ + Marks a `ProjectUser` and any associated `AllocationUser`s as 'Removed'. + + Params: + user (User|ProjectUser): User to remove. + signal_sender (str): Sender for the `project_remove_user` and `allocation_remove_user` signals. + + Raises: + ProjectUser.DoesNotExist: If `user` is a `User` and that user is not found in the Project. + + """ + if isinstance(user, ProjectUser): + project_user = user + elif isinstance(user, get_user_model()): + project_user = self.projectuser_set.get(user=user) + + for active_allocation in self.allocation_set.filter( + status__name__in=( + "Active", + "Denied", + "New", + "Paid", + "Payment Pending", + "Payment Requested", + "Payment Declined", + "Renewal Requested", + "Unpaid", + ) + ): + active_allocation.remove_user(project_user.user, signal_sender) + + project_user.status = ProjectUserStatusChoice.objects.get(name="Removed") + project_user.save() + project_remove_user.send(sender=signal_sender, project_user_pk=project_user.pk) + + def get_absolute_url(self): + return reverse("project-detail", kwargs={"pk": self.pk}) + class ProjectAdminComment(TimeStampedModel): """A project admin comment is a comment that an admin can make on a project. diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index 644b3d274c..ab403844df 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -23,14 +23,10 @@ from django.views.generic.base import TemplateView from django.views.generic.edit import FormView -from coldfront.config.core import ALLOCATION_EULA_ENABLE from coldfront.core.allocation.models import ( Allocation, AllocationStatusChoice, - AllocationUser, - AllocationUserStatusChoice, ) -from coldfront.core.allocation.signals import allocation_activate_user, allocation_remove_user from coldfront.core.allocation.utils import generate_guauge_data_from_usage from coldfront.core.grant.models import Grant from coldfront.core.project.forms import ( @@ -58,10 +54,8 @@ ProjectUserStatusChoice, ) from coldfront.core.project.signals import ( - project_activate_user, project_archive, project_new, - project_remove_user, project_update, ) from coldfront.core.project.utils import determine_automated_institution_choice, generate_project_code @@ -567,7 +561,7 @@ def post(self, request, *args, **kwargs): allocation.status = allocation_status_expired allocation.end_date = end_date allocation.save() - return redirect(reverse("project-detail", kwargs={"pk": project.pk})) + return redirect(project) class ProjectCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): @@ -613,9 +607,6 @@ def form_valid(self, form): return super().form_valid(form) - def get_success_url(self): - return reverse("project-detail", kwargs={"pk": self.object.pk}) - class ProjectUpdateView(SuccessMessageMixin, LoginRequiredMixin, UserPassesTestMixin, UpdateView): model = Project @@ -657,14 +648,14 @@ def dispatch(self, request, *args, **kwargs): "New", ]: messages.error(request, "You cannot update an archived project.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj.pk) else: return super().dispatch(request, *args, **kwargs) def get_success_url(self): # project signals project_update.send(sender=self.__class__, project_obj=self.object) - return reverse("project-detail", kwargs={"pk": self.object.pk}) + return super().get_success_url() class ProjectAddUsersSearchView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): @@ -692,7 +683,7 @@ def dispatch(self, request, *args, **kwargs): "New", ]: messages.error(request, "You cannot add users to an archived project.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj.pk) else: return super().dispatch(request, *args, **kwargs) @@ -729,7 +720,7 @@ def dispatch(self, request, *args, **kwargs): "New", ]: messages.error(request, "You cannot add users to an archived project.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj.pk) else: return super().dispatch(request, *args, **kwargs) @@ -828,7 +819,7 @@ def dispatch(self, request, *args, **kwargs): "New", ]: messages.error(request, "You cannot add users to an archived project.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj.pk) else: return super().dispatch(request, *args, **kwargs) @@ -886,11 +877,6 @@ def post(self, request, *args, **kwargs): added_users_count = 0 if formset.is_valid() and allocation_formset.is_valid(): - project_user_active_status_choice = ProjectUserStatusChoice.objects.get(name="Active") - allocation_user_active_status_choice = AllocationUserStatusChoice.objects.get(name="Active") - if ALLOCATION_EULA_ENABLE: - allocation_user_pending_status_choice = AllocationUserStatusChoice.objects.get(name="PendingEULA") - allocations_selected_objs = Allocation.objects.filter( pk__in=[ allocation_form.cleaned_data.get("pk") @@ -912,47 +898,10 @@ def post(self, request, *args, **kwargs): user_obj.save() role_choice = user_form_data.get("role") - # Is the user already in the project? - project_user_obj = project_obj.projectuser_set.filter(user=user_obj).first() - if project_user_obj: - project_user_obj.role = role_choice - project_user_obj.status = project_user_active_status_choice - project_user_obj.save() - else: - project_user_obj = ProjectUser.objects.create( - user=user_obj, - project=project_obj, - role=role_choice, - status=project_user_active_status_choice, - ) - - # project signals - project_activate_user.send(sender=self.__class__, project_user_pk=project_user_obj.pk) + project_obj.add_user(user_obj, role_choice, signal_sender=self.__class__) for allocation in allocations_selected_objs: - has_eula = allocation.get_eula() - user_status_choice = allocation_user_active_status_choice - allocation_user_query = allocation.allocationuser_set.filter(user=user_obj) - if allocation_user_query: - allocation_user_obj = allocation_user_query.first() - if ( - ALLOCATION_EULA_ENABLE - and has_eula - and (allocation_user_obj.status != allocation_user_active_status_choice) - ): - user_status_choice = allocation_user_pending_status_choice - allocation_user_obj.status = user_status_choice - allocation_user_obj.save() - else: - if ALLOCATION_EULA_ENABLE and has_eula: - user_status_choice = allocation_user_pending_status_choice - allocation_user_obj = AllocationUser.objects.create( - allocation=allocation, user=user_obj, status=user_status_choice - ) - if user_status_choice == allocation_user_active_status_choice: - allocation_activate_user.send( - sender=self.__class__, allocation_user_pk=allocation_user_obj.pk - ) + allocation.add_user(user_obj, signal_sender=self.__class__) messages.success(request, "Added {} users to project.".format(added_users_count)) else: @@ -964,7 +913,7 @@ def post(self, request, *args, **kwargs): for error in allocation_formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": pk})) + return redirect(project_obj) class ProjectRemoveUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): @@ -992,7 +941,7 @@ def dispatch(self, request, *args, **kwargs): "New", ]: messages.error(request, "You cannot remove users from an archived project.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj) else: return super().dispatch(request, *args, **kwargs) @@ -1038,8 +987,6 @@ def post(self, request, *args, **kwargs): remove_users_count = 0 if formset.is_valid(): - project_user_removed_status_choice = ProjectUserStatusChoice.objects.get(name="Removed") - allocation_user_removed_status_choice = AllocationUserStatusChoice.objects.get(name="Removed") for form in formset: user_form_data = form.cleaned_data if user_form_data["selected"]: @@ -1050,30 +997,7 @@ def post(self, request, *args, **kwargs): if project_obj.pi == user_obj: continue - project_user_obj = project_obj.projectuser_set.get(user=user_obj) - project_user_obj.status = project_user_removed_status_choice - project_user_obj.save() - - # project signals - project_remove_user.send(sender=self.__class__, project_user_pk=project_user_obj.pk) - - # get allocation to remove users from - allocations_to_remove_user_from = project_obj.allocation_set.filter( - status__name__in=["Active", "New", "Renewal Requested"] - ) - for allocation in allocations_to_remove_user_from: - for allocation_user_obj in allocation.allocationuser_set.filter( - user=user_obj, - status__name__in=[ - "Active", - ], - ): - allocation_user_obj.status = allocation_user_removed_status_choice - allocation_user_obj.save() - - allocation_remove_user.send( - sender=self.__class__, allocation_user_pk=allocation_user_obj.pk - ) + project_obj.remove_user(user_obj, signal_sender=self.__class__) if remove_users_count == 1: messages.success(request, "Removed {} user from project.".format(remove_users_count)) @@ -1083,7 +1007,7 @@ def post(self, request, *args, **kwargs): for error in formset.errors: messages.error(request, error) - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": pk})) + return redirect(project_obj) class ProjectUserDetail(LoginRequiredMixin, UserPassesTestMixin, TemplateView): @@ -1228,7 +1152,7 @@ def dispatch(self, request, *args, **kwargs): if not project_obj.needs_review: messages.error(request, "You do not need to review this project.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj) if "Auto-Import Project".lower() in project_obj.title.lower(): messages.error( @@ -1281,7 +1205,7 @@ def post(self, request, *args, **kwargs): domain_url = get_domain_url(self.request) project_review_list_url = "{}{}".format(domain_url, reverse("project-review-list")) - project_url = "{}{}".format(domain_url, reverse("project-detail", kwargs={"pk": project_obj.pk})) + project_url = "{}{}".format(domain_url, project_obj.get_absolute_url()) email_context = { "project": project_obj, @@ -1302,10 +1226,10 @@ def post(self, request, *args, **kwargs): ) messages.success(request, "Project reviewed successfully.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj) else: messages.error(request, "There was an error in processing your project review.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj) class ProjectReviewListView(LoginRequiredMixin, UserPassesTestMixin, ListView): @@ -1456,6 +1380,7 @@ def get_form(self, form_class=None): return form def get_success_url(self): + # can probably be replaced with `return self.object.project.get_aboslute_url()` return reverse("project-detail", kwargs={"pk": self.kwargs.get("pk")}) @@ -1501,6 +1426,7 @@ def get_context_data(self, *args, **kwargs): return context def get_success_url(self): + # can probably be replaced with `return self.object.project.get_absolute_url()` return reverse("project-detail", kwargs={"pk": self.object.project_id}) @@ -1652,7 +1578,7 @@ def post(self, request, *args, **kwargs): project_attribute_obj.save() messages.success(request, "Attribute Updated.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(project_obj) else: for error in project_attribute_update_form.errors.values(): messages.error(request, error) From 44a8ceb8d0f3776f79c211d2f5c39bc437d216b4 Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Fri, 31 Oct 2025 16:43:43 +0000 Subject: [PATCH 086/110] make use of AttributeValidator class raise if empty add tests for project clean, resource clean fixup tests choose a default type appropriate for the default value fixup Signed-off-by: Simon Leary --- coldfront/core/allocation/models.py | 36 ++-------- .../core/allocation/tests/test_models.py | 8 +-- coldfront/core/allocation/tests/test_views.py | 9 ++- coldfront/core/project/tests/test_views.py | 2 +- coldfront/core/project/tests/tests.py | 48 +++++++++++++ coldfront/core/resource/tests/tests.py | 70 +++++++++++++++++++ coldfront/core/test_helpers/factories.py | 40 +++++++++-- coldfront/core/utils/validate.py | 7 ++ 8 files changed, 177 insertions(+), 43 deletions(-) diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index 6cc15b3daf..b576a46b87 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -4,7 +4,6 @@ import datetime import logging -from ast import literal_eval from enum import Enum from django.contrib.auth import get_user_model @@ -25,6 +24,7 @@ from coldfront.core.resource.models import Resource from coldfront.core.utils.common import import_from_settings from coldfront.core.utils.mail import build_link, send_email_template +from coldfront.core.utils.validate import AttributeValidator logger = logging.getLogger(__name__) @@ -555,37 +555,15 @@ def clean(self): expected_value_type = self.allocation_attribute_type.attribute_type.name.strip() + validator = AttributeValidator(self.value) if expected_value_type == "Int": - try: - if not isinstance(literal_eval(self.value), int): - raise TypeError - except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError) as e: - raise ValidationError( - 'Invalid Value "%s" for "%s". Value must be an integer.' - % (self.value, self.allocation_attribute_type.name) - ) from e + validator.validate_int() elif expected_value_type == "Float": - try: - if not (isinstance(literal_eval(self.value), int) or isinstance(literal_eval(self.value), float)): - raise TypeError - except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError) as e: - raise ValidationError( - 'Invalid Value "%s" for "%s". Value must be a float.' - % (self.value, self.allocation_attribute_type.name) - ) from e - elif expected_value_type == "Yes/No" and self.value not in ["Yes", "No"]: - raise ValidationError( - 'Invalid Value "%s" for "%s". Allowed inputs are "Yes" or "No".' - % (self.value, self.allocation_attribute_type.name) - ) + validator.validate_float() + elif expected_value_type == "Yes/No": + validator.validate_yes_no() elif expected_value_type == "Date": - try: - datetime.datetime.strptime(self.value.strip(), "%Y-%m-%d") - except ValueError: - raise ValidationError( - 'Invalid Value "%s" for "%s". Date must be in format YYYY-MM-DD' - % (self.value, self.allocation_attribute_type.name) - ) + validator.validate_date() def __str__(self): return "%s" % (self.allocation_attribute_type.name) diff --git a/coldfront/core/allocation/tests/test_models.py b/coldfront/core/allocation/tests/test_models.py index 06d96fec43..86e3eedc61 100644 --- a/coldfront/core/allocation/tests/test_models.py +++ b/coldfront/core/allocation/tests/test_models.py @@ -213,13 +213,11 @@ def test_status_is_active_and_start_date_equals_end_date_no_error(self): class AllocationAttributeModelCleanMethodTests(TestCase): - def _test_clean( - self, allocation_attribute_type_name: str, allocation_attribute_values: list, expect_validation_error: bool - ): - attribute_type = AAttributeTypeFactory(name=allocation_attribute_type_name) + def _test_clean(self, alloc_attr_type_name: str, alloc_attr_values: list, expect_validation_error: bool): + attribute_type = AAttributeTypeFactory(name=alloc_attr_type_name) allocation_attribute_type = AllocationAttributeTypeFactory(attribute_type=attribute_type) allocation_attribute = AllocationAttributeFactory(allocation_attribute_type=allocation_attribute_type) - for value in allocation_attribute_values: + for value in alloc_attr_values: with self.subTest(value=value): if not isinstance(value, str): raise TypeError("allocation attribute value must be a string") diff --git a/coldfront/core/allocation/tests/test_views.py b/coldfront/core/allocation/tests/test_views.py index d09a5606ba..8d518650c4 100644 --- a/coldfront/core/allocation/tests/test_views.py +++ b/coldfront/core/allocation/tests/test_views.py @@ -25,6 +25,7 @@ ) from coldfront.core.test_helpers import utils from coldfront.core.test_helpers.factories import ( + AAttributeTypeFactory, AllocationAttributeFactory, AllocationAttributeTypeFactory, AllocationChangeRequestFactory, @@ -72,10 +73,12 @@ def setUpTestData(cls): ProjectUserFactory(user=pi_user, project=cls.project, role=manager_role) cls.pi_user = pi_user # make a quota TB allocation attribute + attr_type = AAttributeTypeFactory(name="Int") + alloc_attr_type = AllocationAttributeTypeFactory( + name="Storage Quota (TB)", attribute_type=attr_type, is_changeable=True + ) cls.quota_attribute: AllocationAttribute = AllocationAttributeFactory( - allocation=cls.allocation, - value=100, - allocation_attribute_type=AllocationAttributeTypeFactory(name="Storage Quota (TB)", is_changeable=True), + allocation=cls.allocation, value=100, allocation_attribute_type=alloc_attr_type ) def allocation_access_tstbase(self, url): diff --git a/coldfront/core/project/tests/test_views.py b/coldfront/core/project/tests/test_views.py index 0aa02eff99..8879ed191d 100644 --- a/coldfront/core/project/tests/test_views.py +++ b/coldfront/core/project/tests/test_views.py @@ -39,7 +39,7 @@ def setUpTestData(cls): cls.admin_user = UserFactory(is_staff=True, is_superuser=True) cls.nonproject_user = UserFactory(is_staff=False, is_superuser=False) - attributetype = PAttributeTypeFactory(name="string") + attributetype = PAttributeTypeFactory(name="Text") cls.projectattributetype = ProjectAttributeTypeFactory(attribute_type=attributetype) def project_access_tstbase(self, url): diff --git a/coldfront/core/project/tests/tests.py b/coldfront/core/project/tests/tests.py index 39a9676833..d428dc8036 100644 --- a/coldfront/core/project/tests/tests.py +++ b/coldfront/core/project/tests/tests.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later import logging +import sys from unittest.mock import patch from django.core.exceptions import ValidationError @@ -379,3 +380,50 @@ def test_determine_automated_institution_choice_does_not_save_to_database(self): self.assertEqual(original_db_project.institution, "Default") self.assertNotEqual(project.institution, current_db_project.institution) + + +class ProjectAttributeModelCleanMethodTests(TestCase): + def _test_clean(self, proj_attr_type_name: str, proj_attr_values: list, expect_validation_error: bool): + attribute_type = PAttributeTypeFactory(name=proj_attr_type_name) + proj_attr_type = ProjectAttributeTypeFactory(attribute_type=attribute_type) + project_attribute = ProjectAttributeFactory(proj_attr_type=proj_attr_type) + for value in proj_attr_values: + with self.subTest(value=value): + if not isinstance(value, str): + raise TypeError("project attribute value must be a string") + project_attribute.value = value + if expect_validation_error: + with self.assertRaises(ValidationError): + project_attribute.clean() + else: + project_attribute.clean() + + def test_expect_int_given_int(self): + self._test_clean("Int", ["-1", "0", "1", str(sys.maxsize)], False) + + def test_expect_int_given_float(self): + self._test_clean("Int", ["-1.0", "0.0", "1.0", "2e30"], True) + + def test_expect_int_given_garbage(self): + self._test_clean("Int", ["foobar", "", " ", "\0", "1j"], True) + + def test_expect_float_given_int(self): + self._test_clean("Float", ["-1", "0", "1", str(sys.maxsize)], False) + + def test_expect_float_given_float(self): + self._test_clean("Float", ["-1.0", "0.0", "1.0", "2e30"], False) + + def test_expect_float_given_garbage(self): + self._test_clean("Float", ["foobar", "", " ", "\0", "1j"], True) + + def test_expect_yes_no_given_yes_no(self): + self._test_clean("Yes/No", ["Yes", "No"], False) + + def test_expect_yes_no_given_garbage(self): + self._test_clean("Yes/No", ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j", "yes", "no", "YES", "NO"], True) + + def test_expect_date_given_date(self): + self._test_clean("Date", ["1970-01-01"], False) + + def test_expect_date_given_garbage(self): + self._test_clean("Date", ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j"], True) diff --git a/coldfront/core/resource/tests/tests.py b/coldfront/core/resource/tests/tests.py index 576ead011d..3f8679ae15 100644 --- a/coldfront/core/resource/tests/tests.py +++ b/coldfront/core/resource/tests/tests.py @@ -3,3 +3,73 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Create your tests here. + +import logging +import sys + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from coldfront.core.test_helpers.factories import ( + RAttributeTypeFactory, + ResourceAttributeFactory, + ResourceAttributeTypeFactory, +) + +logging.disable(logging.CRITICAL) + + +class ResourceAttributeModelCleanMethodTests(TestCase): + def _test_clean( + self, resource_attribute_type_name: str, resource_attribute_values: list, expect_validation_error: bool + ): + attribute_type = RAttributeTypeFactory(name=resource_attribute_type_name) + resource_attribute_type = ResourceAttributeTypeFactory(attribute_type=attribute_type) + resource_attribute = ResourceAttributeFactory(resource_attribute_type=resource_attribute_type) + for value in resource_attribute_values: + with self.subTest(value=value): + if not isinstance(value, str): + raise TypeError("resource attribute value must be a string") + resource_attribute.value = value + if expect_validation_error: + with self.assertRaises(ValidationError): + resource_attribute.clean() + else: + resource_attribute.clean() + + def test_expect_int_given_int(self): + self._test_clean("Int", ["0", "1", str(sys.maxsize)], False) + + def test_expect_int_given_float(self): + # FIXME -1 should be a valid int + self._test_clean("Int", ["-1", "-1.0", "0.0", "1.0", "2e30"], True) + + def test_expect_int_given_garbage(self): + self._test_clean("Int", ["foobar", "", " ", "\0", "1j"], True) + + def test_expect_public_private_given_public_private(self): + self._test_clean("Public/Private", ["Public", "Private"], False) + + def test_expect_public_private_given_garbage(self): + self._test_clean( + "Public/Private", + ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j", "public", "private", "PUBLIC", "PRIVATE"], + True, + ) + + def test_expect_active_inactive_given_active_inactive(self): + self._test_clean("Active/Inactive", ["Active", "Inactive"], False) + + def test_expect_active_inactive_given_garbage(self): + self._test_clean( + "Active/Inactive", + ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j", "active", "inactive", "ACTIVE", "INACTIVE"], + True, + ) + + def test_expect_date_given_date(self): + # FIXME date format is different from project, allocation + self._test_clean("Date", ["01/01/1970"], False) + + def test_expect_date_given_garbage(self): + self._test_clean("Date", ["foobar", "", " ", "\0", "1", "1.0", "2e30", "1j"], True) diff --git a/coldfront/core/test_helpers/factories.py b/coldfront/core/test_helpers/factories.py index ff8aa1ae3d..b0062aca5b 100644 --- a/coldfront/core/test_helpers/factories.py +++ b/coldfront/core/test_helpers/factories.py @@ -41,7 +41,10 @@ ProjectUserStatusChoice, ) from coldfront.core.publication.models import PublicationSource -from coldfront.core.resource.models import Resource, ResourceType +from coldfront.core.resource.models import ( + AttributeType as RAttributeType, +) +from coldfront.core.resource.models import Resource, ResourceAttribute, ResourceAttributeType, ResourceType from coldfront.core.user.models import UserProfile ### Default values and Faker provider setup ### @@ -49,7 +52,6 @@ project_status_choice_names = ["New", "Active", "Archived"] project_user_role_choice_names = ["User", "Manager"] field_of_science_names = ["Physics", "Chemistry", "Economics", "Biology", "Sociology"] -attr_types = ["Date", "Int", "Float", "Text", "Boolean"] fake = Faker() @@ -68,9 +70,8 @@ def username(self): field_of_science_provider = DynamicProvider(provider_name="fieldofscience", elements=field_of_science_names) -attr_type_provider = DynamicProvider(provider_name="attr_types", elements=attr_types) -for provider in [ColdfrontProvider, field_of_science_provider, attr_type_provider]: +for provider in [ColdfrontProvider, field_of_science_provider]: factory.Faker.add_provider(provider) @@ -186,7 +187,7 @@ class Meta: model = PAttributeType # django_get_or_create = ('name',) - name = factory.Faker("attr_type") + name = "Text" class ProjectAttributeTypeFactory(DjangoModelFactory): @@ -239,6 +240,35 @@ class Meta: resource_type = SubFactory(ResourceTypeFactory) +### Resource Attribute factories ### + + +class RAttributeTypeFactory(DjangoModelFactory): + class Meta: + model = RAttributeType + django_get_or_create = ("name",) + + name = "Text" + + +class ResourceAttributeTypeFactory(DjangoModelFactory): + class Meta: + model = ResourceAttributeType + django_get_or_create = ("name",) + + name = "Test attribute type" + attribute_type = SubFactory(RAttributeTypeFactory) + + +class ResourceAttributeFactory(DjangoModelFactory): + class Meta: + model = ResourceAttribute + + resource_attribute_type = SubFactory(ResourceAttributeTypeFactory) + value = "Test attribute value" + resource = SubFactory(ResourceFactory) + + ### Allocation factories ### diff --git a/coldfront/core/utils/validate.py b/coldfront/core/utils/validate.py index 0f9e57c8f9..59ebfbf964 100644 --- a/coldfront/core/utils/validate.py +++ b/coldfront/core/utils/validate.py @@ -12,7 +12,12 @@ class AttributeValidator: def __init__(self, value): self.value = value + def _raise_if_empty(self): + if self.value == "": + raise ValidationError(f'Invalid Value "{self.value}". Value cannot be empty.') + def validate_int(self): + self._raise_if_empty() try: validate = validators.Int() validate.to_python(self.value) @@ -20,6 +25,7 @@ def validate_int(self): raise ValidationError(f"Invalid Value {self.value}. Value must be an int.") def validate_float(self): + self._raise_if_empty() try: validate = validators.Number() validate.to_python(self.value) @@ -27,6 +33,7 @@ def validate_float(self): raise ValidationError(f"Invalid Value {self.value}. Value must be an float.") def validate_yes_no(self): + self._raise_if_empty() try: validate = validators.OneOf(["Yes", "No"]) validate.to_python(self.value) From 0e23f94080240d14da138303162a577a2650e79a Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Fri, 28 Nov 2025 13:35:15 -0500 Subject: [PATCH 087/110] Upgrade uv.lock, deps, and pin packages. - Newer versions of uv added upload-time to the lock file. This bumps our lock file to version 3. - Pin uv.tool to ensure compatability with new lock file - Pin setuptools==80.6.0 and cryptography==43.0.3 because ipaclient requires these older versions. - Upgrade all deps Signed-off-by: Andrew E. Bruno --- pyproject.toml | 5 + uv.lock | 1360 ++++++++++++++++++++++++------------------------ 2 files changed, 692 insertions(+), 673 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8cf41a08ed..ffcdf3cb3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,8 @@ ldap = [ freeipa = [ "dbus-python>=1.4.0", "ipaclient>=4.12.2", + "setuptools==80.6.0", + "cryptography==43.0.3", ] iquota = [ "kerberos>=1.3.1", @@ -101,6 +103,9 @@ url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true +[tool.uv] +required-version = ">=0.9.13" + [tool.ruff] line-length = 120 indent-width = 4 diff --git a/uv.lock b/uv.lock index 2645620d54..5b2e1e08b6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,35 +1,35 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.10" [[package]] name = "asgiref" -version = "3.8.1" +version = "3.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] [[package]] name = "async-timeout" version = "5.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] [[package]] name = "attrs" -version = "25.3.0" +version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] @@ -39,209 +39,216 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyparsing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/8d/e296c7af03757debd8fc80df2898cbed4fb69fc61ed2c9b4a1d42e923a9e/bibtexparser-1.4.3.tar.gz", hash = "sha256:a9c7ded64bc137720e4df0b1b7f12734edc1361185f1c9097048ff7c35af2b8f", size = 55582 } - -[[package]] -name = "binaryornot" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "chardet" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/fe/7ebfec74d49f97fc55cd38240c7a7d08134002b1e14be8c3897c0dd5e49b/binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061", size = 371054 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4", size = 9006 }, -] +sdist = { url = "https://files.pythonhosted.org/packages/92/8d/e296c7af03757debd8fc80df2898cbed4fb69fc61ed2c9b4a1d42e923a9e/bibtexparser-1.4.3.tar.gz", hash = "sha256:a9c7ded64bc137720e4df0b1b7f12734edc1361185f1c9097048ff7c35af2b8f", size = 55582, upload-time = "2024-12-19T20:41:57.754Z" } [[package]] name = "boolean-py" version = "5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047 } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577 }, + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, ] [[package]] name = "bracex" -version = "2.5.post1" +version = "2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/6c/57418c4404cd22fe6275b8301ca2b46a8cdaa8157938017a9ae0b3edf363/bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6", size = 26641 } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/02/8db98cdc1a58e0abd6716d5e63244658e6e63513c65f469f34b6f1053fd0/bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6", size = 11558 }, + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, ] [[package]] name = "certifi" -version = "2025.1.31" +version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] name = "cffi" -version = "1.17.1" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, - { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, - { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, - { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, - { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, - { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, - { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, - { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, - { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, - { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, - { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, - { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, - { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, -] - -[[package]] -name = "chardet" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, - { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, - { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, - { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, - { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, - { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, - { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, - { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, - { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, - { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, - { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, - { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, - { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, - { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, - { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, - { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, - { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, - { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, - { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, - { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, - { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, - { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, - { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, - { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, - { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, - { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, - { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, - { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 }, - { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 }, - { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 }, - { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 }, - { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 }, - { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 }, - { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 }, - { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 }, - { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 }, - { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 }, - { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 }, - { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 }, - { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] [[package]] name = "click" -version = "8.1.8" +version = "8.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -272,8 +279,10 @@ dependencies = [ [package.optional-dependencies] freeipa = [ + { name = "cryptography" }, { name = "dbus-python" }, { name = "ipaclient" }, + { name = "setuptools" }, ] iquota = [ { name = "kerberos" }, @@ -313,6 +322,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "crispy-bootstrap4", specifier = ">=2024.10" }, + { name = "cryptography", marker = "extra == 'freeipa'", specifier = "==43.0.3" }, { name = "dbus-python", marker = "extra == 'freeipa'", specifier = ">=1.4.0" }, { name = "django", specifier = ">4.2,<5" }, { name = "django-auth-ldap", marker = "extra == 'ldap'", specifier = ">=5.1.0" }, @@ -339,6 +349,7 @@ requires-dist = [ { name = "psycopg2", marker = "extra == 'pg'", specifier = ">=2.9.10" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "redis", specifier = ">=5.2.1" }, + { name = "setuptools", marker = "extra == 'freeipa'", specifier = "==80.6.0" }, ] provides-extras = ["ldap", "freeipa", "iquota", "oidc", "mysql", "pg"] @@ -364,130 +375,118 @@ docs = [ name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "crispy-bootstrap4" -version = "2024.10" +version = "2025.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "django-crispy-forms" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/0b/6a3e2ab27d9eab3fd95628e45212454ac486b2c501def355f3c425cf4ae3/crispy-bootstrap4-2024.10.tar.gz", hash = "sha256:503e8922b0f3b5262a6fdf303a3a94eb2a07514812f1ca130b88f7c02dd25e2b", size = 35301 } +sdist = { url = "https://files.pythonhosted.org/packages/99/f7/5dea1c2ad806fb28b42da08f1dc19ac172e909a63bcef0734bfdf811fedf/crispy_bootstrap4-2025.6.tar.gz", hash = "sha256:69066c33fc9c8841cbd8741a7ec99ad9234f12877b4549490031a47c2c1abd79", size = 35938, upload-time = "2025-06-08T08:04:54.872Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/a9/2a22c0e6b72323205a6780f9a93e8121bc2c81338d34a0ddc1f6d1a958e7/crispy_bootstrap4-2024.10-py3-none-any.whl", hash = "sha256:138a97884044ae4c4799c80595b36c42066e4e933431e2e971611e251c84f96c", size = 23060 }, + { url = "https://files.pythonhosted.org/packages/28/f9/a43aeecf5bf7c9b05eccf9f35d1878291a1f2c5ffc501bd96b8759fb6b10/crispy_bootstrap4-2025.6-py3-none-any.whl", hash = "sha256:64bf732b27690c7147bfaa154b4b690217bee81c71731daf5da73de00ee01943", size = 23281, upload-time = "2025-06-08T08:04:53.622Z" }, ] [[package]] name = "cryptography" -version = "44.0.2" +version = "43.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 }, - { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 }, - { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 }, - { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 }, - { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 }, - { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 }, - { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 }, - { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 }, - { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 }, - { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 }, - { url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 }, - { url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 }, - { url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 }, - { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 }, - { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 }, - { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 }, - { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 }, - { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 }, - { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 }, - { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 }, - { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 }, - { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, - { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 }, - { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, - { url = "https://files.pythonhosted.org/packages/99/10/173be140714d2ebaea8b641ff801cbcb3ef23101a2981cbf08057876f89e/cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb", size = 3396886 }, - { url = "https://files.pythonhosted.org/packages/2f/b4/424ea2d0fce08c24ede307cead3409ecbfc2f566725d4701b9754c0a1174/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", size = 3892387 }, - { url = "https://files.pythonhosted.org/packages/28/20/8eaa1a4f7c68a1cb15019dbaad59c812d4df4fac6fd5f7b0b9c5177f1edd/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", size = 4109922 }, - { url = "https://files.pythonhosted.org/packages/11/25/5ed9a17d532c32b3bc81cc294d21a36c772d053981c22bd678396bc4ae30/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", size = 3895715 }, - { url = "https://files.pythonhosted.org/packages/63/31/2aac03b19c6329b62c45ba4e091f9de0b8f687e1b0cd84f101401bece343/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", size = 4109876 }, - { url = "https://files.pythonhosted.org/packages/99/ec/6e560908349843718db1a782673f36852952d52a55ab14e46c42c8a7690a/cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d", size = 3131719 }, - { url = "https://files.pythonhosted.org/packages/d6/d7/f30e75a6aa7d0f65031886fa4a1485c2fbfe25a1896953920f6a9cfe2d3b/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", size = 3887513 }, - { url = "https://files.pythonhosted.org/packages/9c/b4/7a494ce1032323ca9db9a3661894c66e0d7142ad2079a4249303402d8c71/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", size = 4107432 }, - { url = "https://files.pythonhosted.org/packages/45/f8/6b3ec0bc56123b344a8d2b3264a325646d2dcdbdd9848b5e6f3d37db90b3/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", size = 3891421 }, - { url = "https://files.pythonhosted.org/packages/57/ff/f3b4b2d007c2a646b0f69440ab06224f9cf37a977a72cdb7b50632174e8a/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", size = 4107081 }, +sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989, upload-time = "2024-10-18T15:58:32.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f3/01fdf26701a26f4b4dbc337a26883ad5bccaa6f1bbbdd29cd89e22f18a1c/cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", size = 6225303, upload-time = "2024-10-18T15:57:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905, upload-time = "2024-10-18T15:57:39.166Z" }, + { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271, upload-time = "2024-10-18T15:57:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606, upload-time = "2024-10-18T15:57:42.903Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484, upload-time = "2024-10-18T15:57:45.434Z" }, + { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131, upload-time = "2024-10-18T15:57:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647, upload-time = "2024-10-18T15:57:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/56/48/7b6b190f1462818b324e674fa20d1d5ef3e24f2328675b9b16189cbf0b3c/cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", size = 2623873, upload-time = "2024-10-18T15:57:51.822Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b1/0ebff61a004f7f89e7b65ca95f2f2375679d43d0290672f7713ee3162aff/cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", size = 3068039, upload-time = "2024-10-18T15:57:54.426Z" }, + { url = "https://files.pythonhosted.org/packages/30/d5/c8b32c047e2e81dd172138f772e81d852c51f0f2ad2ae8a24f1122e9e9a7/cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", size = 6222984, upload-time = "2024-10-18T15:57:56.174Z" }, + { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968, upload-time = "2024-10-18T15:57:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754, upload-time = "2024-10-18T15:58:00.683Z" }, + { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458, upload-time = "2024-10-18T15:58:02.225Z" }, + { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220, upload-time = "2024-10-18T15:58:04.331Z" }, + { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898, upload-time = "2024-10-18T15:58:06.113Z" }, + { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592, upload-time = "2024-10-18T15:58:08.673Z" }, + { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145, upload-time = "2024-10-18T15:58:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026, upload-time = "2024-10-18T15:58:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/6f/db/d8b8a039483f25fc3b70c90bc8f3e1d4497a99358d610c5067bf3bd4f0af/cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c", size = 3144545, upload-time = "2024-10-18T15:58:13.572Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/116edd5f8ec23b2dc879f7a42443e073cdad22950d3c8ee834e3b8124543/cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3", size = 3679828, upload-time = "2024-10-18T15:58:15.254Z" }, + { url = "https://files.pythonhosted.org/packages/d8/32/1e1d78b316aa22c0ba6493cc271c1c309969e5aa5c22c830a1d7ce3471e6/cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83", size = 3908132, upload-time = "2024-10-18T15:58:16.943Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/cd2c13be3332e7af3cdf16154147952d39075b9f61ea5e6b5241bf4bf436/cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7", size = 2988811, upload-time = "2024-10-18T15:58:19.674Z" }, ] [[package]] name = "dbus-python" version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/24/63118050c7dd7be04b1ccd60eab53fef00abe844442e1b6dec92dae505d6/dbus-python-1.4.0.tar.gz", hash = "sha256:991666e498f60dbf3e49b8b7678f5559b8a65034fdf61aae62cdecdb7d89c770", size = 232490 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/24/63118050c7dd7be04b1ccd60eab53fef00abe844442e1b6dec92dae505d6/dbus-python-1.4.0.tar.gz", hash = "sha256:991666e498f60dbf3e49b8b7678f5559b8a65034fdf61aae62cdecdb7d89c770", size = 232490, upload-time = "2025-03-13T19:57:54.212Z" } [[package]] name = "decorator" version = "5.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] [[package]] name = "django" -version = "4.2.23" +version = "4.2.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/20/02242739714eb4e53933d6c0fe2c57f41feb449955b0aa39fc2da82b8f3c/django-4.2.23.tar.gz", hash = "sha256:42fdeaba6e6449d88d4f66de47871015097dc6f1b87910db00a91946295cfae4", size = 10448384 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/f2/5cab08b4174b46cd9d5b4d4439d211f5dd15bec256fb43e8287adbb79580/django-4.2.26.tar.gz", hash = "sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a", size = 10433052, upload-time = "2025-11-05T14:08:23.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/44/314e8e4612bd122dd0424c88b44730af68eafbee88cc887a86586b7a1f2a/django-4.2.23-py3-none-any.whl", hash = "sha256:dafbfaf52c2f289bd65f4ab935791cb4fb9a198f2a5ba9faf35d7338a77e9803", size = 7993904 }, + { url = "https://files.pythonhosted.org/packages/39/6f/873365d280002de462852c20bcf050564e2354770041bd950bfb4a16d91e/django-4.2.26-py3-none-any.whl", hash = "sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280", size = 7994264, upload-time = "2025-11-05T14:08:20.328Z" }, ] [[package]] name = "django-auth-ldap" -version = "5.1.0" +version = "5.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "python-ldap" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/e4/2e8781840cc54f719be3241e16640524a9aabf94a599f5e083b0115042ce/django_auth_ldap-5.1.0.tar.gz", hash = "sha256:9c607e8d9c53cf2a0ccafbe0acfc33eb1d1fd474c46ec52d30aee0dca1da9668", size = 55059 } +sdist = { url = "https://files.pythonhosted.org/packages/88/70/6f6a89474667376080f8362f7c17c744d1c52720f0eb085cf74182149efe/django_auth_ldap-5.2.0.tar.gz", hash = "sha256:08ba6efc0340d9874725a962311b14991e29a33593eb150a8fb640709dbfa80f", size = 55287, upload-time = "2025-05-07T12:15:56.774Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/47/f3492884addbb17672cc9a6053381162010d6e40ccd8440dedf22f72bc7f/django_auth_ldap-5.1.0-py3-none-any.whl", hash = "sha256:a5f7bdb54b2ab80e4e9eb080cd3e06e89e4c9d2d534ddb39b66cd970dd6d3536", size = 20833 }, + { url = "https://files.pythonhosted.org/packages/a1/65/0d26a8b5c19039305d7ae0e8e702613a9a1fe1ef3ebbd6206b9e104b7c43/django_auth_ldap-5.2.0-py3-none-any.whl", hash = "sha256:7dc6eb576ba36051850b580e4bdf4464e04bbe7367c3827a3121b4d7c51fb175", size = 20913, upload-time = "2025-05-07T12:15:54.962Z" }, ] [[package]] name = "django-crispy-forms" -version = "2.3" +version = "2.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/f6/5bce7ae3512171c7c0ca3de31689e2a1ced8b030f156fcf13d2870e5468e/django_crispy_forms-2.3.tar.gz", hash = "sha256:2db17ae08527201be1273f0df789e5f92819e23dd28fec69cffba7f3762e1a38", size = 278849 } +sdist = { url = "https://files.pythonhosted.org/packages/79/a1/6a638d13717e4d4f8df169dade0fa51bdc65d9825df39d98ce709a776b49/django_crispy_forms-2.5.tar.gz", hash = "sha256:066e72a8f152a1334f1c811cc37740868efe3265e5a218f79079ef89f848c3d8", size = 1097999, upload-time = "2025-11-06T20:44:01.921Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/3b/5dc3faf8739d1ce7a73cedaff508b4af8f6aa1684120ded6185ca0c92734/django_crispy_forms-2.3-py3-none-any.whl", hash = "sha256:efc4c31e5202bbec6af70d383a35e12fc80ea769d464fb0e7fe21768bb138a20", size = 31411 }, + { url = "https://files.pythonhosted.org/packages/2c/58/ac3a11950baaf75c1f3242e3af9dfe45201f6ee10c113dd37a9c000876d2/django_crispy_forms-2.5-py3-none-any.whl", hash = "sha256:adc99d5901baca09479c53bf536b3909e80a9f2bb299438a223de4c106ebf1f9", size = 31464, upload-time = "2025-11-06T20:44:00.795Z" }, ] [[package]] name = "django-environ" version = "0.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/04/65d2521842c42f4716225f20d8443a50804920606aec018188bbee30a6b0/django_environ-0.12.0.tar.gz", hash = "sha256:227dc891453dd5bde769c3449cf4a74b6f2ee8f7ab2361c93a07068f4179041a", size = 56804 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/04/65d2521842c42f4716225f20d8443a50804920606aec018188bbee30a6b0/django_environ-0.12.0.tar.gz", hash = "sha256:227dc891453dd5bde769c3449cf4a74b6f2ee8f7ab2361c93a07068f4179041a", size = 56804, upload-time = "2025-01-13T17:03:37.74Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/b3/0a3bec4ecbfee960f39b1842c2f91e4754251e0a6ed443db9fe3f666ba8f/django_environ-0.12.0-py2.py3-none-any.whl", hash = "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca", size = 19957 }, + { url = "https://files.pythonhosted.org/packages/83/b3/0a3bec4ecbfee960f39b1842c2f91e4754251e0a6ed443db9fe3f666ba8f/django_environ-0.12.0-py2.py3-none-any.whl", hash = "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca", size = 19957, upload-time = "2025-01-13T17:03:32.918Z" }, ] [[package]] @@ -497,9 +496,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/40/c702a6fe8cccac9bf426b55724ebdf57d10a132bae80a17691d0cf0b9bac/django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", size = 143021 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/40/c702a6fe8cccac9bf426b55724ebdf57d10a132bae80a17691d0cf0b9bac/django_filter-25.1.tar.gz", hash = "sha256:1ec9eef48fa8da1c0ac9b411744b16c3f4c31176c867886e4c48da369c407153", size = 143021, upload-time = "2025-02-14T16:30:53.238Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114 }, + { url = "https://files.pythonhosted.org/packages/07/a6/70dcd68537c434ba7cb9277d403c5c829caf04f35baf5eb9458be251e382/django_filter-25.1-py3-none-any.whl", hash = "sha256:4fa48677cf5857b9b1347fed23e355ea792464e0fe07244d1fdfb8a806215b80", size = 94114, upload-time = "2025-02-14T16:30:50.435Z" }, ] [[package]] @@ -509,35 +508,34 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/60/5e232c32a2c977cc1af8c70a38ef436598bc649ad89c2c4568454edde2c9/django_model_utils-5.0.0.tar.gz", hash = "sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb", size = 80559 } +sdist = { url = "https://files.pythonhosted.org/packages/81/60/5e232c32a2c977cc1af8c70a38ef436598bc649ad89c2c4568454edde2c9/django_model_utils-5.0.0.tar.gz", hash = "sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb", size = 80559, upload-time = "2024-09-04T11:35:22.858Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/13/87a42048700c54bfce35900a34e2031245132775fb24363fc0e33664aa9c/django_model_utils-5.0.0-py3-none-any.whl", hash = "sha256:fec78e6c323d565a221f7c4edc703f4567d7bb1caeafe1acd16a80c5ff82056b", size = 42630 }, + { url = "https://files.pythonhosted.org/packages/fd/13/87a42048700c54bfce35900a34e2031245132775fb24363fc0e33664aa9c/django_model_utils-5.0.0-py3-none-any.whl", hash = "sha256:fec78e6c323d565a221f7c4edc703f4567d7bb1caeafe1acd16a80c5ff82056b", size = 42630, upload-time = "2024-09-04T11:36:23.166Z" }, ] [[package]] name = "django-picklefield" -version = "3.3" +version = "3.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/70/11411d4f528e4fad57a17dae81c85da039feba90c001b64e677fc0925a97/django-picklefield-3.3.tar.gz", hash = "sha256:4e76dd20f2e95ffdaf18d641226ccecc169ff0473b0d6bec746f3ab97c26b8cb", size = 9559 } +sdist = { url = "https://files.pythonhosted.org/packages/93/03/13114bccbd1ec8c026ac1ff33dae75ae6c6a5632e4769ee9cda283b9f57e/django_picklefield-3.4.0.tar.gz", hash = "sha256:3a1f740536c0e60d0dba43aa89ccdbe86760d4c3f8ec47799eae122baa741d0a", size = 12555, upload-time = "2025-11-27T03:11:53.13Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/e1/988fa6ded7275bb11e373ccd4b708af477f12027d3ee86b7cb5fc5779412/django_picklefield-3.3-py3-none-any.whl", hash = "sha256:d6f6fd94a17177fe0d16b0b452a9860b8a1da97b6e70633ab53ade4975f1ce9a", size = 9565 }, + { url = "https://files.pythonhosted.org/packages/79/b7/139eb1419ca7b27fd714925b8d0eed6efb592479dcf2155fed6c0c87c956/django_picklefield-3.4.0-py3-none-any.whl", hash = "sha256:929bcfbae5b48bd22a52bc04521fdfdd152eee36abb9f20228f9480f9df65f45", size = 10031, upload-time = "2025-11-27T03:11:51.937Z" }, ] [[package]] name = "django-q2" -version = "1.7.6" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "django-picklefield" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/48/e4dc1d22ec1c366347b59c4eb9a65c2337d3cfc6139f00778d25464ae591/django_q2-1.7.6.tar.gz", hash = "sha256:5210b121573cf65b97d495dbebefe6cfac394d8c0aec9ca2117e8e56e2fda17d", size = 76849 } +sdist = { url = "https://files.pythonhosted.org/packages/59/c3/682d3966d9c56723bccd07d98b52a3a6e50a1ff76eec9f960bfa7d31da44/django_q2-1.8.0.tar.gz", hash = "sha256:e86b9625c0ce57a5ae31ca8fd7e798d63b9ef91a227c52f8b47536ba50b2b284", size = 77161, upload-time = "2025-04-25T14:42:37.384Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/ee/5c292a0d30b8a250a2389cc31bfa4a13c8c41a9fc29f4678ca6de882e23e/django_q2-1.7.6-py3-none-any.whl", hash = "sha256:9060f4d68e1f3a8a748e0ebd0bd83c8c24bc13036105035873faab9d85b0e8f6", size = 89478 }, + { url = "https://files.pythonhosted.org/packages/3f/85/aa9838ac8b65dff962366e0d79eed5fa0c2170218ea0122a110ca4638471/django_q2-1.8.0-py3-none-any.whl", hash = "sha256:78aaaf18dff1ad3e35bcf6556666f2c26494120f0b75961c13206e37d180dfaa", size = 89579, upload-time = "2025-04-25T14:42:36.029Z" }, ] [[package]] @@ -547,27 +545,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/72/9848a2d631dad70d7ea582540f0619e1a7ecf31b3a117de9d9f2b6b28029/django-settings-export-1.2.1.tar.gz", hash = "sha256:fceeae49fc597f654c1217415d8e049fc81c930b7154f5d8f28c432db738ff79", size = 4951 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/72/9848a2d631dad70d7ea582540f0619e1a7ecf31b3a117de9d9f2b6b28029/django-settings-export-1.2.1.tar.gz", hash = "sha256:fceeae49fc597f654c1217415d8e049fc81c930b7154f5d8f28c432db738ff79", size = 4951, upload-time = "2016-11-06T11:18:58Z" } [[package]] name = "django-simple-history" -version = "3.8.0" +version = "3.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/46/4cbf411f9a8e426ed721785beb2cfd37c47cd5462697d92ff29dc6943d38/django_simple_history-3.8.0.tar.gz", hash = "sha256:e70d70fb4cc2af60a50904f1420d5a6440d24efddceba3daeff8b02d269ebdf0", size = 233906 } +sdist = { url = "https://files.pythonhosted.org/packages/16/2b/8d43eed1f9de32e890e0f5780e6d623a5a7e645e0e77a515edd427029f3c/django_simple_history-3.10.1.tar.gz", hash = "sha256:040f0c2286bed730312aa15f0acee9e7e6f839c4bcd721693251aa7ec5b65d95", size = 233649, upload-time = "2025-06-20T20:22:32.722Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/f0/f09f87199a0b4e21a10314b02e04472a8cc601ead4990e813a0b3cc43f09/django_simple_history-3.8.0-py3-none-any.whl", hash = "sha256:7f8bbdaa5b2c4c1c9a48c89a95ff3389eda6c82cf9de9b09ae99b558205d132f", size = 142593 }, + { url = "https://files.pythonhosted.org/packages/01/c3/d4ba265feb7b8bad3cf0730a3a8d82bb40d14551f8c53c88895ec06fa51c/django_simple_history-3.10.1-py3-none-any.whl", hash = "sha256:e12c27abfcd7e801a9d274d94542549ce8c617b0b384ae69afb161b56cd02ba4", size = 78611, upload-time = "2025-06-20T20:22:30.878Z" }, ] [[package]] name = "django-split-settings" version = "1.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/b9/1c13089454afd7a42d492b8aa8a0c7e49eeca58c0f2ad331f361a067c876/django_split_settings-1.3.2.tar.gz", hash = "sha256:d3975aa3601e37f65c59b9977b6bcb1de8bc27496930054078589c7d56998a9d", size = 5751 } +sdist = { url = "https://files.pythonhosted.org/packages/de/b9/1c13089454afd7a42d492b8aa8a0c7e49eeca58c0f2ad331f361a067c876/django_split_settings-1.3.2.tar.gz", hash = "sha256:d3975aa3601e37f65c59b9977b6bcb1de8bc27496930054078589c7d56998a9d", size = 5751, upload-time = "2024-07-05T14:30:05.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/69/d94db8dac55bcfb6b3243578a3096cfda6c42ea5da292c36919768152ec6/django_split_settings-1.3.2-py3-none-any.whl", hash = "sha256:72bd7dd9f12602585681074d1f859643fb4f6b196b584688fab86bdd73a57dff", size = 6435 }, + { url = "https://files.pythonhosted.org/packages/63/69/d94db8dac55bcfb6b3243578a3096cfda6c42ea5da292c36919768152ec6/django_split_settings-1.3.2-py3-none-any.whl", hash = "sha256:72bd7dd9f12602585681074d1f859643fb4f6b196b584688fab86bdd73a57dff", size = 6435, upload-time = "2024-07-05T14:29:59.756Z" }, ] [[package]] @@ -578,7 +576,7 @@ dependencies = [ { name = "django" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/97/e4011f3944f83a7d2aaaf893c3689ad70e8d2ae46fb6e14fd0e3b0c6ce0b/django_sslserver-0.22-py3-none-any.whl", hash = "sha256:c598a363d2ccdc2421c08ddb3d8b0973f80e8e47a3a5b74e4a2896f21c2947c5", size = 10295 }, + { url = "https://files.pythonhosted.org/packages/6f/97/e4011f3944f83a7d2aaaf893c3689ad70e8d2ae46fb6e14fd0e3b0c6ce0b/django_sslserver-0.22-py3-none-any.whl", hash = "sha256:c598a363d2ccdc2421c08ddb3d8b0973f80e8e47a3a5b74e4a2896f21c2947c5", size = 10295, upload-time = "2019-12-10T02:30:03.76Z" }, ] [[package]] @@ -588,27 +586,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/cf/5d5bdaff569468dba3053a7a623f64fcf5e36d5a936a5617a1c1972a7da4/django-su-1.0.0.tar.gz", hash = "sha256:1a3f98b2f757a3f47e33e90047c0a81cf965805fd7f91f67089292bdd461bd1a", size = 23677 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/cf/5d5bdaff569468dba3053a7a623f64fcf5e36d5a936a5617a1c1972a7da4/django-su-1.0.0.tar.gz", hash = "sha256:1a3f98b2f757a3f47e33e90047c0a81cf965805fd7f91f67089292bdd461bd1a", size = 23677, upload-time = "2022-04-01T14:56:01.013Z" } [[package]] name = "djangorestframework" -version = "3.16.0" +version = "3.16.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/97/112c5a72e6917949b6d8a18ad6c6e72c46da4290c8f36ee5f1c1dcbc9901/djangorestframework-3.16.0.tar.gz", hash = "sha256:f022ff46613584de994c0c6a4aebbace5fd700555fbe9d33b865ebf173eba6c9", size = 1068408 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/3e/2448e93f4f87fc9a9f35e73e3c05669e0edd0c2526834686e949bb1fd303/djangorestframework-3.16.0-py3-none-any.whl", hash = "sha256:bea7e9f6b96a8584c5224bfb2e4348dfb3f8b5e34edbecb98da258e892089361", size = 1067305 }, + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, ] [[package]] name = "dnspython" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] [[package]] @@ -621,19 +619,19 @@ dependencies = [ { name = "requests" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/a0/6dd085ee0856e8c0ea39c5851bdf6fc72da392f734dca66b070b89dd1bf8/doi2bib-0.4.0-py3-none-any.whl", hash = "sha256:09401f766b1533c6d17d428ba26c65433009478f1b8d67a2b7f32871e9e8f90d", size = 6047 }, + { url = "https://files.pythonhosted.org/packages/ec/a0/6dd085ee0856e8c0ea39c5851bdf6fc72da392f734dca66b070b89dd1bf8/doi2bib-0.4.0-py3-none-any.whl", hash = "sha256:09401f766b1533c6d17d428ba26c65433009478f1b8d67a2b7f32871e9e8f90d", size = 6047, upload-time = "2020-07-17T00:23:31.816Z" }, ] [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] @@ -643,21 +641,21 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "faker" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/98/75cacae9945f67cfe323829fc2ac451f64517a8a330b572a06a323997065/factory_boy-3.3.3.tar.gz", hash = "sha256:866862d226128dfac7f2b4160287e899daf54f2612778327dd03d0e2cb1e3d03", size = 164146, upload-time = "2025-02-03T09:49:04.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036 }, + { url = "https://files.pythonhosted.org/packages/27/8d/2bc5f5546ff2ccb3f7de06742853483ab75bf74f36a92254702f8baecc79/factory_boy-3.3.3-py2.py3-none-any.whl", hash = "sha256:1c39e3289f7e667c4285433f305f8d506efc2fe9c73aaea4151ebd5cdea394fc", size = 37036, upload-time = "2025-02-03T09:49:01.659Z" }, ] [[package]] name = "faker" -version = "37.1.0" +version = "38.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/b77f42021308ec8b134502343da882c0905d725a4d661c7adeaf7acaf515/faker-37.1.0.tar.gz", hash = "sha256:ad9dc66a3b84888b837ca729e85299a96b58fdaef0323ed0baace93c9614af06", size = 1875707 } +sdist = { url = "https://files.pythonhosted.org/packages/64/27/022d4dbd4c20567b4c294f79a133cc2f05240ea61e0d515ead18c995c249/faker-38.2.0.tar.gz", hash = "sha256:20672803db9c7cb97f9b56c18c54b915b6f1d8991f63d1d673642dc43f5ce7ab", size = 1941469, upload-time = "2025-11-19T16:37:31.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/a1/8936bc8e79af80ca38288dd93ed44ed1f9d63beb25447a4c59e746e01f8d/faker-37.1.0-py3-none-any.whl", hash = "sha256:dc2f730be71cb770e9c715b13374d80dbcee879675121ab51f9683d262ae9a1c", size = 1918783 }, + { url = "https://files.pythonhosted.org/packages/17/93/00c94d45f55c336434a15f98d906387e87ce28f9918e4444829a8fda432d/faker-38.2.0-py3-none-any.whl", hash = "sha256:35fe4a0a79dee0dc4103a6083ee9224941e7d3594811a50e3969e547b0d2ee65", size = 1980505, upload-time = "2025-11-19T16:37:30.208Z" }, ] [[package]] @@ -665,25 +663,25 @@ name = "fontawesome-free" version = "5.15.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/68/6ed8f4c7efa69a479c8421d9a9d2905132c3869b118554014b8ab7291912/fontawesome_free-5.15.4-py3-none-any.whl", hash = "sha256:5d3d0edbf6ce0f7cd56978a31ea4ea697a8bb28103b5c528b3aa1f0a4474d9a1", size = 20862662 }, + { url = "https://files.pythonhosted.org/packages/01/68/6ed8f4c7efa69a479c8421d9a9d2905132c3869b118554014b8ab7291912/fontawesome_free-5.15.4-py3-none-any.whl", hash = "sha256:5d3d0edbf6ce0f7cd56978a31ea4ea697a8bb28103b5c528b3aa1f0a4474d9a1", size = 20862662, upload-time = "2021-08-04T19:22:31.34Z" }, ] [[package]] name = "formencode" version = "2.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/c3/f68b78f6f062bec9db8ab1c78a648cfc8885acded1941903a3218b9b2571/formencode-2.1.1.tar.gz", hash = "sha256:e17f16199d232e54f67912004f3ad333cdbbb81a1a1a10238acf09bab99f9199", size = 277607 } +sdist = { url = "https://files.pythonhosted.org/packages/29/c3/f68b78f6f062bec9db8ab1c78a648cfc8885acded1941903a3218b9b2571/formencode-2.1.1.tar.gz", hash = "sha256:e17f16199d232e54f67912004f3ad333cdbbb81a1a1a10238acf09bab99f9199", size = 277607, upload-time = "2025-01-31T15:32:13.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/7e/31b40531ae4bae04f795f849b6428245ee49a49bcd8472f9839fb2602ff4/FormEncode-2.1.1-py3-none-any.whl", hash = "sha256:2194d0c9bfe15c3bf9c331cca0cb73de3746f64d327cff06f097a5abb8552d2d", size = 179554 }, + { url = "https://files.pythonhosted.org/packages/6f/7e/31b40531ae4bae04f795f849b6428245ee49a49bcd8472f9839fb2602ff4/FormEncode-2.1.1-py3-none-any.whl", hash = "sha256:2194d0c9bfe15c3bf9c331cca0cb73de3746f64d327cff06f097a5abb8552d2d", size = 179554, upload-time = "2025-01-31T15:32:11.489Z" }, ] [[package]] name = "future" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326 }, + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, ] [[package]] @@ -693,52 +691,52 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] name = "griffe" -version = "1.7.2" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/08/7df7e90e34d08ad890bd71d7ba19451052f88dc3d2c483d228d1331a4736/griffe-1.7.2.tar.gz", hash = "sha256:98d396d803fab3b680c2608f300872fd57019ed82f0672f5b5323a9ad18c540c", size = 394919 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/5e/38b408f41064c9fcdbb0ea27c1bd13a1c8657c4846e04dab9f5ea770602c/griffe-1.7.2-py3-none-any.whl", hash = "sha256:1ed9c2e338a75741fc82083fe5a1bc89cb6142efe126194cc313e34ee6af5423", size = 129187 }, + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, ] [[package]] name = "gssapi" -version = "1.9.0" +version = "1.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "decorator" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/2f/fcffb772a00e658f608e657791484e3111a19a722b464e893fef35f35097/gssapi-1.9.0.tar.gz", hash = "sha256:f468fac8f3f5fca8f4d1ca19e3cd4d2e10bd91074e7285464b22715d13548afe", size = 94285 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/47/aa7f24009de06c6a20f7eee2c4accfea615452875dc15c44e5dc3292722d/gssapi-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:261e00ac426d840055ddb2199f4989db7e3ce70fa18b1538f53e392b4823e8f1", size = 708121 }, - { url = "https://files.pythonhosted.org/packages/3a/79/54f11022e09d214b3c037f9fd0c91f0a876b225e884770ef81e7dfbe0903/gssapi-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:14a1ae12fdf1e4c8889206195ba1843de09fe82587fa113112887cd5894587c6", size = 684749 }, - { url = "https://files.pythonhosted.org/packages/18/8c/1ea407d8c60be3e3e3c1d07e7b2ef3c94666e89289b9267b0ca265d2b8aa/gssapi-1.9.0-cp310-cp310-win32.whl", hash = "sha256:2a9c745255e3a810c3e8072e267b7b302de0705f8e9a0f2c5abc92fe12b9475e", size = 778871 }, - { url = "https://files.pythonhosted.org/packages/16/fd/5e073a430ced9babe0accde37c0a645124da475a617dfc741af1fff59e78/gssapi-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:dfc1b4c0bfe9f539537601c9f187edc320daf488f694e50d02d0c1eb37416962", size = 870707 }, - { url = "https://files.pythonhosted.org/packages/d1/14/39d320ac0c8c8ab05f4b48322d38aacb1572f7a51b2c5b908e51f141e367/gssapi-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:67d9be5e34403e47fb5749d5a1ad4e5a85b568e6a9add1695edb4a5b879f7560", size = 707912 }, - { url = "https://files.pythonhosted.org/packages/cc/04/5d46c5b37b96f87a8efb320ab347e876db2493e1aedaa29068936b063097/gssapi-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11e9b92cef11da547fc8c210fa720528fd854038504103c1b15ae2a89dce5fcd", size = 683779 }, - { url = "https://files.pythonhosted.org/packages/05/29/b673b4ed994796e133e3e7eeec0d8991b7dcbed6b0b4bfc95ac0fe3871ff/gssapi-1.9.0-cp311-cp311-win32.whl", hash = "sha256:6c5f8a549abd187687440ec0b72e5b679d043d620442b3637d31aa2766b27cbe", size = 776532 }, - { url = "https://files.pythonhosted.org/packages/31/07/3bb8521da3ca89e202b50f8de46a9e8e793be7f24318a4f7aaaa022d15d1/gssapi-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:59e1a1a9a6c5dc430dc6edfcf497f5ca00cf417015f781c9fac2e85652cd738f", size = 874225 }, - { url = "https://files.pythonhosted.org/packages/98/f1/76477c66aa9f2abc9ab53f936e9085402d6697db93834437e5ee651e5106/gssapi-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b66a98827fbd2864bf8993677a039d7ba4a127ca0d2d9ed73e0ef4f1baa7fd7f", size = 698148 }, - { url = "https://files.pythonhosted.org/packages/96/34/b737e2a46efc63c6a6ad3baf0f3a8484d7698e673874b060a7d52abfa7b4/gssapi-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2bddd1cc0c9859c5e0fd96d4d88eb67bd498fdbba45b14cdccfe10bfd329479f", size = 681597 }, - { url = "https://files.pythonhosted.org/packages/71/4b/4cbb8b6bc34ed02591e05af48bd4722facb99b10defc321e3b177114dbeb/gssapi-1.9.0-cp312-cp312-win32.whl", hash = "sha256:10134db0cf01bd7d162acb445762dbcc58b5c772a613e17c46cf8ad956c4dfec", size = 770295 }, - { url = "https://files.pythonhosted.org/packages/c1/73/33a65e9d6c5ea43cdb1ee184b201678adaf3a7bbb4f7a1c7a80195c884ac/gssapi-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:e28c7d45da68b7e36ed3fb3326744bfe39649f16e8eecd7b003b082206039c76", size = 867625 }, - { url = "https://files.pythonhosted.org/packages/bc/bb/6fbbeff852b6502e1d33858865822ab2e0efd84764caad1ce9e3ed182b53/gssapi-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cea344246935b5337e6f8a69bb6cc45619ab3a8d74a29fcb0a39fd1e5843c89c", size = 686934 }, - { url = "https://files.pythonhosted.org/packages/c9/72/89eeb28a2cebe8ec3a560be79e89092913d6cf9dc68b32eb4774e8bac785/gssapi-1.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a5786bd9fcf435bd0c87dc95ae99ad68cefcc2bcc80c71fef4cb0ccdfb40f1e", size = 672249 }, - { url = "https://files.pythonhosted.org/packages/5f/f7/3d9d4a198e34b844dc4acb25891e2405f8dca069a8f346f51127196436bc/gssapi-1.9.0-cp313-cp313-win32.whl", hash = "sha256:c99959a9dd62358e370482f1691e936cb09adf9a69e3e10d4f6a097240e9fd28", size = 755372 }, - { url = "https://files.pythonhosted.org/packages/67/00/f4be5211d5dd8e9ca551ded3071b1433880729006768123e1ee7b744b1d8/gssapi-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a2e43f50450e81fe855888c53df70cdd385ada979db79463b38031710a12acd9", size = 845005 }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a4406651de13fced3c1ea18ddb52fbd19498deaf62c5d76df2a6bc10a4b0/gssapi-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cbc93fdadd5aab9bae594538b2128044b8c5cdd1424fe015a465d8a8a587411a", size = 712110 }, - { url = "https://files.pythonhosted.org/packages/84/d3/731b84430ed06fbf3f1e07b265a5f6880dfbcf17c665383b5f616307034b/gssapi-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b2a3c0a9beb895942d4b8e31f515e52c17026e55aeaa81ee0df9bbfdac76098", size = 688419 }, - { url = "https://files.pythonhosted.org/packages/e9/b8/8a100d57d9723aba471a557153cb48c517920221e9e5e8ed94046e3652bc/gssapi-1.9.0-cp39-cp39-win32.whl", hash = "sha256:060b58b455d29ab8aca74770e667dca746264bee660ac5b6a7a17476edc2c0b8", size = 781559 }, - { url = "https://files.pythonhosted.org/packages/88/14/2a448c2d4a5a29b6471ef1202fa151cf3a9a5210b913a7b1e9f323d3345f/gssapi-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:11c9fe066edb0fa0785697eb0cecf2719c7ad1d9f2bf27be57b647a617bcfaa5", size = 874036 }, +sdist = { url = "https://files.pythonhosted.org/packages/b7/bf/95eed332e3911e2b113ceef5e6b0da807b22e45dbf897d8371e83b0a4958/gssapi-1.10.1.tar.gz", hash = "sha256:7b54335dc9a3c55d564624fb6e25fcf9cfc0b80296a5c51e9c7cf9781c7d295b", size = 94262, upload-time = "2025-10-03T03:08:49.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/c2/0e25252f96f4213a666a32fdbfd5a287f115aec8bdb8a2e14af3ca392b7f/gssapi-1.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1456b60bbb999c7d2bf323c1ca9ee077dc6a59368737401c302c64bf0dd8a119", size = 669529, upload-time = "2025-10-03T03:08:10.212Z" }, + { url = "https://files.pythonhosted.org/packages/12/55/b948e8f104c99ef669ae939442651e9817e4584e9b056ff488138b6cd676/gssapi-1.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d75edd60f3105b362e4841ff86f358a9c6b9849e1327ea527b6a17b86e459207", size = 690220, upload-time = "2025-10-03T03:08:12.401Z" }, + { url = "https://files.pythonhosted.org/packages/f8/11/9779cbf496cff411bcce75379f7d7dc51d7831c93aa880d98e0b0e7be72f/gssapi-1.10.1-cp310-cp310-win32.whl", hash = "sha256:a589980c2c8c7ec7537b26b3d8d3becf26daf6f84a9534c54b3376220a9e82b5", size = 735551, upload-time = "2025-10-03T03:08:14.019Z" }, + { url = "https://files.pythonhosted.org/packages/da/f5/3d99ff06a12141cd02a751a5934a90c298b971fae0568965f55664567934/gssapi-1.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:77e92a1bdc4c72af4f1aa850787038741bd38e3fa6c52defee50125509539ffe", size = 820227, upload-time = "2025-10-03T03:08:15.837Z" }, + { url = "https://files.pythonhosted.org/packages/26/e4/d9d088d3dd7ab4009589af9d774d39e13de85709842210afa846efb02eb0/gssapi-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44be38aef1b26270dc23c43d8f124f13cf839cadcba63f5d011793eca2ec95f2", size = 675556, upload-time = "2025-10-03T03:08:17.743Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/ca520b74838edc98cdc3182821539a29da3cd2f00d94b70f860107d84a10/gssapi-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0be7195c96968df44f3cd2b79bbfa2ca3729d4bd91374947e93fde827bdab37f", size = 696622, upload-time = "2025-10-03T03:08:19.5Z" }, + { url = "https://files.pythonhosted.org/packages/bf/da/e7691856ebd762a09d4410fd6dcdb65aa7b09c258b70bf14a04d07ac69e2/gssapi-1.10.1-cp311-cp311-win32.whl", hash = "sha256:048736351b013290081472b2e523251246bc96d7ea74c97189d2af31f7d20bd6", size = 734716, upload-time = "2025-10-03T03:08:21.475Z" }, + { url = "https://files.pythonhosted.org/packages/ff/75/881178aac0bf010ca2608dd6b870e9b7c106ebee3203ddde202f45f934b1/gssapi-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:93166ed5d3ce53af721c2a9a115ffa645900f4b71c4810a18bff10f0a9843d0e", size = 823520, upload-time = "2025-10-03T03:08:22.942Z" }, + { url = "https://files.pythonhosted.org/packages/fa/6f/b2dd133e3accf4be9106258331735b5d56959c018fb4b1952f70b35a3055/gssapi-1.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5c08ae5b5fa3faae1ad5bf9d4821a27da6974df0bf994066bf8e437ff101429", size = 672855, upload-time = "2025-10-03T03:08:24.649Z" }, + { url = "https://files.pythonhosted.org/packages/a8/42/6f499af7de07d1a3e7ad6af789a4a9b097d13b0342629bb152171bfee45f/gssapi-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec74a5e70241655b79c7de7dc750c58dae80482947973e019c67c8d53311981", size = 696430, upload-time = "2025-10-03T03:08:26.331Z" }, + { url = "https://files.pythonhosted.org/packages/20/81/4f70ad5ee531800fecbddd38870c16922d18cb9b5d4be2e1f4354a160f9b/gssapi-1.10.1-cp312-cp312-win32.whl", hash = "sha256:ed40213beec30115302bac3849134fbbfd5b0fdb60d8e4f2d9027cd44765f42b", size = 732078, upload-time = "2025-10-03T03:08:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/35/34/99ebc21b95765491af00d92b8332dba9ae5d357707ba81f05ba537acc4f8/gssapi-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:f0d5e5e6031e879d4050e0373cf854f5082ca234127b6553026a29c64ddf64ed", size = 826944, upload-time = "2025-10-03T03:08:29.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a9/39b5eefe1f7881d3021925c0a3183f1aa1a64d1cfe3ff6a5ab3253ddc2ef/gssapi-1.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:952c900ced1cafe7e7938052e24d01d4ba48f234a0ca7347c854c6d96f94ae26", size = 658891, upload-time = "2025-10-03T03:08:31.001Z" }, + { url = "https://files.pythonhosted.org/packages/15/09/9def6b103752da8e9d51a4258ffe2d4a97191e1067a1581324480b752471/gssapi-1.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df86f1dcc2a1c19c1771565661d05dd09cb1ce7ff2c3be261b3b5312458969f3", size = 682324, upload-time = "2025-10-03T03:08:32.685Z" }, + { url = "https://files.pythonhosted.org/packages/8b/24/615e0544dbf8bcb002d7f15bff44af502be99ed4ed2a64190779f47b0bc7/gssapi-1.10.1-cp313-cp313-win32.whl", hash = "sha256:37c2abb85e76d9e4bef967a752354aa6a365bb965eb18067f1f012aad0f7a446", size = 719627, upload-time = "2025-10-03T03:08:34.193Z" }, + { url = "https://files.pythonhosted.org/packages/16/b4/3c1c5dad78b193626a035661196dc3bed4d1544dd57e609fb6cc0e8838e5/gssapi-1.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:d821d37afd61c326ba729850c9836d84e5d38ad42acec21784fb22dd467345f4", size = 808059, upload-time = "2025-10-03T03:08:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/6c6bba3a06bc9e5c7fd7a8b4337c392b3074cbbce11525c94e8b7af856e9/gssapi-1.10.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a4d2aa439bcd08cd524a6e0c566137850e681b0fed62480aa765c097344387d7", size = 657421, upload-time = "2025-10-03T03:08:37.406Z" }, + { url = "https://files.pythonhosted.org/packages/55/3a/414e9cfa3c4f14682e40a5d61b8181936c78abf4aff0f1a91e9adaa20b5c/gssapi-1.10.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:86758d03906e10cb7feeedf26b5ead6661e844c54ef09d5e7de8e5ffb1154932", size = 685642, upload-time = "2025-10-03T03:08:39.115Z" }, + { url = "https://files.pythonhosted.org/packages/29/e4/812ef20519f020122b5207600fda2906a3d4fcc6536c8aeb764012c28470/gssapi-1.10.1-cp314-cp314-win32.whl", hash = "sha256:2ef6e30c37676fbb2f635467e560c9a5e7b3f49ee9536ecb363939efa81c82bc", size = 740154, upload-time = "2025-10-03T03:08:40.46Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fc/838a46df536111602d6582f8e8efecccaaf828b690c6305a2ef276c71e5e/gssapi-1.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:8f311cec5eabe0ce417908bcf50f60afa91a5b455884794eb02eb35a41d410c7", size = 826869, upload-time = "2025-10-03T03:08:42.524Z" }, ] [[package]] @@ -748,48 +746,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 } +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 }, + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, ] [[package]] name = "humanize" -version = "4.12.2" +version = "4.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/84/ae8e64a6ffe3291105e9688f4e28fa65eba7924e0fe6053d85ca00556385/humanize-4.12.2.tar.gz", hash = "sha256:ce0715740e9caacc982bb89098182cf8ded3552693a433311c6a4ce6f4e12a2c", size = 80871 } +sdist = { url = "https://files.pythonhosted.org/packages/b6/43/50033d25ad96a7f3845f40999b4778f753c3901a11808a584fed7c00d9f5/humanize-4.14.0.tar.gz", hash = "sha256:2fa092705ea640d605c435b1ca82b2866a1b601cdf96f076d70b79a855eba90d", size = 82939, upload-time = "2025-10-15T13:04:51.214Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/c7/6f89082f619c76165feb633446bd0fee32b0e0cbad00d22480e5aea26ade/humanize-4.12.2-py3-none-any.whl", hash = "sha256:e4e44dced598b7e03487f3b1c6fd5b1146c30ea55a110e71d5d4bca3e094259e", size = 128305 }, + { url = "https://files.pythonhosted.org/packages/c3/5b/9512c5fb6c8218332b530f13500c6ff5f3ce3342f35e0dd7be9ac3856fd3/humanize-4.14.0-py3-none-any.whl", hash = "sha256:d57701248d040ad456092820e6fde56c930f17749956ac47f4f655c0c547bfff", size = 132092, upload-time = "2025-10-15T13:04:49.404Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -804,7 +790,7 @@ dependencies = [ { name = "six" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/23/23/dcb83bd7ae4727a0a655c0082566c6c500c215deb110ce55218a17f588b3/ipaclient-4.12.2-py2.py3-none-any.whl", hash = "sha256:f22a02acea8426a3ebd0dbefc1491618f0ce61bc87934eed213cd35175c7b7bf", size = 586136 }, + { url = "https://files.pythonhosted.org/packages/23/23/dcb83bd7ae4727a0a655c0082566c6c500c215deb110ce55218a17f588b3/ipaclient-4.12.2-py2.py3-none-any.whl", hash = "sha256:f22a02acea8426a3ebd0dbefc1491618f0ce61bc87934eed213cd35175c7b7bf", size = 586136, upload-time = "2024-09-24T17:44:06.3Z" }, ] [[package]] @@ -821,7 +807,7 @@ dependencies = [ { name = "urllib3" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/57/4e/e13b27d2aaa1e07b607a38bf6d06b7b2577a3dab0165abe9b2f9d581f55b/ipalib-4.12.2-py2.py3-none-any.whl", hash = "sha256:203170ff3e17466aa192aeb7da3001326a9f101343b200daba0d0dbf4f72608c", size = 180848 }, + { url = "https://files.pythonhosted.org/packages/57/4e/e13b27d2aaa1e07b607a38bf6d06b7b2577a3dab0165abe9b2f9d581f55b/ipalib-4.12.2-py2.py3-none-any.whl", hash = "sha256:203170ff3e17466aa192aeb7da3001326a9f101343b200daba0d0dbf4f72608c", size = 180848, upload-time = "2024-09-24T17:44:07.915Z" }, ] [[package]] @@ -835,7 +821,7 @@ dependencies = [ { name = "six" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/0f/ef74842d75dcbf03c455bf4082370d7fbd49700773d4b9455c8a32b0fde4/ipaplatform-4.12.2-py2.py3-none-any.whl", hash = "sha256:3f3ab6aa30869db16c003f329a9ecb7aa10d3b63a6a44e9cc1fb71fe5b2b395a", size = 90946 }, + { url = "https://files.pythonhosted.org/packages/1a/0f/ef74842d75dcbf03c455bf4082370d7fbd49700773d4b9455c8a32b0fde4/ipaplatform-4.12.2-py2.py3-none-any.whl", hash = "sha256:3f3ab6aa30869db16c003f329a9ecb7aa10d3b63a6a44e9cc1fb71fe5b2b395a", size = 90946, upload-time = "2024-09-24T17:44:09.711Z" }, ] [[package]] @@ -852,7 +838,7 @@ dependencies = [ { name = "six" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/87/ed/e9cec0946f8c2cf247b4bb4d231b64b4059a3d3e20e48a25139205650c8c/ipapython-4.12.2-py2.py3-none-any.whl", hash = "sha256:5b95f03d1c83ac0c2ec8d1cc0ca8297e2fbb69aa1d9cddec235c71f954af9e45", size = 123738 }, + { url = "https://files.pythonhosted.org/packages/87/ed/e9cec0946f8c2cf247b4bb4d231b64b4059a3d3e20e48a25139205650c8c/ipapython-4.12.2-py2.py3-none-any.whl", hash = "sha256:5b95f03d1c83ac0c2ec8d1cc0ca8297e2fbb69aa1d9cddec235c71f954af9e45", size = 123738, upload-time = "2024-09-24T17:44:11.799Z" }, ] [[package]] @@ -862,31 +848,28 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "josepy" -version = "2.0.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/29/e7c14150f200c5cd49d1a71b413f61b97406f57872ad693857982c0869c9/josepy-2.0.0.tar.gz", hash = "sha256:e7d7acd2fe77435cda76092abe4950bb47b597243a8fb733088615fa6de9ec40", size = 55767 } +sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/de/4e1509bdf222503941c6cfcfa79369aa00f385c02e55eef3bfcb84f5e0f8/josepy-2.0.0-py3-none-any.whl", hash = "sha256:eb50ec564b1b186b860c7738769274b97b19b5b831239669c0f3d5c86b62a4c0", size = 28923 }, + { url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" }, ] [[package]] name = "kerberos" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f98699a6e806b9d974ea1d3376b91f09edcb90415adbf31e3b56ee99ba64/kerberos-1.3.1.tar.gz", hash = "sha256:cdd046142a4e0060f96a00eb13d82a5d9ebc0f2d7934393ed559bac773460a2c", size = 19126 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/9a/d10386fa7da4588e61fdafdbac2953576f7de6f693d112c74f09a9749fb6/kerberos-1.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2002b3b1541fc51e2c081ee7048f55e5d9ca63dd09f0d7b951c263920db3a0bb", size = 20248 }, -] +sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f98699a6e806b9d974ea1d3376b91f09edcb90415adbf31e3b56ee99ba64/kerberos-1.3.1.tar.gz", hash = "sha256:cdd046142a4e0060f96a00eb13d82a5d9ebc0f2d7934393ed559bac773460a2c", size = 19126, upload-time = "2021-01-09T06:43:46.862Z" } [[package]] name = "ldap3" @@ -895,110 +878,124 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830 } +sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830, upload-time = "2021-07-18T06:34:21.786Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192 }, + { url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" }, ] [[package]] name = "license-expression" -version = "30.4.1" +version = "30.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boolean-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/6f/8709031ea6e0573e6075d24ea34507b0eb32f83f10e1420f2e34606bf0da/license_expression-30.4.1.tar.gz", hash = "sha256:9f02105f9e0fcecba6a85dfbbed7d94ea1c3a70cf23ddbfb5adf3438a6f6fce0", size = 177184 } +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/84/8a89614b2e7eeeaf0a68a4046d6cfaea4544c8619ea02595ebeec9b2bae3/license_expression-30.4.1-py3-none-any.whl", hash = "sha256:679646bc3261a17690494a3e1cada446e5ee342dbd87dcfa4a0c24cc5dce13ee", size = 111457 }, + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] [[package]] name = "markdown" -version = "3.7" +version = "3.10" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349 }, + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, ] [[package]] name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, - { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, - { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, - { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, - { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, - { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, - { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, - { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, - { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, - { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, - { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] @@ -1009,7 +1006,6 @@ dependencies = [ { name = "click" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "ghp-import" }, - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "jinja2" }, { name = "markdown" }, { name = "markupsafe" }, @@ -1021,23 +1017,23 @@ dependencies = [ { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] name = "mkdocs-autorefs" -version = "1.4.1" +version = "1.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/44/140469d87379c02f1e1870315f3143718036a983dd0416650827b8883192/mkdocs_autorefs-1.4.1.tar.gz", hash = "sha256:4b5b6235a4becb2b10425c2fa191737e415b37aa3418919db33e5d774c9db079", size = 4131355 } +sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/29/1125f7b11db63e8e32bcfa0752a4eea30abff3ebd0796f808e14571ddaa2/mkdocs_autorefs-1.4.1-py3-none-any.whl", hash = "sha256:9793c5ac06a6ebbe52ec0f8439256e66187badf4b5334b5fde0b128ec134df4f", size = 5782047 }, + { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, ] [[package]] @@ -1049,9 +1045,9 @@ dependencies = [ { name = "natsort" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/e8/6ae9c18d8174a5d74ce4ade7a7f4c350955063968bc41ff1e5833cff4a2b/mkdocs_awesome_pages_plugin-2.10.1.tar.gz", hash = "sha256:cda2cb88c937ada81a4785225f20ef77ce532762f4500120b67a1433c1cdbb2f", size = 16303 } +sdist = { url = "https://files.pythonhosted.org/packages/92/e8/6ae9c18d8174a5d74ce4ade7a7f4c350955063968bc41ff1e5833cff4a2b/mkdocs_awesome_pages_plugin-2.10.1.tar.gz", hash = "sha256:cda2cb88c937ada81a4785225f20ef77ce532762f4500120b67a1433c1cdbb2f", size = 16303, upload-time = "2024-12-22T21:13:49.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/61/19fc1e9c579dbfd4e8a402748f1d63cab7aabe8f8d91eb0235e45b32d040/mkdocs_awesome_pages_plugin-2.10.1-py3-none-any.whl", hash = "sha256:c6939dbea37383fc3cf8c0a4e892144ec3d2f8a585e16fdc966b34e7c97042a7", size = 15118 }, + { url = "https://files.pythonhosted.org/packages/73/61/19fc1e9c579dbfd4e8a402748f1d63cab7aabe8f8d91eb0235e45b32d040/mkdocs_awesome_pages_plugin-2.10.1-py3-none-any.whl", hash = "sha256:c6939dbea37383fc3cf8c0a4e892144ec3d2f8a585e16fdc966b34e7c97042a7", size = 15118, upload-time = "2024-12-22T21:13:46.945Z" }, ] [[package]] @@ -1059,22 +1055,20 @@ name = "mkdocs-get-deps" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "mergedeep" }, { name = "platformdirs" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239 } +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521 }, + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, ] [[package]] name = "mkdocstrings" -version = "0.29.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, { name = "jinja2" }, { name = "markdown" }, { name = "markupsafe" }, @@ -1082,14 +1076,14 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/e8/d22922664a627a0d3d7ff4a6ca95800f5dde54f411982591b4621a76225d/mkdocstrings-0.29.1.tar.gz", hash = "sha256:8722f8f8c5cd75da56671e0a0c1bbed1df9946c0cef74794d6141b34011abd42", size = 1212686 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/13/10bbf9d56565fd91b91e6f5a8cd9b9d8a2b101c4e8ad6eeafa35a706301d/mkdocstrings-1.0.0.tar.gz", hash = "sha256:351a006dbb27aefce241ade110d3cd040c1145b7a3eb5fd5ac23f03ed67f401a", size = 101086, upload-time = "2025-11-27T15:39:40.534Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/14/22533a578bf8b187e05d67e2c1721ce10e3f526610eebaf7a149d557ea7a/mkdocstrings-0.29.1-py3-none-any.whl", hash = "sha256:37a9736134934eea89cbd055a513d40a020d87dfcae9e3052c2a6b8cd4af09b6", size = 1631075 }, + { url = "https://files.pythonhosted.org/packages/ec/fc/80aa31b79133634721cf7855d37b76ea49773599214896f2ff10be03de2a/mkdocstrings-1.0.0-py3-none-any.whl", hash = "sha256:4c50eb960bff6e05dfc631f6bc00dfabffbcb29c5ff25f676d64daae05ed82fa", size = 35135, upload-time = "2025-11-27T15:39:39.301Z" }, ] [[package]] name = "mkdocstrings-python" -version = "1.16.10" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe" }, @@ -1097,9 +1091,9 @@ dependencies = [ { name = "mkdocstrings" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/c8/600c4201b6b9e72bab16802316d0c90ce04089f8e6bb5e064cd2a5abba7e/mkdocstrings_python-1.16.10.tar.gz", hash = "sha256:f9eedfd98effb612ab4d0ed6dd2b73aff6eba5215e0a65cea6d877717f75502e", size = 205771 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/0d/dab7b08ca7e5a38b033cd83565bb0f95f05e8f3df7bc273e793c2ad3576e/mkdocstrings_python-2.0.0.tar.gz", hash = "sha256:4d872290f595221740a304bebca5b3afa4beafe84cc6fd27314d52dc3fbb4676", size = 199113, upload-time = "2025-11-27T16:44:44.894Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/37/19549c5e0179785308cc988a68e16aa7550e4e270ec8a9878334e86070c6/mkdocstrings_python-1.16.10-py3-none-any.whl", hash = "sha256:63bb9f01f8848a644bdb6289e86dc38ceddeaa63ecc2e291e3b2ca52702a6643", size = 124112 }, + { url = "https://files.pythonhosted.org/packages/79/de/063481352688c3a1468c51c10b6cfb858d5e35dfef8323d9c83c4f2faa03/mkdocstrings_python-2.0.0-py3-none-any.whl", hash = "sha256:1d552dda109d47e4fddecbb1f06f9a86699c1b073e8b166fba89eeef0a0ffec6", size = 104803, upload-time = "2025-11-27T16:44:43.441Z" }, ] [[package]] @@ -1112,104 +1106,98 @@ dependencies = [ { name = "josepy" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/f9/1ca554a62bf8a4fd31b68209df8603075c2b7436400ea3f7ddd597f204a5/mozilla-django-oidc-4.0.1.tar.gz", hash = "sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918", size = 49027 } +sdist = { url = "https://files.pythonhosted.org/packages/90/f9/1ca554a62bf8a4fd31b68209df8603075c2b7436400ea3f7ddd597f204a5/mozilla-django-oidc-4.0.1.tar.gz", hash = "sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918", size = 49027, upload-time = "2024-03-12T12:29:26.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d6/2b75bf4e742c54028ae07a1fb5a2624e5a73e9cfd2185c2df0e22cbfe14e/mozilla_django_oidc-4.0.1-py2.py3-none-any.whl", hash = "sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca", size = 29059 }, + { url = "https://files.pythonhosted.org/packages/ce/d6/2b75bf4e742c54028ae07a1fb5a2624e5a73e9cfd2185c2df0e22cbfe14e/mozilla_django_oidc-4.0.1-py2.py3-none-any.whl", hash = "sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca", size = 29059, upload-time = "2024-03-12T12:29:24.978Z" }, ] [[package]] name = "mysqlclient" version = "2.2.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/68/810093cb579daae426794bbd9d88aa830fae296e85172d18cb0f0e5dd4bc/mysqlclient-2.2.7.tar.gz", hash = "sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845", size = 91383 } +sdist = { url = "https://files.pythonhosted.org/packages/61/68/810093cb579daae426794bbd9d88aa830fae296e85172d18cb0f0e5dd4bc/mysqlclient-2.2.7.tar.gz", hash = "sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845", size = 91383, upload-time = "2025-01-10T12:06:00.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/24/cdaaef42aac7d53c0a01bb638da64961c293b1b6d204efd47400a68029d4/mysqlclient-2.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:2e3c11f7625029d7276ca506f8960a7fd3c5a0a0122c9e7404e6a8fe961b3d22", size = 207748 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3e2de3f93cd60dd63bd229ec3e3b679f682982614bf513d046c2722aa4ce/mysqlclient-2.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687", size = 207745 }, - { url = "https://files.pythonhosted.org/packages/bb/b5/2a8a4bcba3440550f358b839638fe8ec9146fa3c9194890b4998a530c926/mysqlclient-2.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:4b4c0200890837fc64014cc938ef2273252ab544c1b12a6c1d674c23943f3f2e", size = 208032 }, - { url = "https://files.pythonhosted.org/packages/29/01/e80141f1cd0459e4c9a5dd309dee135bbae41d6c6c121252fdd853001a8a/mysqlclient-2.2.7-cp313-cp313-win_amd64.whl", hash = "sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5", size = 208000 }, - { url = "https://files.pythonhosted.org/packages/0e/e0/524b0777524e0d410f71987f556dd6a0e3273fdb06cd6e91e96afade7220/mysqlclient-2.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:199dab53a224357dd0cb4d78ca0e54018f9cee9bf9ec68d72db50e0a23569076", size = 207857 }, - { url = "https://files.pythonhosted.org/packages/16/cc/5b1570be9f8597ee41e2a0bd7b62ba861ec2c81898d9449f3d6bfbe15d29/mysqlclient-2.2.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585", size = 207800 }, - { url = "https://files.pythonhosted.org/packages/20/40/b5d03494c1caa8f4da171db41d8d9d5b0d8959f893761597d97420083362/mysqlclient-2.2.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069", size = 207965 }, + { url = "https://files.pythonhosted.org/packages/0c/24/cdaaef42aac7d53c0a01bb638da64961c293b1b6d204efd47400a68029d4/mysqlclient-2.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:2e3c11f7625029d7276ca506f8960a7fd3c5a0a0122c9e7404e6a8fe961b3d22", size = 207748, upload-time = "2025-01-10T11:56:24.357Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3e2de3f93cd60dd63bd229ec3e3b679f682982614bf513d046c2722aa4ce/mysqlclient-2.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687", size = 207745, upload-time = "2025-01-10T11:56:28.67Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b5/2a8a4bcba3440550f358b839638fe8ec9146fa3c9194890b4998a530c926/mysqlclient-2.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:4b4c0200890837fc64014cc938ef2273252ab544c1b12a6c1d674c23943f3f2e", size = 208032, upload-time = "2025-01-10T11:56:29.879Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/e80141f1cd0459e4c9a5dd309dee135bbae41d6c6c121252fdd853001a8a/mysqlclient-2.2.7-cp313-cp313-win_amd64.whl", hash = "sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5", size = 208000, upload-time = "2025-01-10T11:56:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/16/cc/5b1570be9f8597ee41e2a0bd7b62ba861ec2c81898d9449f3d6bfbe15d29/mysqlclient-2.2.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585", size = 207800, upload-time = "2025-01-10T11:56:36.023Z" }, ] [[package]] name = "natsort" version = "8.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575 } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268 }, + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, ] [[package]] name = "netaddr" version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/90/188b2a69654f27b221fba92fda7217778208532c962509e959a9cee5229d/netaddr-1.3.0.tar.gz", hash = "sha256:5c3c3d9895b551b763779ba7db7a03487dc1f8e3b385af819af341ae9ef6e48a", size = 2260504 } +sdist = { url = "https://files.pythonhosted.org/packages/54/90/188b2a69654f27b221fba92fda7217778208532c962509e959a9cee5229d/netaddr-1.3.0.tar.gz", hash = "sha256:5c3c3d9895b551b763779ba7db7a03487dc1f8e3b385af819af341ae9ef6e48a", size = 2260504, upload-time = "2024-05-28T21:30:37.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cc/f4fe2c7ce68b92cbf5b2d379ca366e1edae38cccaad00f69f529b460c3ef/netaddr-1.3.0-py3-none-any.whl", hash = "sha256:c2c6a8ebe5554ce33b7d5b3a306b71bbb373e000bbbf2350dd5213cc56e3dbbe", size = 2262023 }, + { url = "https://files.pythonhosted.org/packages/12/cc/f4fe2c7ce68b92cbf5b2d379ca366e1edae38cccaad00f69f529b460c3ef/netaddr-1.3.0-py3-none-any.whl", hash = "sha256:c2c6a8ebe5554ce33b7d5b3a306b71bbb373e000bbbf2350dd5213cc56e3dbbe", size = 2262023, upload-time = "2024-05-28T21:30:34.191Z" }, ] [[package]] name = "packaging" -version = "24.2" +version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "pathspec" version = "0.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] [[package]] name = "platformdirs" -version = "4.3.7" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "psycopg2" -version = "2.9.10" +version = "2.9.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672 } +sdist = { url = "https://files.pythonhosted.org/packages/89/8d/9d12bc8677c24dad342ec777529bce705b3e785fa05d85122b5502b9ab55/psycopg2-2.9.11.tar.gz", hash = "sha256:964d31caf728e217c697ff77ea69c2ba0865fa41ec20bb00f0977e62fdcc52e3", size = 379598, upload-time = "2025-10-10T11:14:46.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/a9/146b6bdc0d33539a359f5e134ee6dda9173fb8121c5b96af33fa299e50c4/psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716", size = 1024527 }, - { url = "https://files.pythonhosted.org/packages/47/50/c509e56f725fd2572b59b69bd964edaf064deebf1c896b2452f6b46fdfb3/psycopg2-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:c6f7b8561225f9e711a9c47087388a97fdc948211c10a4bccbf0ba68ab7b3b5a", size = 1163735 }, - { url = "https://files.pythonhosted.org/packages/20/a2/c51ca3e667c34e7852157b665e3d49418e68182081060231d514dd823225/psycopg2-2.9.10-cp311-cp311-win32.whl", hash = "sha256:47c4f9875125344f4c2b870e41b6aad585901318068acd01de93f3677a6522c2", size = 1024538 }, - { url = "https://files.pythonhosted.org/packages/33/39/5a9a229bb5414abeb86e33b8fc8143ab0aecce5a7f698a53e31367d30caa/psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4", size = 1163736 }, - { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113 }, - { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951 }, - { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060 }, - { url = "https://files.pythonhosted.org/packages/5f/29/bc9639b9c50abd93a8274fd2deffbf70b2a65aa9e7881e63ea6bc4319e84/psycopg2-2.9.10-cp39-cp39-win32.whl", hash = "sha256:9d5b3b94b79a844a986d029eee38998232451119ad653aea42bb9220a8c5066b", size = 1025259 }, - { url = "https://files.pythonhosted.org/packages/2c/f8/0be7d99d24656b689d83ac167240c3527efb0b161d814fb1dd58329ddf75/psycopg2-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:88138c8dedcbfa96408023ea2b0c369eda40fe5d75002c0964c78f46f11fa442", size = 1163878 }, + { url = "https://files.pythonhosted.org/packages/e3/ba/b7672ed9d0be238265972ef52a7a8c9e9e815ca2a7dc19a1b2e4b5b637f0/psycopg2-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:103e857f46bb76908768ead4e2d0ba1d1a130e7b8ed77d3ae91e8b33481813e8", size = 2713725, upload-time = "2025-10-10T11:10:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/86/fe/d6dce306fd7b61e312757ba4d068617f562824b9c6d3e4a39fc578ea2814/psycopg2-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:210daed32e18f35e3140a1ebe059ac29209dd96468f2f7559aa59f75ee82a5cb", size = 2713723, upload-time = "2025-10-10T11:10:12.957Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bf/635fbe5dd10ed200afbbfbe98f8602829252ca1cce81cc48fb25ed8dadc0/psycopg2-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:e03e4a6dbe87ff81540b434f2e5dc2bddad10296db5eea7bdc995bf5f4162938", size = 2713969, upload-time = "2025-10-10T11:10:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/88/5a/18c8cb13fc6908dc41a483d2c14d927a7a3f29883748747e8cb625da6587/psycopg2-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:8dc379166b5b7d5ea66dcebf433011dfc51a7bb8a5fc12367fa05668e5fc53c8", size = 2714048, upload-time = "2025-10-10T11:10:19.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" }, ] [[package]] name = "pyasn1" version = "0.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, ] [[package]] @@ -1219,54 +1207,54 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycparser" -version = "2.22" +version = "2.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pymdown-extensions" -version = "10.14.3" +version = "10.17.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/44/e6de2fdc880ad0ec7547ca2e087212be815efbc9a425a8d5ba9ede602cbb/pymdown_extensions-10.14.3.tar.gz", hash = "sha256:41e576ce3f5d650be59e900e4ceff231e0aed2a88cf30acaee41e02f063a061b", size = 846846 } +sdist = { url = "https://files.pythonhosted.org/packages/25/6d/af5378dbdb379fddd9a277f8b9888c027db480cde70028669ebd009d642a/pymdown_extensions-10.17.2.tar.gz", hash = "sha256:26bb3d7688e651606260c90fb46409fbda70bf9fdc3623c7868643a1aeee4713", size = 847344, upload-time = "2025-11-26T15:43:57.004Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/f5/b9e2a42aa8f9e34d52d66de87941ecd236570c7ed2e87775ed23bbe4e224/pymdown_extensions-10.14.3-py3-none-any.whl", hash = "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9", size = 264467 }, + { url = "https://files.pythonhosted.org/packages/93/78/b93cb80bd673bdc9f6ede63d8eb5b4646366953df15667eb3603be57a2b1/pymdown_extensions-10.17.2-py3-none-any.whl", hash = "sha256:bffae79a2e8b9e44aef0d813583a8fea63457b7a23643a43988055b7b79b4992", size = 266556, upload-time = "2025-11-26T15:43:55.162Z" }, ] [[package]] name = "pyparsing" -version = "3.2.3" +version = "3.2.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, ] [[package]] name = "pytest" -version = "8.3.5" +version = "9.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1274,11 +1262,12 @@ dependencies = [ { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, + { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, ] [[package]] @@ -1288,9 +1277,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281 }, + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, ] [[package]] @@ -1300,9 +1289,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] @@ -1312,113 +1301,133 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/4b/3c4cf635311b6203f17c2d693dc15e898969983dd3f729bee3c428aa60d4/python-debian-1.0.1.tar.gz", hash = "sha256:3ada9b83a3d671b58081782c0969cffa0102f6ce433fbbc7cf21275b8b5cc771", size = 127249 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/4b/3c4cf635311b6203f17c2d693dc15e898969983dd3f729bee3c428aa60d4/python-debian-1.0.1.tar.gz", hash = "sha256:3ada9b83a3d671b58081782c0969cffa0102f6ce433fbbc7cf21275b8b5cc771", size = 127249, upload-time = "2025-03-11T12:27:27.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/15/e8096189b18dda72e4923622abc10b021ecff723b397e22eff29fb86637b/python_debian-1.0.1-py3-none-any.whl", hash = "sha256:8f137c230c1d9279c2ac892b35915068b2aca090c9fd3da5671ff87af32af12c", size = 137453 }, + { url = "https://files.pythonhosted.org/packages/ba/15/e8096189b18dda72e4923622abc10b021ecff723b397e22eff29fb86637b/python_debian-1.0.1-py3-none-any.whl", hash = "sha256:8f137c230c1d9279c2ac892b35915068b2aca090c9fd3da5671ff87af32af12c", size = 137453, upload-time = "2025-03-11T12:27:25.014Z" }, ] [[package]] name = "python-ldap" -version = "3.4.4" +version = "3.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/8b/1eeb4025dc1d3ac2f16678f38dec9ebdde6271c74955b72db5ce7a4dbfbd/python-ldap-3.4.4.tar.gz", hash = "sha256:7edb0accec4e037797705f3a05cbf36a9fde50d08c8f67f2aef99a2628fab828", size = 377889 } +sdist = { url = "https://files.pythonhosted.org/packages/0c/88/8d2797decc42e1c1cdd926df4f005e938b0643d0d1219c08c2b5ee8ae0c0/python_ldap-3.4.5.tar.gz", hash = "sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a", size = 388482, upload-time = "2025-10-10T20:00:39.06Z" } + +[[package]] +name = "python-magic" +version = "0.4.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, +] [[package]] name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, - { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, - { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, - { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, - { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, - { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, - { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, - { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, - { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, - { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "pyyaml-env-tag" -version = "0.1" +version = "1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/8e/da1c6c58f751b70f8ceb1eb25bc25d524e8f14fe16edcce3f4e3ba08629c/pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", size = 5631 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911 }, + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] name = "qrcode" -version = "8.1" +version = "8.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/d4/d222d00f65c81945b55e8f64011c33cb11a2931957ba3e2845fb0874fffe/qrcode-8.1.tar.gz", hash = "sha256:e8df73caf72c3bace3e93d9fa0af5aa78267d4f3f5bc7ab1b208f271605a5e48", size = 41549 } +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/e6/273de1f5cda537b00bc2947082be747f1d76358db8b945f3a60837bcd0f6/qrcode-8.1-py3-none-any.whl", hash = "sha256:9beba317d793ab8b3838c52af72e603b8ad2599c4e9bbd5c3da37c7dcc13c5cf", size = 45711 }, + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, ] [[package]] name = "redis" -version = "5.2.1" +version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355 } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502 }, + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, ] [[package]] name = "requests" -version = "2.32.3" +version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1426,202 +1435,207 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] name = "reuse" -version = "5.0.2" +version = "6.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "binaryornot" }, - { name = "boolean-py" }, { name = "click" }, { name = "jinja2" }, { name = "license-expression" }, { name = "python-debian" }, + { name = "python-magic" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/43/35421efe0e69823787b331362e11cc16bb697cd6f19cbed284d421615f14/reuse-5.0.2.tar.gz", hash = "sha256:878016ae5dd29c10bad4606d6676c12a268c12aa9fcfea66403598e16eed085c", size = 358798 } +sdist = { url = "https://files.pythonhosted.org/packages/05/35/298d9410b3635107ce586725cdfbca4c219c08d77a3511551f5e479a78db/reuse-6.2.0.tar.gz", hash = "sha256:4feae057a2334c9a513e6933cdb9be819d8b822f3b5b435a36138bd218897d23", size = 1615611, upload-time = "2025-10-27T15:25:46.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/2f/73de654df9e7e5f67d742c1d949b5c0c7c1203e84b2272d9e34a91faaf5c/reuse-5.0.2-cp313-cp313-manylinux_2_40_x86_64.whl", hash = "sha256:7a680f00324e87a72061677a892d8cbabfddf7adcf7a5376aeeed2d78995bbbb", size = 184309 }, + { url = "https://files.pythonhosted.org/packages/b3/f5/7ed954c7a56ef50dba65a422f6dba08f45f7de59c43a7e5ba1333ad81916/reuse-6.2.0-cp310-cp310-manylinux_2_41_x86_64.whl", hash = "sha256:12b68549bb9d5f4957f06d726a83a9780628810008fb732bb0d0f21607f8c6d6", size = 269991, upload-time = "2025-10-27T15:25:44.186Z" }, ] [[package]] name = "ruff" -version = "0.11.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/5b/3ae20f89777115944e89c2d8c2e795dcc5b9e04052f76d5347e35e0da66e/ruff-0.11.4.tar.gz", hash = "sha256:f45bd2fb1a56a5a85fae3b95add03fb185a0b30cf47f5edc92aa0355ca1d7407", size = 3933063 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/db/baee59ac88f57527fcbaad3a7b309994e42329c6bc4d4d2b681a3d7b5426/ruff-0.11.4-py3-none-linux_armv6l.whl", hash = "sha256:d9f4a761ecbde448a2d3e12fb398647c7f0bf526dbc354a643ec505965824ed2", size = 10106493 }, - { url = "https://files.pythonhosted.org/packages/c1/d6/9a0962cbb347f4ff98b33d699bf1193ff04ca93bed4b4222fd881b502154/ruff-0.11.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c1747d903447d45ca3d40c794d1a56458c51e5cc1bc77b7b64bd2cf0b1626cc", size = 10876382 }, - { url = "https://files.pythonhosted.org/packages/3a/8f/62bab0c7d7e1ae3707b69b157701b41c1ccab8f83e8501734d12ea8a839f/ruff-0.11.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:51a6494209cacca79e121e9b244dc30d3414dac8cc5afb93f852173a2ecfc906", size = 10237050 }, - { url = "https://files.pythonhosted.org/packages/09/96/e296965ae9705af19c265d4d441958ed65c0c58fc4ec340c27cc9d2a1f5b/ruff-0.11.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f171605f65f4fc49c87f41b456e882cd0c89e4ac9d58e149a2b07930e1d466f", size = 10424984 }, - { url = "https://files.pythonhosted.org/packages/e5/56/644595eb57d855afed6e54b852e2df8cd5ca94c78043b2f29bdfb29882d5/ruff-0.11.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebf99ea9af918878e6ce42098981fc8c1db3850fef2f1ada69fb1dcdb0f8e79e", size = 9957438 }, - { url = "https://files.pythonhosted.org/packages/86/83/9d3f3bed0118aef3e871ded9e5687fb8c5776bde233427fd9ce0a45db2d4/ruff-0.11.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edad2eac42279df12e176564a23fc6f4aaeeb09abba840627780b1bb11a9d223", size = 11547282 }, - { url = "https://files.pythonhosted.org/packages/40/e6/0c6e4f5ae72fac5ccb44d72c0111f294a5c2c8cc5024afcb38e6bda5f4b3/ruff-0.11.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f103a848be9ff379fc19b5d656c1f911d0a0b4e3e0424f9532ececf319a4296e", size = 12182020 }, - { url = "https://files.pythonhosted.org/packages/b5/92/4aed0e460aeb1df5ea0c2fbe8d04f9725cccdb25d8da09a0d3f5b8764bf8/ruff-0.11.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:193e6fac6eb60cc97b9f728e953c21cc38a20077ed64f912e9d62b97487f3f2d", size = 11679154 }, - { url = "https://files.pythonhosted.org/packages/1b/d3/7316aa2609f2c592038e2543483eafbc62a0e1a6a6965178e284808c095c/ruff-0.11.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7af4e5f69b7c138be8dcffa5b4a061bf6ba6a3301f632a6bce25d45daff9bc99", size = 13905985 }, - { url = "https://files.pythonhosted.org/packages/63/80/734d3d17546e47ff99871f44ea7540ad2bbd7a480ed197fe8a1c8a261075/ruff-0.11.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:126b1bf13154aa18ae2d6c3c5efe144ec14b97c60844cfa6eb960c2a05188222", size = 11348343 }, - { url = "https://files.pythonhosted.org/packages/04/7b/70fc7f09a0161dce9613a4671d198f609e653d6f4ff9eee14d64c4c240fb/ruff-0.11.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8806daaf9dfa881a0ed603f8a0e364e4f11b6ed461b56cae2b1c0cab0645304", size = 10308487 }, - { url = "https://files.pythonhosted.org/packages/1a/22/1cdd62dabd678d75842bf4944fd889cf794dc9e58c18cc547f9eb28f95ed/ruff-0.11.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5d94bb1cc2fc94a769b0eb975344f1b1f3d294da1da9ddbb5a77665feb3a3019", size = 9929091 }, - { url = "https://files.pythonhosted.org/packages/9f/20/40e0563506332313148e783bbc1e4276d657962cc370657b2fff20e6e058/ruff-0.11.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:995071203d0fe2183fc7a268766fd7603afb9996785f086b0d76edee8755c896", size = 10924659 }, - { url = "https://files.pythonhosted.org/packages/b5/41/eef9b7aac8819d9e942f617f9db296f13d2c4576806d604aba8db5a753f1/ruff-0.11.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a37ca937e307ea18156e775a6ac6e02f34b99e8c23fe63c1996185a4efe0751", size = 11428160 }, - { url = "https://files.pythonhosted.org/packages/ff/61/c488943414fb2b8754c02f3879de003e26efdd20f38167ded3fb3fc1cda3/ruff-0.11.4-py3-none-win32.whl", hash = "sha256:0e9365a7dff9b93af933dab8aebce53b72d8f815e131796268709890b4a83270", size = 10311496 }, - { url = "https://files.pythonhosted.org/packages/b6/2b/2a1c8deb5f5dfa3871eb7daa41492c4d2b2824a74d2b38e788617612a66d/ruff-0.11.4-py3-none-win_amd64.whl", hash = "sha256:5a9fa1c69c7815e39fcfb3646bbfd7f528fa8e2d4bebdcf4c2bd0fa037a255fb", size = 11399146 }, - { url = "https://files.pythonhosted.org/packages/4f/03/3aec4846226d54a37822e4c7ea39489e4abd6f88388fba74e3d4abe77300/ruff-0.11.4-py3-none-win_arm64.whl", hash = "sha256:d435db6b9b93d02934cf61ef332e66af82da6d8c69aefdea5994c89997c7a0fc", size = 10450306 }, +version = "0.14.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/5b/dd7406afa6c95e3d8fa9d652b6d6dd17dd4a6bf63cb477014e8ccd3dcd46/ruff-0.14.7.tar.gz", hash = "sha256:3417deb75d23bd14a722b57b0a1435561db65f0ad97435b4cf9f85ffcef34ae5", size = 5727324, upload-time = "2025-11-28T20:55:10.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/b1/7ea5647aaf90106f6d102230e5df874613da43d1089864da1553b899ba5e/ruff-0.14.7-py3-none-linux_armv6l.whl", hash = "sha256:b9d5cb5a176c7236892ad7224bc1e63902e4842c460a0b5210701b13e3de4fca", size = 13414475, upload-time = "2025-11-28T20:54:54.569Z" }, + { url = "https://files.pythonhosted.org/packages/af/19/fddb4cd532299db9cdaf0efdc20f5c573ce9952a11cb532d3b859d6d9871/ruff-0.14.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3f64fe375aefaf36ca7d7250292141e39b4cea8250427482ae779a2aa5d90015", size = 13634613, upload-time = "2025-11-28T20:55:17.54Z" }, + { url = "https://files.pythonhosted.org/packages/40/2b/469a66e821d4f3de0440676ed3e04b8e2a1dc7575cf6fa3ba6d55e3c8557/ruff-0.14.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93e83bd3a9e1a3bda64cb771c0d47cda0e0d148165013ae2d3554d718632d554", size = 12765458, upload-time = "2025-11-28T20:55:26.128Z" }, + { url = "https://files.pythonhosted.org/packages/f1/05/0b001f734fe550bcfde4ce845948ac620ff908ab7241a39a1b39bb3c5f49/ruff-0.14.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3838948e3facc59a6070795de2ae16e5786861850f78d5914a03f12659e88f94", size = 13236412, upload-time = "2025-11-28T20:55:28.602Z" }, + { url = "https://files.pythonhosted.org/packages/11/36/8ed15d243f011b4e5da75cd56d6131c6766f55334d14ba31cce5461f28aa/ruff-0.14.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24c8487194d38b6d71cd0fd17a5b6715cda29f59baca1defe1e3a03240f851d1", size = 13182949, upload-time = "2025-11-28T20:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cf/fcb0b5a195455729834f2a6eadfe2e4519d8ca08c74f6d2b564a4f18f553/ruff-0.14.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79c73db6833f058a4be8ffe4a0913b6d4ad41f6324745179bd2aa09275b01d0b", size = 13816470, upload-time = "2025-11-28T20:55:08.203Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5d/34a4748577ff7a5ed2f2471456740f02e86d1568a18c9faccfc73bd9ca3f/ruff-0.14.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:12eb7014fccff10fc62d15c79d8a6be4d0c2d60fe3f8e4d169a0d2def75f5dad", size = 15289621, upload-time = "2025-11-28T20:55:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/53/53/0a9385f047a858ba133d96f3f8e3c9c66a31cc7c4b445368ef88ebeac209/ruff-0.14.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c623bbdc902de7ff715a93fa3bb377a4e42dd696937bf95669118773dbf0c50", size = 14975817, upload-time = "2025-11-28T20:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d7/2f1c32af54c3b46e7fadbf8006d8b9bcfbea535c316b0bd8813d6fb25e5d/ruff-0.14.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f53accc02ed2d200fa621593cdb3c1ae06aa9b2c3cae70bc96f72f0000ae97a9", size = 14284549, upload-time = "2025-11-28T20:55:06.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/05/434ddd86becd64629c25fb6b4ce7637dd52a45cc4a4415a3008fe61c27b9/ruff-0.14.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:281f0e61a23fcdcffca210591f0f53aafaa15f9025b5b3f9706879aaa8683bc4", size = 14071389, upload-time = "2025-11-28T20:55:35.617Z" }, + { url = "https://files.pythonhosted.org/packages/ff/50/fdf89d4d80f7f9d4f420d26089a79b3bb1538fe44586b148451bc2ba8d9c/ruff-0.14.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dbbaa5e14148965b91cb090236931182ee522a5fac9bc5575bafc5c07b9f9682", size = 14202679, upload-time = "2025-11-28T20:55:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/77/54/87b34988984555425ce967f08a36df0ebd339bb5d9d0e92a47e41151eafc/ruff-0.14.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1464b6e54880c0fe2f2d6eaefb6db15373331414eddf89d6b903767ae2458143", size = 13147677, upload-time = "2025-11-28T20:55:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/67/29/f55e4d44edfe053918a16a3299e758e1c18eef216b7a7092550d7a9ec51c/ruff-0.14.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f217ed871e4621ea6128460df57b19ce0580606c23aeab50f5de425d05226784", size = 13151392, upload-time = "2025-11-28T20:55:21.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/47aae6dbd4f1d9b4f7085f4d9dcc84e04561ee7ad067bf52e0f9b02e3209/ruff-0.14.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6be02e849440ed3602d2eb478ff7ff07d53e3758f7948a2a598829660988619e", size = 13412230, upload-time = "2025-11-28T20:55:12.749Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4b/6e96cb6ba297f2ba502a231cd732ed7c3de98b1a896671b932a5eefa3804/ruff-0.14.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19a0f116ee5e2b468dfe80c41c84e2bbd6b74f7b719bee86c2ecde0a34563bcc", size = 14195397, upload-time = "2025-11-28T20:54:56.896Z" }, + { url = "https://files.pythonhosted.org/packages/69/82/251d5f1aa4dcad30aed491b4657cecd9fb4274214da6960ffec144c260f7/ruff-0.14.7-py3-none-win32.whl", hash = "sha256:e33052c9199b347c8937937163b9b149ef6ab2e4bb37b042e593da2e6f6cccfa", size = 13126751, upload-time = "2025-11-28T20:55:03.47Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b5/d0b7d145963136b564806f6584647af45ab98946660d399ec4da79cae036/ruff-0.14.7-py3-none-win_amd64.whl", hash = "sha256:e17a20ad0d3fad47a326d773a042b924d3ac31c6ca6deb6c72e9e6b5f661a7c6", size = 14531726, upload-time = "2025-11-28T20:54:59.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215, upload-time = "2025-11-28T20:55:15.375Z" }, +] + +[[package]] +name = "setuptools" +version = "80.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/c6/4af6938e3c7321dab335c384d2ce4f604adc81099f124257de71599e301b/setuptools-80.6.0.tar.gz", hash = "sha256:79cf4c44dfd0b5fb890be2dccc3fbd405253ce3baedd2700b54880a75219ea25", size = 1319406, upload-time = "2025-05-14T19:50:17.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/28/46ef5c181f4aa677d0d773da770a8c111e29f21ad67ed1d1b8e414d3fa0b/setuptools-80.6.0-py3-none-any.whl", hash = "sha256:3f6586e9196c76f59857319fdca6571efd156a4b57d6069fd774145c4b5655a2", size = 1201126, upload-time = "2025-05-14T19:50:15.138Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "sqlparse" -version = "0.5.3" +version = "0.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } +sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112, upload-time = "2025-11-28T07:10:18.377Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, + { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, ] [[package]] name = "tomli" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] [[package]] name = "tomlkit" -version = "0.13.2" +version = "0.13.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, ] [[package]] name = "typing-extensions" -version = "4.14.1" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] [[package]] name = "urllib3" -version = "2.3.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390 }, - { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389 }, - { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020 }, - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, - { url = "https://files.pythonhosted.org/packages/05/52/7223011bb760fce8ddc53416beb65b83a3ea6d7d13738dde75eeb2c89679/watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8", size = 96390 }, - { url = "https://files.pythonhosted.org/packages/9c/62/d2b21bc4e706d3a9d467561f487c2938cbd881c69f3808c43ac1ec242391/watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a", size = 88386 }, - { url = "https://files.pythonhosted.org/packages/ea/22/1c90b20eda9f4132e4603a26296108728a8bfe9584b006bd05dd94548853/watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", size = 89017 }, - { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902 }, - { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380 }, - { url = "https://files.pythonhosted.org/packages/5b/79/69f2b0e8d3f2afd462029031baafb1b75d11bb62703f0e1022b2e54d49ee/watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa", size = 87903 }, - { url = "https://files.pythonhosted.org/packages/e2/2b/dc048dd71c2e5f0f7ebc04dd7912981ec45793a03c0dc462438e0591ba5d/watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e", size = 88381 }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] [[package]] name = "wcmatch" -version = "10.0" +version = "10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bracex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/ab/b3a52228538ccb983653c446c1656eddf1d5303b9cb8b9aef6a91299f862/wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a", size = 115578 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/df/4ee467ab39cc1de4b852c212c1ed3becfec2e486a51ac1ce0091f85f38d7/wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a", size = 39347 }, -] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, ] From 81a1cd3bb1d6473c96f18a39f03f90e1c53f5282 Mon Sep 17 00:00:00 2001 From: Matthew Kusz Date: Tue, 2 Dec 2025 09:28:00 -0500 Subject: [PATCH 088/110] Add slurm help text on the home page and allocation page Signed-off-by: Matthew Kusz --- MANIFEST.in | 1 + coldfront/config/plugins/slurm.py | 11 ++ coldfront/config/urls.py | 3 + .../allocation/allocation_detail.html | 4 + coldfront/core/allocation/views.py | 1 + .../templates/portal/extra_app_templates.html | 4 + .../templates/slurm/full_slurm_help.html | 59 ++++++++ .../templates/slurm/full_slurm_help_div.html | 17 +++ .../slurm/templates/slurm/slurm_help.html | 42 ++++++ .../slurm/templates/slurm/slurm_help_div.html | 17 +++ coldfront/plugins/slurm/urls.py | 12 ++ coldfront/plugins/slurm/views.py | 134 ++++++++++++++++++ 12 files changed, 305 insertions(+) create mode 100644 coldfront/plugins/slurm/templates/slurm/full_slurm_help.html create mode 100644 coldfront/plugins/slurm/templates/slurm/full_slurm_help_div.html create mode 100644 coldfront/plugins/slurm/templates/slurm/slurm_help.html create mode 100644 coldfront/plugins/slurm/templates/slurm/slurm_help_div.html create mode 100644 coldfront/plugins/slurm/urls.py create mode 100644 coldfront/plugins/slurm/views.py diff --git a/MANIFEST.in b/MANIFEST.in index e5bdd3ff21..4894e3e1b3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,3 +11,4 @@ recursive-include coldfront/core/publication/templates * recursive-include coldfront/core/grant/templates * recursive-include coldfront/core/user/templates * recursive-include coldfront/core/resource/templates * +recursive-include coldfront/plugins/slurm/templates * diff --git a/coldfront/config/plugins/slurm.py b/coldfront/config/plugins/slurm.py index e1843c708f..158384bfe9 100644 --- a/coldfront/config/plugins/slurm.py +++ b/coldfront/config/plugins/slurm.py @@ -13,3 +13,14 @@ SLURM_NOOP = ENV.bool("SLURM_NOOP", False) SLURM_IGNORE_USERS = ENV.list("SLURM_IGNORE_USERS", default=["root"]) SLURM_IGNORE_ACCOUNTS = ENV.list("SLURM_IGNORE_ACCOUNTS", default=[]) +SLURM_SUBMISSION_INFO = ENV.list("SLURM_SUBMISSION_INFO", default=["account"]) +SLURM_DISPLAY_SHORT_OPTION_NAMES = ENV.bool("SLURM_DISPLAY_SHORT_OPTION_NAMES", default=False) +SLURM_SHORT_OPTION_NAMES = ENV.dict( + "SLURM_SHORT_OPTION_NAMES", + default={ + "qos": "q", + "account": "A", + "clusters": "M", + "partition": "p", + }, +) diff --git a/coldfront/config/urls.py b/coldfront/config/urls.py index 86bcb0bd95..427b975b6f 100644 --- a/coldfront/config/urls.py +++ b/coldfront/config/urls.py @@ -52,6 +52,9 @@ if "mozilla_django_oidc" in settings.INSTALLED_APPS: urlpatterns.append(path("oidc/", include("mozilla_django_oidc.urls"))) +if "coldfront.plugins.slurm" in settings.INSTALLED_APPS: + urlpatterns.append(path("slurm/", include("coldfront.plugins.slurm.urls"))) + if "django_su.backends.SuBackend" in settings.AUTHENTICATION_BACKENDS: urlpatterns.append(path("su/", include("django_su.urls"))) diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index 8f76a1e7f5..13829073d8 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -274,6 +274,10 @@

{{attribute}}

{% endif %} +{% if display_slurm_help %} + {% include "slurm/slurm_help_div.html" %} +{% endif %} + {% if not user_is_pending %}
diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index ef38fa92ec..7837cadd8e 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -175,6 +175,7 @@ def get_context_data(self, **kwargs): context["attributes_with_usage"] = attributes_with_usage context["attributes"] = attributes context["allocation_changes"] = allocation_changes + context["display_slurm_help"] = "coldfront.plugins.slurm" in settings.INSTALLED_APPS # Can the user update the project? context["is_allowed_to_update_project"] = allocation_obj.project.has_perm( diff --git a/coldfront/core/portal/templates/portal/extra_app_templates.html b/coldfront/core/portal/templates/portal/extra_app_templates.html index 8a46581eab..9d6d88a9db 100644 --- a/coldfront/core/portal/templates/portal/extra_app_templates.html +++ b/coldfront/core/portal/templates/portal/extra_app_templates.html @@ -5,3 +5,7 @@ {% if 'coldfront.plugins.system_monitor' in EXTRA_APPS %} {% include "system_monitor/system_monitor_div.html" %} {% endif %} + +{% if 'coldfront.plugins.slurm' in EXTRA_APPS and user.is_authenticated %} + {% include "slurm/full_slurm_help_div.html" %} +{% endif %} diff --git a/coldfront/plugins/slurm/templates/slurm/full_slurm_help.html b/coldfront/plugins/slurm/templates/slurm/full_slurm_help.html new file mode 100644 index 0000000000..e5dbf7511c --- /dev/null +++ b/coldfront/plugins/slurm/templates/slurm/full_slurm_help.html @@ -0,0 +1,59 @@ +{% if slurm_info %} +
+
+
+ Submitting Slurm Jobs +
+
+ {% for project_pk, info in slurm_info.items %} +
+ +
+

Interactive Jobs

+
+
{{ message.message }}
+ {% for resources_name, submit_info in info.submit_info.items %} + {% if submit_info %} + + + + + {% endif %} + {% endfor %} +
{{ resources_name }} + srun + {% for option, value in submit_info.items %} + {{ option }} {{ value }} + {% endfor %} + <other options> +
+
+

Batch Jobs

+
+ {% for resources_name, submit_info in info.submit_info.items %} + {% if submit_info %} +
+
+ {{ resources_name }} +
+
    + {% for option, value in submit_info.items %} +
  • + #SBATCH {{ option }} {{ value }} +
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endfor %} +
+
+
+ {% endfor %} +
+
+
+{% endif %} diff --git a/coldfront/plugins/slurm/templates/slurm/full_slurm_help_div.html b/coldfront/plugins/slurm/templates/slurm/full_slurm_help_div.html new file mode 100644 index 0000000000..93f0b58699 --- /dev/null +++ b/coldfront/plugins/slurm/templates/slurm/full_slurm_help_div.html @@ -0,0 +1,17 @@ +
+ + diff --git a/coldfront/plugins/slurm/templates/slurm/slurm_help.html b/coldfront/plugins/slurm/templates/slurm/slurm_help.html new file mode 100644 index 0000000000..f866ce2749 --- /dev/null +++ b/coldfront/plugins/slurm/templates/slurm/slurm_help.html @@ -0,0 +1,42 @@ +{% if slurm_info %} +
+
+

Submitting Slurm Jobs

+
+
+

Interactive Jobs

+
+
    + {% for resources_name, submit_info in slurm_info.items %} + {% if submit_info %} +
  • + srun + {% for slurm_submit_option, submit_info_value in submit_info.items %} + {{ slurm_submit_option }} {{ submit_info_value }} + {% endfor %} + <other options> +
  • + {% endif %} + {% endfor %} +
+
+

Batch Jobs

+
+
+ {% for resources_name, submit_info in slurm_info.items %} + {% if submit_info %} +
+
    + {% for slurm_submit_option, submit_info_value in submit_info.items %} +
  • + #SBATCH {{ slurm_submit_option }} {{ submit_info_value }} +
  • + {% endfor %} +
+
+ {% endif %} + {% endfor %} +
+
+
+{% endif %} diff --git a/coldfront/plugins/slurm/templates/slurm/slurm_help_div.html b/coldfront/plugins/slurm/templates/slurm/slurm_help_div.html new file mode 100644 index 0000000000..18b986bbcd --- /dev/null +++ b/coldfront/plugins/slurm/templates/slurm/slurm_help_div.html @@ -0,0 +1,17 @@ +
+ + diff --git a/coldfront/plugins/slurm/urls.py b/coldfront/plugins/slurm/urls.py new file mode 100644 index 0000000000..3cecf11ff9 --- /dev/null +++ b/coldfront/plugins/slurm/urls.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.urls import path + +from coldfront.plugins.slurm.views import get_full_slurm_help, get_slurm_help + +urlpatterns = [ + path("full-slurm-help/", get_full_slurm_help, name="full-slurm-help"), + path("slurm-help/", get_slurm_help, name="slurm-help"), +] diff --git a/coldfront/plugins/slurm/views.py b/coldfront/plugins/slurm/views.py new file mode 100644 index 0000000000..91d5b6a344 --- /dev/null +++ b/coldfront/plugins/slurm/views.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: (C) ColdFront Authors +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from django.contrib.auth.decorators import login_required +from django.db.models import Prefetch +from django.shortcuts import render + +from coldfront.core.allocation.models import Allocation, AllocationAttribute +from coldfront.core.utils.common import import_from_settings + +SLURM_SUBMISSION_INFO = import_from_settings("SLURM_SUBMISSION_INFO", ["account"]) +SLURM_DISPLAY_SHORT_OPTION_NAMES = import_from_settings("SLURM_DISPLAY_SHORT_OPTION_NAMES", False) +SLURM_SHORT_OPTION_NAMES = import_from_settings("SLURM_SHORT_OPTION_NAMES", {}) + + +@login_required +def get_full_slurm_help(request): + allocation_objs = ( + Allocation.objects.filter( + status__name__in=[ + "Active", + "Renewal Requested", + ], + allocationuser__user=request.user, + allocationuser__status__name="Active", + project__status__name="Active", + allocationattribute__allocation_attribute_type__name__in=["slurm_account_name", "slurm_specs"], + ) + .select_related("project") + .prefetch_related( + Prefetch( + lookup="allocationattribute_set", + queryset=AllocationAttribute.objects.filter(allocation_attribute_type__name="slurm_account_name"), + to_attr="slurm_account_name", + ), + Prefetch( + lookup="allocationattribute_set", + queryset=AllocationAttribute.objects.filter(allocation_attribute_type__name="slurm_specs"), + to_attr="slurm_specs", + ), + ) + ) + + slurm_info = {} + for allocation_obj in allocation_objs: + if not slurm_info.get(allocation_obj.project_id): + slurm_info[allocation_obj.project_id] = {"project_title": allocation_obj.project.title, "submit_info": {}} + slurm_info[allocation_obj.project_id]["submit_info"].update(get_slurm_info_from_allocation(allocation_obj)) + + return render(request, "slurm/full_slurm_help.html", {"slurm_info": slurm_info}) + + +@login_required +def get_slurm_help(request): + allocation_obj = ( + Allocation.objects.filter(pk=request.POST.get("allocation_pk")) + .prefetch_related( + Prefetch( + lookup="allocationattribute_set", + queryset=AllocationAttribute.objects.filter(allocation_attribute_type__name="slurm_account_name"), + to_attr="slurm_account_name", + ), + Prefetch( + lookup="allocationattribute_set", + queryset=AllocationAttribute.objects.filter(allocation_attribute_type__name="slurm_specs"), + to_attr="slurm_specs", + ), + ) + .first() + ) + slurm_info = get_slurm_info_from_allocation(allocation_obj) + return render(request, "slurm/slurm_help.html", {"slurm_info": slurm_info}) + + +def get_slurm_info_from_allocation(allocation_obj): + submit_options = {} + resource_obj = allocation_obj.get_parent_resource + resource_type = resource_obj.resource_type.name + if resource_type == "Cluster Partition": + cluster_obj = resource_obj.parent_resource + if "clusters" in SLURM_SUBMISSION_INFO: + submit_options["clusters"] = cluster_obj.resourceattribute_set.get( + resource_attribute_type__name="slurm_cluster" + ).value + if "partition" in SLURM_SUBMISSION_INFO: + submit_options["partition"] = resource_obj.name.lower() + elif resource_type != "Cluster": + return {} + + slurm_account = allocation_obj.slurm_account_name + if slurm_account: + if "account" in SLURM_SUBMISSION_INFO: + submit_options["account"] = slurm_account[0].value + + slurm_specs = resource_obj.resourceattribute_set.filter(resource_attribute_type__name="slurm_specs") + submit_options = get_slurm_info_from_slurm_specs(slurm_specs, submit_options) + slurm_specs = allocation_obj.slurm_specs + submit_options = get_slurm_info_from_slurm_specs(slurm_specs, submit_options) + + if SLURM_DISPLAY_SHORT_OPTION_NAMES: + submit_short_options = {} + for option, value in submit_options.items(): + short_option = SLURM_SHORT_OPTION_NAMES.get(option) + if short_option: + submit_short_options["-" + short_option] = value + else: + submit_short_options["--" + option] = value + + slurm_info = submit_short_options + else: + submit_long_options = {} + for option, value in submit_options.items(): + submit_long_options["--" + option] = value + + slurm_info = submit_long_options + + return {resource_obj.name: slurm_info} + + +def get_slurm_info_from_slurm_specs(slurm_specs, submit_options): + if slurm_specs: + specs = slurm_specs[0].value.replace("+", "").split(":") + for spec in specs: + spec_split = spec.split("=") + # Expanded attributes should be skipped + if len(spec_split) > 2: + continue + option, value = spec_split + option = option.lower() + if option in SLURM_SUBMISSION_INFO: + submit_options[option] = value + + return submit_options From 5da5387c658d178136713c1355d1e6e0edf19de9 Mon Sep 17 00:00:00 2001 From: Cecilia Lau Date: Tue, 2 Dec 2025 11:07:37 -0500 Subject: [PATCH 089/110] Use ModelForm for AllocationForm and CreateView for AllocationCreateView Squahsed commits: - Use ModelForm for AllocationForm and CreateView for AllocationCreateView - Add 'allocation_account' to the fields list for AllocationForm - Allow initial values to be overriden Signed-off-by: Cecilia Lau --- coldfront/core/allocation/forms.py | 128 +++++++++++++----- coldfront/core/allocation/models.py | 3 +- coldfront/core/allocation/tests/test_views.py | 14 +- coldfront/core/allocation/views.py | 123 ++++------------- coldfront/core/user/forms.py | 8 ++ 5 files changed, 147 insertions(+), 129 deletions(-) diff --git a/coldfront/core/allocation/forms.py b/coldfront/core/allocation/forms.py index d24018efdc..a5ff16947e 100644 --- a/coldfront/core/allocation/forms.py +++ b/coldfront/core/allocation/forms.py @@ -3,10 +3,12 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from django import forms +from django.contrib.auth import get_user_model from django.db.models.functions import Lower -from django.shortcuts import get_object_or_404 +from django.forms import ValidationError from coldfront.core.allocation.models import ( + Allocation, AllocationAccount, AllocationAttribute, AllocationAttributeType, @@ -15,57 +17,117 @@ from coldfront.core.allocation.utils import get_user_resources from coldfront.core.project.models import Project from coldfront.core.resource.models import Resource, ResourceType +from coldfront.core.user.forms import UserModelMultipleChoiceField from coldfront.core.utils.common import import_from_settings ALLOCATION_ACCOUNT_ENABLED = import_from_settings("ALLOCATION_ACCOUNT_ENABLED", False) ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS = import_from_settings("ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS", []) +ALLOCATION_ACCOUNT_MAPPING = import_from_settings("ALLOCATION_ACCOUNT_MAPPING", {}) +ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT = import_from_settings( + "ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT", True +) +INVOICE_ENABLED = import_from_settings("INVOICE_ENABLED", False) +if INVOICE_ENABLED: + INVOICE_DEFAULT_STATUS = import_from_settings("INVOICE_DEFAULT_STATUS", "Pending Payment") + +class AllocationForm(forms.ModelForm): + class Meta: + model = Allocation + fields = [ + "resource", + "justification", + "quantity", + "users", + "project", + "is_changeable", + "allocation_account", + ] + help_texts = { + "justification": "
Justification for requesting this allocation.", + "users": "
Select users in your project to add to this allocation.", + } + widgets = { + "status": forms.HiddenInput(), + "project": forms.HiddenInput(), + "is_changeable": forms.HiddenInput(), + } -class AllocationForm(forms.Form): resource = forms.ModelChoiceField(queryset=None, empty_label=None) - justification = forms.CharField(widget=forms.Textarea) - quantity = forms.IntegerField(required=True) - users = forms.MultipleChoiceField(widget=forms.CheckboxSelectMultiple, required=False) - allocation_account = forms.ChoiceField(required=False) + users = UserModelMultipleChoiceField(queryset=None, required=False) + allocation_account = forms.ModelChoiceField(queryset=None, required=False) def __init__(self, request_user, project_pk, *args, **kwargs): + project_obj = Project.objects.get(pk=project_pk) + # Set default initial values + initial = { + "quantity": 1, + "is_changeable": ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT, + "project": project_obj, + } + if kwargs["initial"] is not None: + initial.update(kwargs["initial"]) + kwargs["initial"] = initial super().__init__(*args, **kwargs) - project_obj = get_object_or_404(Project, pk=project_pk) + self.fields["resource"].queryset = get_user_resources(request_user).order_by(Lower("name")) - self.fields["quantity"].initial = 1 - user_query_set = ( - project_obj.projectuser_set.select_related("user") - .filter( - status__name__in=[ - "Active", - ] - ) - .order_by("user__username") + self.fields["users"].queryset = ( + get_user_model() + .objects.filter(projectuser__project=project_obj, projectuser__status__name="Active") + .order_by("username") + .exclude(pk=project_obj.pi.pk) ) - user_query_set = user_query_set.exclude(user=project_obj.pi) - if user_query_set: - self.fields["users"].choices = ( - (user.user.username, "%s %s (%s)" % (user.user.first_name, user.user.last_name, user.user.username)) - for user in user_query_set - ) - self.fields["users"].help_text = "
Select users in your project to add to this allocation." - else: + if not self.fields["users"].queryset: self.fields["users"].widget = forms.HiddenInput() + # Set allocation_account choices if ALLOCATION_ACCOUNT_ENABLED: - allocation_accounts = AllocationAccount.objects.filter(user=request_user) - if allocation_accounts: - self.fields["allocation_account"].choices = ( - ((account.name, account.name)) for account in allocation_accounts + self.fields["allocation_account"].queryset = AllocationAccount.objects.filter(user=request_user) + if not self.fields["allocation_account"].queryset: + self.fields["allocation_account"].widget = forms.HiddenInput() + else: + self.fields["allocation_account"].widget = forms.HiddenInput() + + def clean(self): + form_data = super().clean() + project_obj = form_data.get("project") + resource_obj = form_data.get("resource") + allocation_account = form_data.get("allocation_account", None) + + # Ensure user has account name if ALLOCATION_ACCOUNT_ENABLED + if ( + ALLOCATION_ACCOUNT_ENABLED + and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING + and AllocationAttributeType.objects.filter(name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name]).exists() + and not allocation_account + ): + raise ValidationError( + 'You need to create an account name. Create it by clicking the link under the "Allocation account" field.', + code="user_has_no_account_name", + ) + + # Ensure this allocaiton wouldn't exceed the limit + allocation_limit = resource_obj.get_attribute("allocation_limit", typed=True) + if allocation_limit: + allocation_count = project_obj.allocation_set.filter( + resources=resource_obj, + status__name__in=["Active", "New", "Renewal Requested", "Paid", "Payment Pending", "Payment Requested"], + ).count() + if allocation_count >= allocation_limit: + raise ValidationError( + "Your project is at the allocation limit allowed for this resource.", + code="reached_allocation_limit", ) - self.fields[ - "allocation_account" - ].help_text = '
Select account name to associate with resource. Click here to create an account name!' + # Set allocation status + if INVOICE_ENABLED and resource_obj.requires_payment: + allocation_status_name = INVOICE_DEFAULT_STATUS else: - self.fields["allocation_account"].widget = forms.HiddenInput() + allocation_status_name = "New" + form_data["status"] = AllocationStatusChoice.objects.get(name=allocation_status_name) + self.instance.status = form_data["status"] - self.fields["justification"].help_text = "
Justification for requesting this allocation." + return form_data class AllocationUpdateForm(forms.Form): diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index 6cc15b3daf..deb91f9dc3 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -408,7 +408,8 @@ def remove_user(self, user, signal_sender=None, ignore_user_not_found=True): Params: user (User|AllocationUser): User to remove. signal_sender (str): Sender for the `allocation_remove_user` signal. - ignore_user_not_found (bool): + ignore_user_not_found (bool): If enabled, logs a warning that the allocation user for + the provded user couldn't be found and returns. Otherwise, raises `AllocationUser.DoesNotExist`. """ if isinstance(user, AllocationUser): allocation_user = user diff --git a/coldfront/core/allocation/tests/test_views.py b/coldfront/core/allocation/tests/test_views.py index d09a5606ba..378ee7ce98 100644 --- a/coldfront/core/allocation/tests/test_views.py +++ b/coldfront/core/allocation/tests/test_views.py @@ -409,8 +409,12 @@ def setUp(self): self.client.force_login(self.pi_user) self.post_data = { "justification": "test justification", - "quantity": "1", - "resource": f"{self.allocation.resources.first().pk}", + "quantity": 10, + "resource": self.allocation.resources.first().pk, + "project": self.project.pk, + "is_changeable": True, + "users": [self.proj_nonallocation_user.pk], + "allocation_account": [], } def test_allocationcreateview_access(self): @@ -426,6 +430,9 @@ def test_allocationcreateview_post(self): utils.assert_response_success(self, response) self.assertContains(response, "Allocation requested.") self.assertEqual(len(self.project.allocation_set.all()), 2) + new_allocation = self.project.allocation_set.last() + self.assertEqual(len(new_allocation.resources.all()), 1) + self.assertEqual(len(new_allocation.allocationuser_set.all()), 1) def test_allocationcreateview_post_zeroquantity(self): """Test POST to the AllocationCreateView""" @@ -435,6 +442,9 @@ def test_allocationcreateview_post_zeroquantity(self): utils.assert_response_success(self, response) self.assertContains(response, "Allocation requested.") self.assertEqual(len(self.project.allocation_set.all()), 2) + new_allocation = self.project.allocation_set.last() + self.assertEqual(len(new_allocation.resources.all()), 1) + self.assertEqual(len(new_allocation.allocationuser_set.all()), 1) class AllocationAddUsersViewTest(AllocationViewBaseTest): diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index d98f80893d..3322e0d8e2 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -19,7 +19,6 @@ from django.http import HttpResponseBadRequest, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy -from django.utils.html import format_html from django.views import View from django.views.generic import ListView, TemplateView from django.views.generic.edit import CreateView, FormView, UpdateView @@ -596,7 +595,7 @@ def get_context_data(self, **kwargs): return context -class AllocationCreateView(LoginRequiredMixin, UserPassesTestMixin, FormView): +class AllocationCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): form_class = AllocationForm template_name = "allocation/allocation_create.html" @@ -610,27 +609,23 @@ def test_func(self): return False def dispatch(self, request, *args, **kwargs): - project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) + self.project = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) - if project_obj.needs_review: + if self.project.needs_review: messages.error( request, "You cannot request a new allocation because you have to review your project first." ) - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(self.project) - if project_obj.status.name not in [ - "Active", - "New", - ]: + if self.project.status.name not in ["Active", "New"]: messages.error(request, "You cannot request a new allocation to an archived project.") - return HttpResponseRedirect(reverse("project-detail", kwargs={"pk": project_obj.pk})) + return redirect(self.project) return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) - context["project"] = project_obj + context["project"] = self.project user_resources = get_user_resources(self.request.user) resources_form_default_quantities = {} @@ -662,109 +657,51 @@ def get_context_data(self, **kwargs): return context - def get_form(self, form_class=None): - """Return an instance of the form to be used in this view.""" - if form_class is None: - form_class = self.get_form_class() - return form_class(self.request.user, self.kwargs.get("project_pk"), **self.get_form_kwargs()) + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["request_user"] = self.request.user + kwargs["project_pk"] = self.project.pk + return kwargs def form_valid(self, form): + redirect = super().form_valid(form) form_data = form.cleaned_data - project_obj = get_object_or_404(Project, pk=self.kwargs.get("project_pk")) resource_obj = form_data.get("resource") - justification = form_data.get("justification") - quantity = form_data.get("quantity", 1) allocation_account = form_data.get("allocation_account", None) - # A resource is selected that requires an account name selection but user has no account names - if ( - ALLOCATION_ACCOUNT_ENABLED - and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING - and AllocationAttributeType.objects.filter(name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name]).exists() - and not allocation_account - ): - form.add_error( - None, - format_html( - 'You need to create an account name. Create it by clicking the link under the "Allocation account" field.' - ), - ) - return self.form_invalid(form) - - allocation_limit_objs = resource_obj.resourceattribute_set.filter( - resource_attribute_type__name="allocation_limit" - ).first() - if allocation_limit_objs: - allocation_limit = int(allocation_limit_objs.value) - allocation_count = project_obj.allocation_set.filter( - resources=resource_obj, - status__name__in=["Active", "New", "Renewal Requested", "Paid", "Payment Pending", "Payment Requested"], - ).count() - if allocation_count >= allocation_limit: - form.add_error(None, format_html("Your project is at the allocation limit allowed for this resource.")) - return self.form_invalid(form) - - usernames = form_data.get("users") - usernames.append(project_obj.pi.username) - usernames = list(set(usernames)) - - users = [get_user_model().objects.get(username=username) for username in usernames] - if project_obj.pi not in users: - users.append(project_obj.pi) - - if INVOICE_ENABLED and resource_obj.requires_payment: - allocation_status_obj = AllocationStatusChoice.objects.get(name=INVOICE_DEFAULT_STATUS) - else: - allocation_status_obj = AllocationStatusChoice.objects.get(name="New") - - allocation_obj = Allocation.objects.create( - project=project_obj, justification=justification, quantity=quantity, status=allocation_status_obj - ) - if ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT: - allocation_obj.is_changeable = True - allocation_obj.save() + # add users to allocation + self.object.add_user(self.project.pi, signal_sender=self.__class__) + users = form_data.get("users") + for user in users: + self.object.add_user(user, signal_sender=self.__class__) - allocation_obj.resources.add(resource_obj) + # add resources to allocation + self.object.resources.add(resource_obj) + for linked_resource in resource_obj.linked_resources.all(): + self.object.resources.add(linked_resource) + # add allocation account attribute to allocation if ALLOCATION_ACCOUNT_ENABLED and allocation_account and resource_obj.name in ALLOCATION_ACCOUNT_MAPPING: allocation_attribute_type_obj = AllocationAttributeType.objects.get( name=ALLOCATION_ACCOUNT_MAPPING[resource_obj.name] ) - AllocationAttribute.objects.create( + self.object.allocationattribute_set.create( allocation_attribute_type=allocation_attribute_type_obj, - allocation=allocation_obj, - value=allocation_account, + value=allocation_account.name, ) - for linked_resource in resource_obj.linked_resources.all(): - allocation_obj.resources.add(linked_resource) - - allocation_user_active_status = AllocationUserStatusChoice.objects.get(name="Active") - if ALLOCATION_EULA_ENABLE: - allocation_user_pending_status = AllocationUserStatusChoice.objects.get(name="PendingEULA") - for user in users: - if ALLOCATION_EULA_ENABLE and not (user == self.request.user): - AllocationUser.objects.create( - allocation=allocation_obj, user=user, status=allocation_user_pending_status - ) - else: - AllocationUser.objects.create( - allocation=allocation_obj, user=user, status=allocation_user_active_status - ) - send_allocation_admin_email( - allocation_obj, + self.object, "New Allocation Request", "email/new_allocation_request.txt", domain_url=get_domain_url(self.request), ) - allocation_new.send(sender=self.__class__, allocation_pk=allocation_obj.pk) - return super().form_valid(form) + allocation_new.send(sender=self.__class__, allocation_pk=self.object.pk) + return redirect def get_success_url(self): - msg = "Allocation requested. It will be available once it is approved." - messages.success(self.request, msg) - return reverse("project-detail", kwargs={"pk": self.kwargs.get("project_pk")}) + messages.success(self.request, "Allocation requested. It will be available once it is approved.") + return self.project.get_absolute_url() class AllocationAddUsersView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): diff --git a/coldfront/core/user/forms.py b/coldfront/core/user/forms.py index 3198031bb4..c5cbb3b0a9 100644 --- a/coldfront/core/user/forms.py +++ b/coldfront/core/user/forms.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from django import forms +from django.forms.widgets import CheckboxSelectMultiple from django.utils.html import mark_safe @@ -24,3 +25,10 @@ class UserSearchForm(forms.Form): help_text="Copy paste usernames separated by space or newline for multiple username searches!", ) search_by = forms.ChoiceField(choices=CHOICES, widget=forms.RadioSelect(), initial="username_only") + + +class UserModelMultipleChoiceField(forms.ModelMultipleChoiceField): + widget = CheckboxSelectMultiple + + def label_from_instance(self, obj): + return f"{obj.first_name} {obj.last_name} ({obj.username})" From d450fa77e725b71d0f45a97cf53507f946a7eff8 Mon Sep 17 00:00:00 2001 From: Matthew Kusz Date: Wed, 3 Dec 2025 10:02:54 -0500 Subject: [PATCH 090/110] Fix incorrect values being saved to edited allocation attributes Signed-off-by: Matthew Kusz --- coldfront/core/allocation/views.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index d98f80893d..d863364aee 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -2144,21 +2144,17 @@ def post(self, request, *args, **kwargs): error_redirect = HttpResponseRedirect(reverse("allocation-attribute-edit", kwargs={"pk": pk})) return error_redirect - attribute_changes_to_make = set() attribute_changes_to_make_pks = dict() for entry in formset: formset_data = entry.cleaned_data value = formset_data.get("value") + orig_value = formset_data.get("orig_value") - if value != "": + if not value == "" and not value == orig_value: attribute_changes_to_make_pks[formset_data.get("attribute_pk")] = value - for allocation_attribute in AllocationAttribute.objects.filter(pk__in=attribute_changes_to_make_pks): - if allocation_attribute.value != attribute_changes_to_make_pks.get("value"): - attribute_changes_to_make.add((allocation_attribute, value)) - - for allocation_attribute, value in attribute_changes_to_make: - allocation_attribute.value = value + for allocation_attribute in AllocationAttribute.objects.filter(pk__in=attribute_changes_to_make_pks.keys()): + allocation_attribute.value = attribute_changes_to_make_pks.get(allocation_attribute.pk) allocation_attribute.save() allocation_attribute_changed.send( sender=self.__class__, From bf7d54e3e1f8ab1902cfb13eafde3e7717e6ac9b Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Thu, 4 Dec 2025 08:43:23 -0500 Subject: [PATCH 091/110] bump django version --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 5b2e1e08b6..00e7521f7e 100644 --- a/uv.lock +++ b/uv.lock @@ -443,16 +443,16 @@ wheels = [ [[package]] name = "django" -version = "4.2.26" +version = "4.2.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/f2/5cab08b4174b46cd9d5b4d4439d211f5dd15bec256fb43e8287adbb79580/django-4.2.26.tar.gz", hash = "sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a", size = 10433052, upload-time = "2025-11-05T14:08:23.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/ff/6aa5a94b85837af893ca82227301ac6ddf4798afda86151fb2066d26ca0a/django-4.2.27.tar.gz", hash = "sha256:b865fbe0f4a3d1ee36594c5efa42b20db3c8bbb10dff0736face1c6e4bda5b92", size = 10432781, upload-time = "2025-12-02T14:01:49.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/6f/873365d280002de462852c20bcf050564e2354770041bd950bfb4a16d91e/django-4.2.26-py3-none-any.whl", hash = "sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280", size = 7994264, upload-time = "2025-11-05T14:08:20.328Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/1a2319cc090870bfe8c62ef5ad881a6b73b5f4ce7330c5cf2cb4f9536b12/django-4.2.27-py3-none-any.whl", hash = "sha256:f393a394053713e7d213984555c5b7d3caeee78b2ccb729888a0774dff6c11a8", size = 7995090, upload-time = "2025-12-02T14:01:44.234Z" }, ] [[package]] From de92f18b2260ff8dfc54e0c92780dea240f765e1 Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Mon, 1 Dec 2025 08:35:01 -0500 Subject: [PATCH 092/110] add migration check to CI Signed-off-by: Simon Leary --check --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63a1cd1842..fef6e7a76e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,6 @@ jobs: - name: Run tests run: uv run coldfront test + + - name: Check for migrations + run: uv run coldfront makemigrations --check From eaf47e6406c0a4d2d6f95bb8bdaad32bc281a218 Mon Sep 17 00:00:00 2001 From: Cecilia Lau Date: Fri, 5 Dec 2025 12:57:13 -0500 Subject: [PATCH 093/110] Fix invalid operand types Signed-off-by: Cecilia Lau --- coldfront/core/portal/views.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py index 3fbfc97242..461a900f9f 100644 --- a/coldfront/core/portal/views.py +++ b/coldfront/core/portal/views.py @@ -166,15 +166,17 @@ def center_summary(request): total_grants_by_agency = sorted(total_grants_by_agency, key=operator.itemgetter(1), reverse=True) grants_agency_chart_data = generate_total_grants_by_agency_chart_data(total_grants_by_agency) context["grants_agency_chart_data"] = grants_agency_chart_data - context["grants_total"] = intcomma(int(sum(list(Grant.objects.values_list("total_amount_awarded", flat=True))))) + context["grants_total"] = intcomma( + int(sum(map(float, Grant.objects.values_list("total_amount_awarded", flat=True)))) + ) context["grants_total_pi_only"] = intcomma( - int(sum(list(Grant.objects.filter(role="PI").values_list("total_amount_awarded", flat=True)))) + int(sum(map(float, Grant.objects.filter(role="PI").values_list("total_amount_awarded", flat=True)))) ) context["grants_total_copi_only"] = intcomma( - int(sum(list(Grant.objects.filter(role="CoPI").values_list("total_amount_awarded", flat=True)))) + int(sum(map(float, Grant.objects.filter(role="CoPI").values_list("total_amount_awarded", flat=True)))) ) context["grants_total_sp_only"] = intcomma( - int(sum(list(Grant.objects.filter(role="SP").values_list("total_amount_awarded", flat=True)))) + int(sum(map(float, Grant.objects.filter(role="SP").values_list("total_amount_awarded", flat=True)))) ) return render(request, "portal/center_summary.html", context) From ee48772f1f6a9d75d90f257096ccc323375096e1 Mon Sep 17 00:00:00 2001 From: Cecilia Lau Date: Fri, 5 Dec 2025 13:41:49 -0500 Subject: [PATCH 094/110] Use django's aggregations Signed-off-by: Cecilia Lau --- coldfront/core/portal/views.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py index 461a900f9f..7ff0d79a8a 100644 --- a/coldfront/core/portal/views.py +++ b/coldfront/core/portal/views.py @@ -166,17 +166,16 @@ def center_summary(request): total_grants_by_agency = sorted(total_grants_by_agency, key=operator.itemgetter(1), reverse=True) grants_agency_chart_data = generate_total_grants_by_agency_chart_data(total_grants_by_agency) context["grants_agency_chart_data"] = grants_agency_chart_data - context["grants_total"] = intcomma( - int(sum(map(float, Grant.objects.values_list("total_amount_awarded", flat=True)))) - ) + sum_agg = Sum("total_amount_awarded", default=0) + context["grants_total"] = intcomma(int(Grant.objects.aggregate(sum_agg)["total_amount_awarded__sum"])) context["grants_total_pi_only"] = intcomma( - int(sum(map(float, Grant.objects.filter(role="PI").values_list("total_amount_awarded", flat=True)))) + int(Grant.objects.filter(role="PI").aggregate(sum_agg)["total_amount_awarded__sum"]) ) context["grants_total_copi_only"] = intcomma( - int(sum(map(float, Grant.objects.filter(role="CoPI").values_list("total_amount_awarded", flat=True)))) + int(Grant.objects.filter(role="CoPI").aggregate(sum_agg)["total_amount_awarded__sum"]) ) context["grants_total_sp_only"] = intcomma( - int(sum(map(float, Grant.objects.filter(role="SP").values_list("total_amount_awarded", flat=True)))) + int(Grant.objects.filter(role="SP").aggregate(sum_agg)["total_amount_awarded__sum"]) ) return render(request, "portal/center_summary.html", context) From 3d64020c72bbea28794ff705ed6ac146c53e7616 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Thu, 4 Dec 2025 21:00:37 -0500 Subject: [PATCH 095/110] Fix deprecation warnings for Django5. In preparation for upgrading to Django5, this commit cleans up the following minor warnings/errors found by running: ``` PYTHONWARNINGS=always uv run coldfront test ``` - ResourceWarning: unclosed file <_io.TextIOWrapper name='.env' mode='r' encoding='UTF-8'> - RemovedInDjango50Warning: The USE_L10N setting is deprecated. (always enabled by default now) - RemovedInDjango50Warning: The django.utils.timezone.utc alias is deprecated. - DeprecationWarning: The 'warn' method is deprecated, use 'warning' instead We also adjust this test case which will error out in Django5: ERROR: test_project_attribute_create_post_required_values ProjectAttributeCreate correctly flags missing project or value ---------------------------------------------------------------------- Traceback (most recent call last): File "coldfront/core/project/tests/test_views.py", line 174, in test_project_attribute_create_post_required_values self.assertFormError(response, "form", "project", "This field is required.") File ".venv/lib/python3.12/site-packages/django/test/testcases.py", line 708, in assertFormError self._assert_form_error(form, field, errors, msg_prefix, f"form {form!r}") File ".venv/lib/python3.12/site-packages/django/test/testcases.py", line 674, in _assert_form_error if not form.is_bound: ^^^^^^^^^^^^^ AttributeError: 'TemplateResponse' object has no attribute 'is_bound' The `assertFormError` in SimpleTestCase requires a bound form instance not a template response. Instead we check the response content to ensure we're still on the project attribute create page. Signed-off-by: Andrew E. Bruno --- coldfront/config/base.py | 1 - coldfront/config/env.py | 4 ++-- coldfront/core/allocation/models.py | 2 +- coldfront/core/allocation/tests/test_models.py | 13 ++++++++----- coldfront/core/project/tests/test_views.py | 4 ++-- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/coldfront/config/base.py b/coldfront/config/base.py index 3727357205..a63a9c5b8f 100644 --- a/coldfront/config/base.py +++ b/coldfront/config/base.py @@ -36,7 +36,6 @@ LANGUAGE_CODE = ENV.str("LANGUAGE_CODE", default="en-us") TIME_ZONE = ENV.str("TIME_ZONE", default="America/New_York") USE_I18N = True -USE_L10N = True USE_TZ = True # ------------------------------------------------------------------------------ diff --git a/coldfront/config/env.py b/coldfront/config/env.py index 3ea9c12269..360a96eba8 100644 --- a/coldfront/config/env.py +++ b/coldfront/config/env.py @@ -19,7 +19,7 @@ # Read in any environment files for e in env_paths: try: - e.file("") - ENV.read_env(e()) + with e.file(""): + ENV.read_env(e()) except FileNotFoundError: pass diff --git a/coldfront/core/allocation/models.py b/coldfront/core/allocation/models.py index 6cc15b3daf..1581885bd7 100644 --- a/coldfront/core/allocation/models.py +++ b/coldfront/core/allocation/models.py @@ -417,7 +417,7 @@ def remove_user(self, user, signal_sender=None, ignore_user_not_found=True): allocation_user = self.allocationuser_set.get(user=user) except AllocationUser.DoesNotExist: if ignore_user_not_found: - logger.warn( + logger.warning( f"Cannot remove user={str(user)} for allocation pk={self.pk} - AllocationUser not found." ) return diff --git a/coldfront/core/allocation/tests/test_models.py b/coldfront/core/allocation/tests/test_models.py index 06d96fec43..3b510d382a 100644 --- a/coldfront/core/allocation/tests/test_models.py +++ b/coldfront/core/allocation/tests/test_models.py @@ -11,7 +11,6 @@ from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.test import TestCase -from django.utils import timezone from coldfront.core.allocation.models import ( Allocation, @@ -148,7 +147,7 @@ def test_status_is_expired_and_start_date_after_end_date_has_validation_error(se def test_status_is_expired_and_start_date_before_end_date_no_error(self): """Test that an allocation with status 'expired' and start date before end date does not raise a validation error.""" - start_date: datetime.date = datetime.datetime(year=2023, month=11, day=2, tzinfo=timezone.utc).date() + start_date: datetime.date = datetime.datetime(year=2023, month=11, day=2, tzinfo=datetime.timezone.utc).date() end_date: datetime.date = start_date + datetime.timedelta(days=40) actual_allocation: Allocation = AllocationFactory.build( @@ -158,7 +157,9 @@ def test_status_is_expired_and_start_date_before_end_date_no_error(self): def test_status_is_expired_and_start_date_equals_end_date_no_error(self): """Test that an allocation with status 'expired' and start date equal to end date does not raise a validation error.""" - start_and_end_date: datetime.date = datetime.datetime(year=1997, month=4, day=20, tzinfo=timezone.utc).date() + start_and_end_date: datetime.date = datetime.datetime( + year=1997, month=4, day=20, tzinfo=datetime.timezone.utc + ).date() actual_allocation: Allocation = AllocationFactory.build( status=self.expired_status, start_date=start_and_end_date, end_date=start_and_end_date, project=self.project @@ -194,7 +195,7 @@ def test_status_is_active_and_start_date_after_end_date_has_validation_error(sel def test_status_is_active_and_start_date_before_end_date_no_error(self): """Test that an allocation with status 'active' and start date before end date does not raise a validation error.""" - start_date: datetime.date = datetime.datetime(year=2001, month=5, day=3, tzinfo=timezone.utc).date() + start_date: datetime.date = datetime.datetime(year=2001, month=5, day=3, tzinfo=datetime.timezone.utc).date() end_date: datetime.date = start_date + datetime.timedelta(days=160) actual_allocation: Allocation = AllocationFactory.build( @@ -204,7 +205,9 @@ def test_status_is_active_and_start_date_before_end_date_no_error(self): def test_status_is_active_and_start_date_equals_end_date_no_error(self): """Test that an allocation with status 'active' and start date equal to end date does not raise a validation error.""" - start_and_end_date: datetime.date = datetime.datetime(year=2005, month=6, day=3, tzinfo=timezone.utc).date() + start_and_end_date: datetime.date = datetime.datetime( + year=2005, month=6, day=3, tzinfo=datetime.timezone.utc + ).date() actual_allocation: Allocation = AllocationFactory.build( status=self.active_status, start_date=start_and_end_date, end_date=start_and_end_date, project=self.project diff --git a/coldfront/core/project/tests/test_views.py b/coldfront/core/project/tests/test_views.py index 0aa02eff99..a329119ef9 100644 --- a/coldfront/core/project/tests/test_views.py +++ b/coldfront/core/project/tests/test_views.py @@ -171,12 +171,12 @@ def test_project_attribute_create_post_required_values(self): response = self.client.post( self.url, data={"proj_attr_type": self.projectattributetype.pk, "value": "test_value"} ) - self.assertFormError(response, "form", "project", "This field is required.") + self.assertIn(b"Adding project attribute to", response.content) # missing value response = self.client.post( self.url, data={"proj_attr_type": self.projectattributetype.pk, "project": self.project.pk} ) - self.assertFormError(response, "form", "value", "This field is required.") + self.assertIn(b"Adding project attribute to", response.content) def test_project_attribute_create_value_type_match(self): """ProjectAttributeCreate correctly flags value-type mismatch""" From 136ff865e38fa6585d9e5f5f63158885f6a96382 Mon Sep 17 00:00:00 2001 From: Simon Leary Date: Sun, 5 Oct 2025 17:27:44 +0000 Subject: [PATCH 096/110] document undocumented options Signed-off-by: Simon Leary --- CONTRIBUTING.md | 22 +-- docs/pages/config.md | 359 +++++++++++++++++++++++++++---------------- 2 files changed, 238 insertions(+), 143 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ddc157a0b..3a996ee1e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,10 @@ # Contributing to ColdFront -Before we start, thank you for considering contributing to ColdFront! +Before we start, thank you for considering contributing to ColdFront! -Every contribution no matter how small is welcome here! Whether you are adding a whole new feature, improving the aesthetics of the webpage, adding some tests cases, or just fixing a typo your contributions are appreciated. +Every contribution no matter how small is welcome here! Whether you are adding a whole new feature, improving the aesthetics of the webpage, adding some tests cases, or just fixing a typo your contributions are appreciated. -In this document you will find a set a guidelines for contributing to the ColdFront project, not hard rules. However, sticking to the advice contained in this document will help you to make better contributions, save time, and increase the chances of your contributions getting accepted. +In this document you will find a set a guidelines for contributing to the ColdFront project, not hard rules. However, sticking to the advice contained in this document will help you to make better contributions, save time, and increase the chances of your contributions getting accepted. This project abides by a [Code of Conduct](CODE_OF_CONDUCT.md) that all contributors are required to uphold. Please read this document before interacting with the project. @@ -20,11 +20,11 @@ For most contributions, you will start by opening up an issue, discussing change ### Issues -We track requested changes using GitHub issues. These include bugs, feature requests, and general concerns. +We track requested changes using GitHub issues. These include bugs, feature requests, and general concerns. -Before making an issue, please look at current and previous issues to make sure that your concern has not already been raised by someone else. It is also advised to read through the [current documentation](https://coldfront.readthedocs.io/en/stable/). If an issue with your concern is already opened you are encouraged to comment further on it. The `Search Issues` feature is great to check to see if someone has already raised your issue before. +Before making an issue, please look at current and previous issues to make sure that your concern has not already been raised by someone else. It is also advised to read through the [current documentation](https://coldfront.readthedocs.io/en/stable/). If an issue with your concern is already opened you are encouraged to comment further on it. The `Search Issues` feature is great to check to see if someone has already raised your issue before. -If, after searching pre-existing issues, your concern has not been raised (or you are unsure if a previous issue covers your concern) please open a new issue with any labels you believe are relevant. Please include any relevant images, links, and syntax-highlighted text snippets to help maintainers understand your concerns better. It is also helpful to include any debugging steps you have attempted or ideas on how to fix your issue. +If, after searching pre-existing issues, your concern has not been raised (or you are unsure if a previous issue covers your concern) please open a new issue with any labels you believe are relevant. Please include any relevant images, links, and syntax-highlighted text snippets to help maintainers understand your concerns better. It is also helpful to include any debugging steps you have attempted or ideas on how to fix your issue. ### Pull Requests @@ -34,7 +34,7 @@ To create a pull request: 2. Create a branch off the `main` branch. 3. Make commits to your branch. Make sure your additions include [testing](#testing) for any new functionality. Make sure that you run the full test suite on your PR before submitting. If your changes necessitate removing or changing previous tests, please state this explicitly in your PR. Also ensure that your changes pass the [linter and formatter](#formatting-and-linting). 4. Create a pull request back to this main repository. -5. Wait for a maintainer to review your code and request any changes. Don't worry if the maintainer asks for changes! This feedback is perfectly normal and ensures a more maintainable project for everyone. +5. Wait for a maintainer to review your code and request any changes. Don't worry if the maintainer asks for changes! This feedback is perfectly normal and ensures a more maintainable project for everyone. ## Conventions and Style Guide @@ -44,7 +44,7 @@ Please use a spell-checker when modifying the codebase to reduce the prevalence #### Annotations -You are encouraged to use Python's type annotations to improve code readability and maintainability. Whenever possible, use the most recent annotation syntax available for the minimum version of Python supported by ColdFront. +You are encouraged to use Python's type annotations to improve code readability and maintainability. Whenever possible, use the most recent annotation syntax available for the minimum version of Python supported by ColdFront. > The minimum Python version supported can be found in the `pyproject.toml` file. @@ -52,11 +52,11 @@ You are encouraged to use Python's type annotations to improve code readability All new and changed features must include unit tests to verify that they work correctly. Every non-trivial function should have at least as many test cases as its cyclomatic complexity to verify all independent code paths in the function operate correctly. -When using [uv](https://docs.astral.sh/uv/), the full test suite can be run using the command `uv run coldfront test`. +When using [uv](https://docs.astral.sh/uv/), the full test suite can be run using the command `uv run coldfront test`. #### Formatting and Linting -This project is formatted and linted using [ruff](https://docs.astral.sh/ruff/). +This project is formatted and linted using [ruff](https://docs.astral.sh/ruff/). You can use Ruff to check for any linting errors in proposed Python code using `uv run ruff check`. Ruff can also fix many linting errors automatically with `uv run ruff check --fix` when using [uv](https://docs.astral.sh/uv/). @@ -64,4 +64,4 @@ If your code is failing linting checks but you you have a valid reason to leave You can also use Ruff to check formatting using `uv run ruff format --check` and automatically fix formatting errors with `uv run ruff format`. -If your code is failing formatting checks but you have a valid reason to leave it unchanged, you can suppress warnings for a specific block of code by enclosing it with the comments `# fmt: off` and `# fmt: on`. These comments work at the statement level so placing them inside of expressions will not have any effect. +If your code is failing formatting checks but you have a valid reason to leave it unchanged, you can suppress warnings for a specific block of code by enclosing it with the comments `# fmt: off` and `# fmt: on`. These comments work at the statement level so placing them inside of expressions will not have any effect. diff --git a/docs/pages/config.md b/docs/pages/config.md index 9ee8e2a7ec..485feb8315 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -75,60 +75,85 @@ $ COLDFRONT_ENV=coldfront.env coldfront runserver The following settings allow overriding basic ColdFront Django settings. For more advanced configuration use `local_settings.py`. -| Name | Description | -| :------------------------- |:-------------------------------------| -| ALLOWED_HOSTS | A list of strings representing the host/domain names that ColdFront can serve. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#allowed-hosts) | -| DEBUG | Turn on/off debug mode. Never deploy a site into production with DEBUG turned on. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#debug) | -| SECRET_KEY | This is used to provide cryptographic signing, and should be set to a unique, unpredictable value. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#secret-key). If you don't provide this one will be generated each time ColdFront starts. | -| LANGUAGE_CODE | A string representing the language code. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#language-code) -| TIME_ZONE | A string representing the time zone for this installation. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-TIME_ZONE) | -| Q_CLUSTER_RETRY | The number of seconds Django Q broker will wait for a cluster to finish a task. [See here](https://django-q.readthedocs.io/en/latest/configure.html#retry) | -| Q_CLUSTER_TIMEOUT | The number of seconds a Django Q worker is allowed to spend on a task before it’s terminated. IMPORTANT NOTE: Q_CLUSTER_TIMEOUT must be less than Q_CLUSTER_RETRY. [See here](https://django-q.readthedocs.io/en/latest/configure.html#timeout) | -| SESSION_INACTIVITY_TIMEOUT | Seconds of inactivity after which sessions will expire (default 1hr). This value sets the `SESSION_COOKIE_AGE` and the session is saved on every request. [See here](https://docs.djangoproject.com/en/4.1/topics/http/sessions/#when-sessions-are-saved) | +| Name | Description | Has Setting | Has Environment Variable | +| :------------------------- |:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------|:-------------------------| +| ALLOWED_HOSTS | A list of strings representing the host/domain names that ColdFront can serve. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#allowed-hosts) | no | yes | +| DEBUG | Turn on/off debug mode. Never deploy a site into production with DEBUG turned on. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#debug) | no | yes | +| SECRET_KEY | This is used to provide cryptographic signing, and should be set to a unique, unpredictable value. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#secret-key). If you don't provide this one will be generated each time ColdFront starts. | no | yes | +| LANGUAGE_CODE | A string representing the language code. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#language-code) | no | yes | +| TIME_ZONE | A string representing the time zone for this installation. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-TIME_ZONE) | no | yes | +| Q_CLUSTER_RETRY | The number of seconds Django Q broker will wait for a cluster to finish a task. [See here](https://django-q.readthedocs.io/en/latest/configure.html#retry) | no | yes | +| Q_CLUSTER_TIMEOUT | The number of seconds a Django Q worker is allowed to spend on a task before it’s terminated. IMPORTANT NOTE: Q_CLUSTER_TIMEOUT must be less than Q_CLUSTER_RETRY. [See here](https://django-q.readthedocs.io/en/latest/configure.html#timeout) | no | yes | +| SESSION_INACTIVITY_TIMEOUT | Seconds of inactivity after which sessions will expire (default 1hr). This value sets the `SESSION_COOKIE_AGE` and the session is saved on every request. [See here](https://docs.djangoproject.com/en/4.1/topics/http/sessions/#when-sessions-are-saved) | no | yes | ### Template settings -| Name | Description | -| :--------------------|:-------------------------------------| -| STATIC_ROOT | Path to the directory where collectstatic will collect static files for deployment. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-STATIC_ROOT) | -| SITE_TEMPLATES | Path to a directory of custom templates. Add custom templates here. This path will be added to TEMPLATES DIRS. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-TEMPLATES-DIRS) | -| SITE_STATIC | Path to a directory of custom static files. Add custom css here. This path will be added to STATICFILES_DIRS [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-STATICFILES_DIRS) | +| Name | Description | Has Setting | Has Environment Variable | +| :--------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------|:-------------------------| +| STATIC_ROOT | Path to the directory where collectstatic will collect static files for deployment. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-STATIC_ROOT) | no | yes | +| SITE_TEMPLATES | Path to a directory of custom templates. Add custom templates here. This path will be added to TEMPLATES DIRS. [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-TEMPLATES-DIRS) | no | yes | +| SITE_STATIC | Path to a directory of custom static files. Add custom css here. This path will be added to STATICFILES_DIRS [See here](https://docs.djangoproject.com/en/3.1/ref/settings/#std:setting-STATICFILES_DIRS) | no | yes | ### ColdFront core settings The following settings are ColdFront specific settings related to the core application. -| Name | Description | -| :--------------------------------------|:-----------------------------------------------| -| CENTER_NAME | The display name of your center | -| CENTER_HELP_URL | The URL of your help ticketing system | -| CENTER_PROJECT_RENEWAL_HELP_URL | The URL of the article describing project renewals | -| CENTER_BASE_URL | The base URL of your center. | -| PROJECT_ENABLE_PROJECT_REVIEW | Enable or disable project reviews. Default True| -| ALLOCATION_ENABLE_ALLOCATION_RENEWAL | Enable or disable allocation renewals. Default True | -| ALLOCATION_DEFAULT_ALLOCATION_LENGTH | Default number of days an allocation is active for. Default 365 | -| ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT | Enable or disable allocation change requests. Default True | -| ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS | List of days users can request extensions in an allocation change request. Default 30,60,90 | -| ALLOCATION_ACCOUNT_ENABLED | Allow user to select account name for allocation. Default False | -| ALLOCATION_RESOURCE_ORDERING | Controls the ordering of parent resources for an allocation (if allocation has multiple resources). Should be a list of field names suitable for Django QuerySet order_by method. Default is ['-is_allocatable', 'name']; i.e. prefer Resources with is_allocatable field set, ordered by name of the Resource.| -| ALLOCATION_EULA_ENABLE | Enable or disable requiring users to agree to EULA on allocations. Only applies to allocations using a resource with a defined 'eula' attribute. Default False| -| INVOICE_ENABLED | Enable or disable invoices. Default True | -| ONDEMAND_URL | The URL to your Open OnDemand installation | -| LOGIN_FAIL_MESSAGE | Custom message when user fails to login. Here you can paint a custom link to your user account portal | -| ENABLE_SU | Enable administrators to login as other users. Default True | -| RESEARCH_OUTPUT_ENABLE | Enable or disable research outputs. Default True | -| GRANT_ENABLE | Enable or disable grants. Default True | -| PUBLICATION_ENABLE | Enable or disable publications. Default True | -| PROJECT_CODE | Specifies a custom internal project identifier. Default False, provide string value to enable. Must be no longer than 10 - PROJECT_CODE_PADDING characters in length.| -| PROJECT_CODE_PADDING | Defines a optional padding value to be added before the Primary Key section of PROJECT_CODE. Default False, provide integer value to enable.| -| PROJECT_INSTITUTION_EMAIL_MAP | Defines a dictionary where PI domain email addresses are keys and their corresponding institutions are values. Default is False, provide key-value pairs to enable this feature.| +| Name | Description | Has Setting | Has Environment Variable | +|:---------------------------------------------|:------------------------------------------------------------------------------------------------------|:------------|:-------------------------| +| CENTER_NAME | The display name of your center | yes | yes | +| CENTER_HELP_URL | The URL of your help ticketing system | no | yes | +| CENTER_PROJECT_RENEWAL_HELP_URL | The URL of the article describing project renewals | yes | yes | +| CENTER_BASE_URL | The base URL of your center. | yes | yes | +| PROJECT_ENABLE_PROJECT_REVIEW | Enable or disable project reviews. Default True | yes | yes | +| ALLOCATION_ENABLE_ALLOCATION_RENEWAL | Enable or disable allocation renewals. Default True | yes | yes | +| ALLOCATION_DEFAULT_ALLOCATION_LENGTH | Default number of days an allocation is active for. Default 365 | yes | yes | +| ALLOCATION_ENABLE_CHANGE_REQUESTS_BY_DEFAULT | Enable or disable allocation change requests. Default True | yes | no | +| ALLOCATION_CHANGE_REQUEST_EXTENSION_DAYS | List of days users can request extensions in an allocation change request. Default 30,60,90 | yes | yes | +| ALLOCATION_ACCOUNT_ENABLED | Allow user to select account name for allocation. Default False | yes | yes | +| ALLOCATION_ACCOUNT_MAPPING | Mapping where each key is the name of a resource and each value is the name of the attribute where the account name will be stored in the allocation. Only applies when `ALLOCATION_ACCOUNT_ENABLE` is true. Default `{}` | yes | yes | +| ALLOCATION_RESOURCE_ORDERING | Controls the ordering of parent resources for an allocation (if allocation has multiple resources). Should be a list of field names suitable for Django QuerySet order_by method. Default is ['-is_allocatable', 'name']; i.e. prefer Resources with is_allocatable field set, ordered by name of the Resource. | yes | no | +| ALLOCATION_EULA_ENABLE | Enable or disable requiring users to agree to EULA on allocations. Only applies to allocations using a resource with a defined 'eula' attribute. Default False | yes | yes | +| ALLOCATION_ATTRIBUTE_VIEW_LIST | Names of allocation attributes which should be viewed as a list | yes | yes | +| ALLOCATION_FUNCS_ON_EXPIRE | Functions to be called when an allocation expires | yes | no | +| INVOICE_ENABLED | Enable or disable invoices. Default True | yes | yes | +| ONDEMAND_URL | The URL to your Open OnDemand installation | no | yes | +| LOGIN_FAIL_MESSAGE | Custom message when user fails to login. Here you can paint a custom link to your user account portal | no | yes | +| ENABLE_SU | Enable administrators to login as other users. Default True | no | yes | +| RESEARCH_OUTPUT_ENABLE | Enable or disable research outputs. Default True | no | yes | +| GRANT_ENABLE | Enable or disable grants. Default True | no | yes | +| PUBLICATION_ENABLE | Enable or disable publications. Default True | no | yes | +| PROJECT_CODE | Specifies a custom internal project identifier. Default False, provide string value to enable. Must be no longer than 10 - PROJECT_CODE_PADDING characters in length. | yes | yes | +| PROJECT_CODE_PADDING | Defines a optional padding value to be added before the Primary Key section of PROJECT_CODE. Default False, provide integer value to enable. | yes | yes | +| PROJECT_INSTITUTION_EMAIL_MAP | Defines a dictionary where PI domain email addresses are keys and their corresponding institutions are values. Default is False, provide key-value pairs to enable this feature. | yes | yes | +| ACCOUNT_CREATION_TEXT | | yes | yes | +| ADDITIONAL_USER_SEARCH_CLASSES | | yes | no | +| ADMIN_COMMENTS_SHOW_EMPTY | | no | yes | +| ALLOCATION_ENABLE_CHANGE_REQUESTS | | no | yes | +| BASE_DIR | | yes | no | +| COLDFRONT_CONFIG | | no | yes | +| COLDFRONT_ENV | | no | yes | +| COLDFRONT_URLS | | no | yes | +| INVOICE_DEFAULT_STATUS | | yes | yes | +| LOGOUT_REDIRECT_URL | | no | yes | +| PLUGIN_API | | no | yes | +| PLUGIN_SYSMON | | no | yes | +| SYSMON_ENDPOINT | same as `SYSTEM_MONITOR_ENDPOINT` | no | yes | +| SYSMON_LINK | same as `SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK` | no | yes | +| SYSMON_TITLE | same as `SYSTEM_MONITOR_PANEL_TITLE` | no | yes | +| SYSMON_XDMOD_LINK | same as `SYSTEM_MONITOR_DISPLAY_XDMOD_LINK` | no | yes | +| SYSTEM_MONITOR_DISPLAY_MORE_STATUS_INFO_LINK | | yes | no | +| SYSTEM_MONITOR_DISPLAY_XDMOD_LINK | | yes | no | +| SYSTEM_MONITOR_ENDPOINT | | yes | no | +| SYSTEM_MONITOR_PANEL_TITLE | | yes | no | +| TEMPLATES | | yes | no | + ### Database settings The following settings configure the database server to use, if not set will default to using SQLite: -| Name | Description | -| :--------------------|:-------------------------------------| -| DB_URL | The database connection url string | +| Name | Description | Has Setting | Has Environment Variable | +| :--------------------|:-----------------------------------|:------------|:-------------------------| +| DB_URL | The database connection url string | no | yes | Examples: @@ -144,30 +169,32 @@ DB_URL=sqlite:////usr/share/coldfront/coldfront.db The following settings configure emails in ColdFront. By default email is disabled: -| Name | Description | -| :-------------------------------|:------------------------------------------| -| EMAIL_ENABLED | Enable/disable email. Default False | -| EMAIL_HOST | Hostname of smtp server | -| EMAIL_PORT | smtp port | -| EMAIL_HOST_USER | Username for smtp | -| EMAIL_HOST_PASSWORD | password for smtp | -| EMAIL_USE_TLS | Enable/disable tls. Default False | -| EMAIL_SENDER | Default sender email address | -| EMAIL_SUBJECT_PREFIX | Prefix to add to subject line | -| EMAIL_ADMIN_LIST | List of admin email addresses. | -| EMAIL_TICKET_SYSTEM_ADDRESS | Email address of ticketing system | -| EMAIL_DIRECTOR_EMAIL_ADDRESS | Email address for director | -| EMAIL_PROJECT_REVIEW_CONTACT | Email address of review contact | -| EMAIL_DEVELOPMENT_EMAIL_LIST | List of emails to send when in debug mode | -| EMAIL_OPT_OUT_INSTRUCTION_URL | URL of article regarding opt out | -| EMAIL_SIGNATURE | Email signature to add to outgoing emails | -| EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS | List of days to send email notifications for expiring allocations. Default 7,14,30 | -| EMAIL_ADMINS_ON_ALLOCATION_EXPIRE | Setting this to True will send a daily email notification to administrators with a list of allocations that have expired that day. | -| EMAIL_ALLOCATION_EULA_REMINDERS | Enable/Disable EULA reminders. Default False | -| EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT | Ignore user email settings and always send EULA related emails. Default False | -| EMAIL_ALLOCATION_EULA_CONFIRMATIONS | Enable/Disable email notifications when a EULA is accepted or declined. Default False | -| EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS | CC project managers on eula notification emails (requires EMAIL_ALLOCATION_EULA_CONFIRMATIONS to be enabled). Default False | -| EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA | Include copy of EULA in email notifications for accepted EULAs. Default False | +| Name | Description | Has Setting | Has Environment Variable | +| :-----------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------|:------------|:-------------------------| +| EMAIL_ENABLED | Enable/disable email. Default False | yes | yes | +| EMAIL_HOST | Hostname of smtp server | no | yes | +| EMAIL_PORT | smtp port | no | yes | +| EMAIL_HOST_USER | Username for smtp | no | yes | +| EMAIL_HOST_PASSWORD | password for smtp | no | yes | +| EMAIL_USE_TLS | Enable/disable tls. Default False | no | yes | +| EMAIL_SENDER | Default sender email address | yes | yes | +| EMAIL_SUBJECT_PREFIX | Prefix to add to subject line | yes | yes | +| EMAIL_ADMIN_LIST | List of admin email addresses. | yes | yes | +| EMAIL_TICKET_SYSTEM_ADDRESS | Email address of ticketing system | yes | yes | +| EMAIL_DIRECTOR_EMAIL_ADDRESS | Email address for director | yes | yes | +| EMAIL_PROJECT_REVIEW_CONTACT | Email address of review contact | no | yes | +| EMAIL_DEVELOPMENT_EMAIL_LIST | List of emails to send when in debug mode | yes | yes | +| EMAIL_OPT_OUT_INSTRUCTION_URL | URL of article regarding opt out | yes | yes | +| EMAIL_SIGNATURE | Email signature to add to outgoing emails | yes | yes | +| EMAIL_ALLOCATION_EXPIRING_NOTIFICATION_DAYS | List of days to send email notifications for expiring allocations. Default 7,14,30 | yes | yes | +| EMAIL_ADMINS_ON_ALLOCATION_EXPIRE | Setting this to True will send a daily email notification to administrators with a list of allocations that have expired that day. | yes | yes | +| EMAIL_ALLOCATION_EULA_REMINDERS | Enable/Disable EULA reminders. Default False | no | yes | +| EMAIL_ALLOCATION_EULA_IGNORE_OPT_OUT | Ignore user email settings and always send EULA related emails. Default False | yes | yes | +| EMAIL_ALLOCATION_EULA_CONFIRMATIONS | Enable/Disable email notifications when a EULA is accepted or declined. Default False | yes | yes | +| EMAIL_ALLOCATION_EULA_CONFIRMATIONS_CC_MANAGERS | CC project managers on eula notification emails (requires EMAIL_ALLOCATION_EULA_CONFIRMATIONS to be enabled). Default False | yes | yes | +| EMAIL_ALLOCATION_EULA_INCLUDE_ACCEPTED_EULA | Include copy of EULA in email notifications for accepted EULAs. Default False | yes | yes | +| EMAIL_TIMEOUT | | no | yes | +| EMAIL_DIRECTOR_PENDING_PROJECT_REVIEW_EMAIL | | yes | no | ### Plugin settings For more info on [ColdFront plugins](plugin/existing_plugins.md) (Django apps) @@ -184,18 +211,19 @@ For more info on [ColdFront plugins](plugin/existing_plugins.md) (Django apps) global OS ldap config, `/etc/{ldap,openldap}/ldap.conf` and within `TLS_CACERT` -| Name | Description | -| :---------------------------|:----------------------------------------| -| PLUGIN_AUTH_LDAP | Enable LDAP Authentication Backend. Default False | -| AUTH_LDAP_SERVER_URI | URI of LDAP server | -| AUTH_LDAP_START_TLS | Enable/disable start tls. Default True | -| AUTH_LDAP_BIND_DN | The distinguished name to use when binding to the LDAP server | -| AUTH_LDAP_BIND_PASSWORD | The password to use AUTH_LDAP_BIND_DN | -| AUTH_LDAP_USER_SEARCH_BASE | User search base dn | -| AUTH_LDAP_GROUP_SEARCH_BASE | Group search base dn | -| AUTH_COLDFRONT_LDAP_SEARCH_SCOPE | The search scope for Coldfront authentication. Options: SUBTREE or default (ONELEVEL) | -| AUTH_LDAP_MIRROR_GROUPS | Enable/disable mirroring of groups. Default True | -| AUTH_LDAP_BIND_AS_AUTHENTICATING_USER | Authentication will leave the LDAP connection bound as the authenticating user, rather than forcing it to re-bind. Default False | +| Name | Description | Has Setting | Has Environment Variable | +| :-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------|:------------|:-------------------------| +| PLUGIN_AUTH_LDAP | Enable LDAP Authentication Backend. Default False | no | yes | +| AUTH_LDAP_SERVER_URI | URI of LDAP server | no | yes | +| AUTH_LDAP_START_TLS | Enable/disable start tls. Default True | no | yes | +| AUTH_LDAP_BIND_DN | The distinguished name to use when binding to the LDAP server | no | yes | +| AUTH_LDAP_BIND_PASSWORD | The password to use AUTH_LDAP_BIND_DN | no | yes | +| AUTH_LDAP_USER_SEARCH_BASE | User search base dn | no | yes | +| AUTH_LDAP_GROUP_SEARCH_BASE | Group search base dn | no | yes | +| AUTH_COLDFRONT_LDAP_SEARCH_SCOPE | The search scope for Coldfront authentication. Options: SUBTREE or default (ONELEVEL) | no | yes | +| AUTH_LDAP_MIRROR_GROUPS | Enable/disable mirroring of groups. Default True | no | yes | +| AUTH_LDAP_BIND_AS_AUTHENTICATING_USER | Authentication will leave the LDAP connection bound as the authenticating user, rather than forcing it to re-bind. Default False | no | yes | +| AUTH_LDAP_USER_ATTR_MAP | | no | yes | #### OpenID Connect Auth @@ -210,18 +238,18 @@ For more info on [ColdFront plugins](plugin/existing_plugins.md) (Django apps) authentication process. You must use `SESSION_COOKIE_SAMESITE="Lax"` in your settings for authentication to work correctly. -| Name | Description | -| :------------------------------|:-------------------------------------| -| PLUGIN_AUTH_OIDC | Enable OpenID Connect Authentication Backend. Default False | -| OIDC_OP_JWKS_ENDPOINT | URL of JWKS endpoint | -| OIDC_RP_SIGN_ALGO | Signature algorithm | -| OIDC_RP_CLIENT_ID | Client ID | -| OIDC_RP_CLIENT_SECRET | Client secret | -| OIDC_OP_AUTHORIZATION_ENDPOINT | OAuth2 authorization endpoint | -| OIDC_OP_TOKEN_ENDPOINT | OAuth2 token endpoint | -| OIDC_OP_USER_ENDPOINT | OAuth2 userinfo endpoint | -| OIDC_VERIFY_SSL | Verify ssl Default True | -| OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS | Token lifetime in seconds. Default 3600 | +| Name | Description | Has Setting | Has Environment Variable | +| :----------------------------------|:------------------------------------------------------------|:------------|:-------------------------| +| PLUGIN_AUTH_OIDC | Enable OpenID Connect Authentication Backend. Default False | no | yes | +| OIDC_OP_JWKS_ENDPOINT | URL of JWKS endpoint | no | yes | +| OIDC_RP_SIGN_ALGO | Signature algorithm | no | yes | +| OIDC_RP_CLIENT_ID | Client ID | no | yes | +| OIDC_RP_CLIENT_SECRET | Client secret | no | yes | +| OIDC_OP_AUTHORIZATION_ENDPOINT | OAuth2 authorization endpoint | no | yes | +| OIDC_OP_TOKEN_ENDPOINT | OAuth2 token endpoint | no | yes | +| OIDC_OP_USER_ENDPOINT | OAuth2 userinfo endpoint | no | yes | +| OIDC_VERIFY_SSL | Verify ssl Default True | no | yes | +| OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS | Token lifetime in seconds. Default 3600 | no | yes | #### Mokey @@ -229,46 +257,64 @@ For more info on [ColdFront plugins](plugin/existing_plugins.md) (Django apps) Mokey depends on the OpenID Connect plugin above. You must also enable the OpenID Connect plugin via `PLUGIN_AUTH_OIDC=True`. -| Name | Description | -| :--------------------|:-------------------------------------| -| PLUGIN_MOKEY | Enable Mokey/Hydra OpenID Connect Authentication Backend. Default False| +| Name | Description | Has Setting | Has Environment Variable | +| :-------------------------|:------------------------------------------------------------------------|:------------|:-------------------------| +| PLUGIN_MOKEY | Enable Mokey/Hydra OpenID Connect Authentication Backend. Default False | no | yes | +| MOKEY_OIDC_ALLOWED_GROUPS | | yes | no | +| MOKEY_OIDC_DENY_GROUPS | | yes | no | +| MOKEY_OIDC_PI_GROUP | | yes | yes | #### Slurm -| Name | Description | -| :---------------------|:-------------------------------------| -| PLUGIN_SLURM | Enable Slurm integration. Default False | -| SLURM_SACCTMGR_PATH | Path to sacctmgr command. Default `/usr/bin/sacctmgr` | -| SLURM_NOOP | Enable/disable noop. Default False | -| SLURM_IGNORE_USERS | List of user accounts to ignore when generating Slurm associations | -| SLURM_IGNORE_ACCOUNTS | List of Slurm accounts to ignore when generating Slurm associations | +| Name | Description | Has Setting | Has Environment Variable | +| :-------------------------------|:--------------------------------------------------------------------|:------------|:-------------------------| +| PLUGIN_SLURM | Enable Slurm integration. Default False | no | yes | +| SLURM_SACCTMGR_PATH | Path to sacctmgr command. Default `/usr/bin/sacctmgr` | yes | yes | +| SLURM_NOOP | Enable/disable noop. Default False | yes | yes | +| SLURM_IGNORE_USERS | List of user accounts to ignore when generating Slurm associations | yes | yes | +| SLURM_IGNORE_ACCOUNTS | List of Slurm accounts to ignore when generating Slurm associations | yes | yes | +| SLURM_ACCOUNT_ATTRIBUTE_NAME | Internal use only | yes | no | +| SLURM_CLUSTER_ATTRIBUTE_NAME | Internal use only | yes | no | +| SLURM_IGNORE_CLUSTERS | Internal use only | yes | no | +| SLURM_SPECS_ATTRIBUTE_NAME | Internal use only | yes | no | +| SLURM_USER_SPECS_ATTRIBUTE_NAME | Internal use only | yes | no | #### XDMoD -| Name | Description | -| :--------------------|:----------------------------------------| -| PLUGIN_XDMOD | Enable XDMoD integration. Default False | -| XDMOD_API_URL | URL to XDMoD API | +| Name | Description | Has Setting | Has Environment Variable | +| :------------------------------------|:----------------------------------------|:------------|:-------------------------| +| PLUGIN_XDMOD | Enable XDMoD integration. Default False | no | yes | +| XDMOD_API_URL | URL to XDMoD API | yes | yes | +| XDMOD_ACC_HOURS_ATTRIBUTE_NAME | Internal use only | yes | no | +| XDMOD_ACCOUNT_ATTRIBUTE_NAME | Internal use only | yes | no | +| XDMOD_CLOUD_CORE_TIME_ATTRIBUTE_NAME | Internal use only | yes | no | +| XDMOD_CLOUD_PROJECT_ATTRIBUTE_NAME | Internal use only | yes | no | +| XDMOD_CPU_HOURS_ATTRIBUTE_NAME | Internal use only | yes | no | +| XDMOD_RESOURCE_ATTRIBUTE_NAME | Internal use only | yes | no | +| XDMOD_STORAGE_ATTRIBUTE_NAME | Internal use only | yes | no | +| XDMOD_STORAGE_GROUP_ATTRIBUTE_NAME | Internal use only | yes | no | #### FreeIPA -| Name | Description | -| :------------------------|:------------------------------------------| -| PLUGIN_FREEIPA | Enable FreeIPA integration. Default False | -| FREEIPA_KTNAME | Path to keytab file | -| FREEIPA_SERVER | Hostname of FreeIPA server | -| FREEIPA_USER_SEARCH_BASE | User search base dn | -| FREEIPA_ENABLE_SIGNALS | Enable/Disable signals. Default False | +| Name | Description | Has Setting | Has Environment Variable | +| :----------------------------|:------------------------------------------|:------------|:-------------------------| +| PLUGIN_FREEIPA | Enable FreeIPA integration. Default False | no | yes | +| FREEIPA_KTNAME | Path to keytab file | yes | yes | +| FREEIPA_SERVER | Hostname of FreeIPA server | yes | yes | +| FREEIPA_USER_SEARCH_BASE | User search base dn | yes | yes | +| FREEIPA_ENABLE_SIGNALS | Enable/Disable signals. Default False | yes | no | +| FREEIPA_GROUP_ATTRIBUTE_NAME | Internal use only | yes | no | +| FREEIPA_NOOP | Internal use only | yes | no | #### iquota -| Name | Description | -| :---------------|:-----------------------------------------| -| PLUGIN_IQUOTA | Enable iquota integration. Default False | -| IQUOTA_KEYTAB | Path to keytab file | -| IQUOTA_CA_CERT | Path to ca cert | -| IQUOTA_API_HOST | Hostname of iquota server | -| IQUOTA_API_PORT | Port of iquota server | +| Name | Description | Has Setting | Has Environment Variable | +| :---------------|:-----------------------------------------|:------------|:-------------------------| +| PLUGIN_IQUOTA | Enable iquota integration. Default False | no | yes | +| IQUOTA_KEYTAB | Path to keytab file | yes | yes | +| IQUOTA_CA_CERT | Path to ca cert | yes | yes | +| IQUOTA_API_HOST | Hostname of iquota server | yes | yes | +| IQUOTA_API_PORT | Port of iquota server | yes | yes | #### LDAP User Search @@ -282,20 +328,69 @@ exist in your backend LDAP to show up in the ColdFront user search. $ pip install ldap3 ``` -| Name | Description | -| :---------------------------|:----------------------------------------| -| PLUGIN_LDAP_USER_SEARCH | Enable LDAP User Search. Default False | -| LDAP_USER_SEARCH_SERVER_URI | URI of LDAP server | -| LDAP_USER_SEARCH_BIND_DN | The distinguished name to use when binding to the LDAP server | -| LDAP_USER_SEARCH_BIND_PASSWORD | The password to use LDAP_USER_SEARCH_BIND_DN | -| LDAP_USER_SEARCH_BASE | User search base dn | -| LDAP_USER_SEARCH_CONNECT_TIMEOUT | Time in seconds to wait before timing out. Default 2.5 | -| LDAP_USER_SEARCH_USE_SSL | Whether to use ssl when connecting to LDAP server. Default True | -| LDAP_USER_SEARCH_USE_TLS | Whether to use tls when connecting to LDAP server. Default False | -| LDAP_USER_SEARCH_PRIV_KEY_FILE | Path to the private key file. | -| LDAP_USER_SEARCH_CERT_FILE | Path to the certificate file. | -| LDAP_USER_SEARCH_CACERT_FILE | Path to the CA cert file. | -| LDAP_USER_SEARCH_CERT_VALIDATE_MODE | Whether to require/validate certs. If 'required', certs are required and validated. If 'optional', certs are optional but validated if provided. If 'none' (the default) certs are ignored. | +| Name | Description | Has Setting | Has Environment Variable | +| :-----------------------------------|:-----------------------------------------------------------------|:------------|:-------------------------| +| PLUGIN_LDAP_USER_SEARCH | Enable LDAP User Search. Default False | no | yes | +| LDAP_USER_SEARCH_SERVER_URI | URI of LDAP server | yes | yes | +| LDAP_USER_SEARCH_BIND_DN | The distinguished name to use when binding to the LDAP server | yes | yes | +| LDAP_USER_SEARCH_BIND_PASSWORD | The password to use LDAP_USER_SEARCH_BIND_DN | yes | yes | +| LDAP_USER_SEARCH_BASE | User search base dn | yes | yes | +| LDAP_USER_SEARCH_CONNECT_TIMEOUT | Time in seconds to wait before timing out. Default 2.5 | yes | yes | +| LDAP_USER_SEARCH_USE_SSL | Whether to use ssl when connecting to LDAP server. Default True | yes | yes | +| LDAP_USER_SEARCH_USE_TLS | Whether to use tls when connecting to LDAP server. Default False | yes | yes | +| LDAP_USER_SEARCH_PRIV_KEY_FILE | Path to the private key file. | yes | yes | +| LDAP_USER_SEARCH_CERT_FILE | Path to the certificate file. | yes | yes | +| LDAP_USER_SEARCH_CACERT_FILE | Path to the CA cert file. | yes | yes | +| LDAP_USER_SEARCH_CERT_VALIDATE_MODE | Whether to require/validate certs. If 'required', certs are required and validated. If 'optional', certs are optional but validated if provided. If 'none' (the default) certs are ignored. | yes | yes | +| LDAP_USER_SEARCH_ATTRIBUTE_MAP | Internal use only | yes | no | +| LDAP_USER_SEARCH_MAPPING_CALLBACK | Internal use only | yes | no | +| LDAP_USER_SEARCH_SASL_CREDENTIALS | Internal use only | yes | no | +| LDAP_USER_SEARCH_SASL_MECHANISM | Internal use only | yes | no | +| LDAP_USER_SEARCH_USERNAME_ONLY_ATTR | Internal use only | yes | no | + +#### Project OpenLDAP + +This plugin allows for projects and project membership to be synced to an OpenLDAP server. +See `coldfront/coldfront/plugins/project_openldap/README.md` in the source code for more detailed information. + +| Option | Description | Has Setting | Has Environment Variable | +| :-------------------------------------------|:-------------------------------------------------------------------------------------|:------------|:-------------------------| +| `PLUGIN_PROJECT_OPENLDAP` | Enable the plugin, required to be set as True (bool). | no | yes | +| `PROJECT_OPENLDAP_GID_START` | Starting value for project gidNumbers, requires an integer. | yes | yes | +| `PROJECT_OPENLDAP_SERVER_URI` | The URI of the OpenLDAP instance, requires a string URI. | yes | yes | +| `PROJECT_OPENLDAP_OU` | The OU where projects will be written, requires a string DN of OU. | yes | yes | +| `PROJECT_OPENLDAP_BIND_USER` | DN of bind user. | yes | yes | +| `PROJECT_OPENLDAP_BIND_PASSWORD` | The password for the bind user, requires a string. | yes | yes | +| `PROJECT_OPENLDAP_REMOVE_PROJECT` | Required to take action upon archive (action) of a project. Default True (bool). | yes | yes | +| `PROJECT_OPENLDAP_CONNECT_TIMEOUT` | Connection timeout. | yes | yes | +| `PROJECT_OPENLDAP_USE_SSL` | Use SSL. | yes | yes | +| `PROJECT_OPENLDAP_USE_TLS` | Enable Tls. | yes | yes | +| `PROJECT_OPENLDAP_PRIV_KEY_FILE` | Tls Private key. | yes | yes | +| `PROJECT_OPENLDAP_CERT_FILE` | Tls Certificate file. | yes | yes | +| `PROJECT_OPENLDAP_CACERT_FILE` | Tls CA certificate file. | yes | yes | +| `PROJECT_OPENLDAP_ARCHIVE_OU` | Destination OU for archived projects. | yes | yes | +| `PROJECT_OPENLDAP_DESCRIPTION_TITLE_LENGTH` | Truncates the project title before inserting it into the description LDAP attribute. | yes | yes | +| `PROJECT_OPENLDAP_EXCLUDE_USERS` | Exclude users from sync command. | yes | yes | + +#### Auto Compute Allocation + +| Option | Description | Has Setting | Has Environment Variable | +| :---------------------------------------------------------|:------------|:-------------|:------------------------| +| PLUGIN_AUTO_COMPUTE_ALLOCATION | | no | yes | +| AUTO_COMPUTE_ALLOCATION_CHANGEABLE | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_CLUSTERS | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_CORE_HOURS | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_CORE_HOURS_TRAINING | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_DESCRIPTION | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_END_DELTA | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_LOCKED | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_ACCELERATOR_HOURS_TRAINING | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_FAIRSHARE_INSTITUTION_NAME_FORMAT | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_SLURM_ACCOUNT_NAME_FORMAT | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE | | yes | yes | +| AUTO_COMPUTE_ALLOCATION_SLURM_ATTR_TUPLE_TRAINING | | yes | yes | ## Advanced Configuration From 473ca2c98c5c02e8a67db420e5b4082df9a5828a Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Fri, 5 Dec 2025 08:59:31 -0500 Subject: [PATCH 097/110] Batch commands when syncing FreeIPA. When syncing unix groups with FreeIPA, the group_remove_member and group_add_member commands are submitted to the IPA server separately per user. This patch modifies the FreeIPA sync command to leverage the batch command from the IPA API which can be used to send commands in the same API request. Currently the batch size is hard coded to 100 which should be sufficent. In the future consider making this a CLI arg. See here: https://freeipa.readthedocs.io/en/latest/api/basic_usage.html#batch-operations We have also been experiencing random failures on IPA API calls, for example: ``` ipa: ERROR: connect to 'https://ipa-server/ipa/json': EOF occurred in violation of protocol (_ssl.c:2427) ``` It appears this may be caused by running the IPA API bootstrapping when utils.py was imported instead of right before making an API call. This patch adds a helper function for more control over when to bootstrap the IPA API. Signed-off-by: Andrew E. Bruno --- .../management/commands/freeipa_check.py | 84 ++++++++++++------- .../commands/freeipa_expire_users.py | 4 +- coldfront/plugins/freeipa/tasks.py | 3 + coldfront/plugins/freeipa/utils.py | 17 ++-- 4 files changed, 69 insertions(+), 39 deletions(-) diff --git a/coldfront/plugins/freeipa/management/commands/freeipa_check.py b/coldfront/plugins/freeipa/management/commands/freeipa_check.py index 165b033127..f2240ce6a4 100644 --- a/coldfront/plugins/freeipa/management/commands/freeipa_check.py +++ b/coldfront/plugins/freeipa/management/commands/freeipa_check.py @@ -18,9 +18,7 @@ CLIENT_KTNAME, FREEIPA_NOOP, UNIX_GROUP_ATTRIBUTE_NAME, - AlreadyMemberError, - NotMemberError, - check_ipa_group_error, + ipa_bootstrap, ) logger = logging.getLogger(__name__) @@ -55,16 +53,7 @@ def check_ipa_error(self, res): raise ValueError("Missing FreeIPA result") def add_group(self, user, group, status): - if self.sync and not self.noop: - try: - res = api.Command.group_add_member(group, user=[user.username]) - check_ipa_group_error(res) - except AlreadyMemberError: - logger.warning("User %s is already a member of group %s", user.username, group) - except Exception as e: - logger.error("Failed adding user %s to group %s: %s", user.username, group, e) - else: - logger.info("Added user %s to group %s successfully", user.username, group) + self.ipa_batch_args.append({"method": "group_add_member", "params": [[group], {"user": [user.username]}]}) row = [ "Add", @@ -76,16 +65,7 @@ def add_group(self, user, group, status): self.writerow(row) def remove_group(self, user, group, status): - if self.sync and not self.noop: - try: - res = api.Command.group_remove_member(group, user=[user.username]) - check_ipa_group_error(res) - except NotMemberError: - logger.warning("User %s is not a member of group %s", user.username, group) - except Exception as e: - logger.error("Failed removing user %s from group %s: %s", user.username, group, e) - else: - logger.info("Removed user %s from group %s successfully", user.username, group) + self.ipa_batch_args.append({"method": "group_remove_member", "params": [[group], {"user": [user.username]}]}) row = [ "Remove", @@ -186,6 +166,42 @@ def check_user_freeipa(self, user, active_groups, removed_groups): logger.info("User %s should be removed from freeipa group: %s", user.username, g) self.remove_group(user, g, freeipa_status) + def exec_batch(self): + ipa_bootstrap() + self._set_logging() + batch_args = [] + + for ci, arg in enumerate(self.ipa_batch_args): + if len(batch_args) < self.ipa_batch_size: + batch_args.append(arg) + + if len(batch_args) < self.ipa_batch_size and ci < len(self.ipa_batch_args) - 1: + continue + + result = api.Command.batch(batch_args) + + if len(batch_args) != result["count"]: + logger.error("Result count %d does not match batch size %d", result["count"], len(batch_args)) + if result["count"] > 0: + for ri, res in enumerate(result["results"]): + _res = res.get("result", None) + if "error" not in res or res["error"] is None: + logger.info( + "Success %s for user %s to group %s", + batch_args[ri]["method"], + batch_args[ri]["params"][1]["user"][0], + batch_args[ri]["params"][0][0], + ) + else: + logger.error( + "Failed %s for user %s to group %s: %s", + batch_args[ri]["method"], + batch_args[ri]["params"][1]["user"][0], + batch_args[ri]["params"][0][0], + res["error"], + ) + del batch_args[:] + def process_user(self, user): if self.filter_user and self.filter_user != user.username: return @@ -241,20 +257,25 @@ def process_user(self, user): self.check_user_freeipa(user, active_groups, removed_groups) - def handle(self, *args, **options): - os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME - - verbosity = int(options["verbosity"]) + def _set_logging(self): root_logger = logging.getLogger("") - if verbosity == 0: + if self.verbosity == 0: root_logger.setLevel(logging.ERROR) - elif verbosity == 2: + elif self.verbosity == 2: root_logger.setLevel(logging.INFO) - elif verbosity == 3: + elif self.verbosity == 3: root_logger.setLevel(logging.DEBUG) else: root_logger.setLevel(logging.WARNING) + def handle(self, *args, **options): + os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME + + self.verbosity = int(options["verbosity"]) + self._set_logging() + + self.ipa_batch_args = [] + self.ipa_batch_size = 100 self.noop = FREEIPA_NOOP if options["noop"]: self.noop = True @@ -300,6 +321,9 @@ def handle(self, *args, **options): for user in users: self.process_user(user) + if self.sync and not self.noop: + self.exec_batch() + if self.disable: for user in users: if self.filter_user and self.filter_user != user.username: diff --git a/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py b/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py index 3bd0bcdd09..49992c5a90 100644 --- a/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py +++ b/coldfront/plugins/freeipa/management/commands/freeipa_expire_users.py @@ -14,7 +14,7 @@ from coldfront.core.allocation.models import AllocationUser from coldfront.core.utils.mail import build_link -from coldfront.plugins.freeipa.utils import CLIENT_KTNAME, FREEIPA_NOOP +from coldfront.plugins.freeipa.utils import CLIENT_KTNAME, FREEIPA_NOOP, ipa_bootstrap logger = logging.getLogger(__name__) @@ -119,6 +119,8 @@ def handle(self, *args, **options): "allocation_id": allocation.id, } + ipa_bootstrap() + # Print users whose latest allocation expiration date GTE 365 days and active in FreeIPA for key in expired_allocation_users.keys(): if expired_allocation_users[key]["expire_date"] > expired_365_days_ago: diff --git a/coldfront/plugins/freeipa/tasks.py b/coldfront/plugins/freeipa/tasks.py index 0af4897ce6..1ba930b0a7 100644 --- a/coldfront/plugins/freeipa/tasks.py +++ b/coldfront/plugins/freeipa/tasks.py @@ -16,6 +16,7 @@ AlreadyMemberError, NotMemberError, check_ipa_group_error, + ipa_bootstrap, ) logger = logging.getLogger(__name__) @@ -37,6 +38,7 @@ def add_user_group(allocation_user_pk): return os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME + ipa_bootstrap() for g in groups: if FREEIPA_NOOP: logger.warning( @@ -104,6 +106,7 @@ def remove_user_group(allocation_user_pk): return os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME + ipa_bootstrap() for g in groups: if FREEIPA_NOOP: logger.warning( diff --git a/coldfront/plugins/freeipa/utils.py b/coldfront/plugins/freeipa/utils.py index 0893bf9662..4f5d65247c 100644 --- a/coldfront/plugins/freeipa/utils.py +++ b/coldfront/plugins/freeipa/utils.py @@ -29,14 +29,15 @@ class NotMemberError(ApiError): pass -try: - os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME - api.bootstrap() - api.finalize() - api.Backend.rpcclient.connect() -except Exception as e: - logger.error("Failed to initialze FreeIPA lib: %s", e) - raise ImproperlyConfigured("Failed to initialze FreeIPA: {0}".format(e)) +def ipa_bootstrap(): + try: + os.environ["KRB5_CLIENT_KTNAME"] = CLIENT_KTNAME + api.bootstrap(context="client", in_server=False) + api.finalize() + api.Backend.rpcclient.connect() + except Exception as e: + logger.error("Failed to initialze FreeIPA lib: %s", e) + raise ImproperlyConfigured("Failed to initialze FreeIPA: {0}".format(e)) def check_ipa_group_error(res): From bc47f02525801b0dc641d1a64f9e9e5ec74d0169 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Tue, 9 Dec 2025 20:11:18 -0500 Subject: [PATCH 098/110] Fix aggregation to be compatible with postgres. PR #902 did not work with postgres. This commit fixes that. Signed-off-by: Andrew E. Bruno --- coldfront/core/portal/views.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/coldfront/core/portal/views.py b/coldfront/core/portal/views.py index 7ff0d79a8a..c7295710f0 100644 --- a/coldfront/core/portal/views.py +++ b/coldfront/core/portal/views.py @@ -166,16 +166,18 @@ def center_summary(request): total_grants_by_agency = sorted(total_grants_by_agency, key=operator.itemgetter(1), reverse=True) grants_agency_chart_data = generate_total_grants_by_agency_chart_data(total_grants_by_agency) context["grants_agency_chart_data"] = grants_agency_chart_data - sum_agg = Sum("total_amount_awarded", default=0) - context["grants_total"] = intcomma(int(Grant.objects.aggregate(sum_agg)["total_amount_awarded__sum"])) + sum_agg = Sum(Cast("total_amount_awarded", FloatField()), default=0) + context["grants_total"] = intcomma( + int(Grant.objects.aggregate(total_amount_awarded__sum=sum_agg)["total_amount_awarded__sum"]) + ) context["grants_total_pi_only"] = intcomma( - int(Grant.objects.filter(role="PI").aggregate(sum_agg)["total_amount_awarded__sum"]) + int(Grant.objects.filter(role="PI").aggregate(total_amount_awarded__sum=sum_agg)["total_amount_awarded__sum"]) ) context["grants_total_copi_only"] = intcomma( - int(Grant.objects.filter(role="CoPI").aggregate(sum_agg)["total_amount_awarded__sum"]) + int(Grant.objects.filter(role="CoPI").aggregate(total_amount_awarded__sum=sum_agg)["total_amount_awarded__sum"]) ) context["grants_total_sp_only"] = intcomma( - int(Grant.objects.filter(role="SP").aggregate(sum_agg)["total_amount_awarded__sum"]) + int(Grant.objects.filter(role="SP").aggregate(total_amount_awarded__sum=sum_agg)["total_amount_awarded__sum"]) ) return render(request, "portal/center_summary.html", context) From 23c9b16c458a557fe274ed3baaebe8739195cf01 Mon Sep 17 00:00:00 2001 From: "Andrew E. Bruno" Date: Sun, 7 Dec 2025 22:46:54 -0500 Subject: [PATCH 099/110] Upgrade to Django 5.2 - Remove default_app_config (has been deprecated since 3.2) - Fix admin.action descriptions Signed-off-by: Andrew E. Bruno --- coldfront/core/allocation/admin.py | 9 ++---- coldfront/core/field_of_science/__init__.py | 1 - coldfront/core/project/__init__.py | 1 - coldfront/core/user/__init__.py | 1 - .../auto_compute_allocation/__init__.py | 1 - coldfront/plugins/freeipa/__init__.py | 1 - .../plugins/project_openldap/__init__.py | 1 - coldfront/plugins/slurm/__init__.py | 1 - coldfront/plugins/xdmod/__init__.py | 1 - pyproject.toml | 3 +- uv.lock | 31 ++++++++++++++++--- 11 files changed, 32 insertions(+), 19 deletions(-) diff --git a/coldfront/core/allocation/admin.py b/coldfront/core/allocation/admin.py index 137e9ca2b0..738e629fdc 100644 --- a/coldfront/core/allocation/admin.py +++ b/coldfront/core/allocation/admin.py @@ -370,21 +370,18 @@ def get_inline_instances(self, request, obj=None): else: return super().get_inline_instances(request) + @admin.action(description="Set Selected User's Status To Active") def set_active(self, request, queryset): queryset.update(status=AllocationUserStatusChoice.objects.get(name="Active")) + @admin.action(description="Set Selected User's Status To Denied") def set_denied(self, request, queryset): queryset.update(status=AllocationUserStatusChoice.objects.get(name="Denied")) + @admin.action(description="Set Selected User's Status To Removed") def set_removed(self, request, queryset): queryset.update(status=AllocationUserStatusChoice.objects.get(name="Removed")) - set_active.short_description = "Set Selected User's Status To Active" - - set_denied.short_description = "Set Selected User's Status To Denied" - - set_removed.short_description = "Set Selected User's Status To Removed" - actions = [ set_active, set_denied, diff --git a/coldfront/core/field_of_science/__init__.py b/coldfront/core/field_of_science/__init__.py index 7d532ca4ca..6d24412f63 100644 --- a/coldfront/core/field_of_science/__init__.py +++ b/coldfront/core/field_of_science/__init__.py @@ -2,4 +2,3 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -default_app_config = "coldfront.core.field_of_science.apps.FieldOfScienceConfig" diff --git a/coldfront/core/project/__init__.py b/coldfront/core/project/__init__.py index ce27ea4442..6d24412f63 100644 --- a/coldfront/core/project/__init__.py +++ b/coldfront/core/project/__init__.py @@ -2,4 +2,3 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -default_app_config = "coldfront.core.project.apps.ProjectConfig" diff --git a/coldfront/core/user/__init__.py b/coldfront/core/user/__init__.py index 2782e42058..6d24412f63 100644 --- a/coldfront/core/user/__init__.py +++ b/coldfront/core/user/__init__.py @@ -2,4 +2,3 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -default_app_config = "coldfront.core.user.apps.UserConfig" diff --git a/coldfront/plugins/auto_compute_allocation/__init__.py b/coldfront/plugins/auto_compute_allocation/__init__.py index 66a388a382..6d24412f63 100644 --- a/coldfront/plugins/auto_compute_allocation/__init__.py +++ b/coldfront/plugins/auto_compute_allocation/__init__.py @@ -2,4 +2,3 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -default_app_config = "coldfront.plugins.auto_compute_allocation.apps.AutoComputeAllocationConfig" diff --git a/coldfront/plugins/freeipa/__init__.py b/coldfront/plugins/freeipa/__init__.py index ac9048af25..6d24412f63 100644 --- a/coldfront/plugins/freeipa/__init__.py +++ b/coldfront/plugins/freeipa/__init__.py @@ -2,4 +2,3 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -default_app_config = "coldfront.plugins.freeipa.apps.IPAConfig" diff --git a/coldfront/plugins/project_openldap/__init__.py b/coldfront/plugins/project_openldap/__init__.py index fae6df9f02..6d24412f63 100644 --- a/coldfront/plugins/project_openldap/__init__.py +++ b/coldfront/plugins/project_openldap/__init__.py @@ -2,4 +2,3 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -default_app_config = "coldfront.plugins.project_openldap.apps.ProjectOpenldapConfig" diff --git a/coldfront/plugins/slurm/__init__.py b/coldfront/plugins/slurm/__init__.py index e9dcec9ec6..6d24412f63 100644 --- a/coldfront/plugins/slurm/__init__.py +++ b/coldfront/plugins/slurm/__init__.py @@ -2,4 +2,3 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -default_app_config = "coldfront.plugins.slurm.apps.SlurmConfig" diff --git a/coldfront/plugins/xdmod/__init__.py b/coldfront/plugins/xdmod/__init__.py index 5c255d4a61..6d24412f63 100644 --- a/coldfront/plugins/xdmod/__init__.py +++ b/coldfront/plugins/xdmod/__init__.py @@ -2,4 +2,3 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later -default_app_config = "coldfront.plugins.xdmod.apps.XdmodConfig" diff --git a/pyproject.toml b/pyproject.toml index ffcdf3cb3c..b0e60036ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "crispy-bootstrap4>=2024.10", - "django>4.2,<5", + "django>5.2,<6", "django-crispy-forms>=2.3", "django-environ>=0.12.0", "django-filter>=25.1", @@ -82,6 +82,7 @@ pg = [ [dependency-groups] dev = [ "django-sslserver>=0.22", + "django-upgrade>=1.29.1", "factory-boy>=3.3.3", "faker>=37.1.0", "pytest-django>=4.11.1", diff --git a/uv.lock b/uv.lock index 00e7521f7e..8f20d04144 100644 --- a/uv.lock +++ b/uv.lock @@ -304,6 +304,7 @@ pg = [ [package.dev-dependencies] dev = [ { name = "django-sslserver" }, + { name = "django-upgrade" }, { name = "factory-boy" }, { name = "faker" }, { name = "pytest-django" }, @@ -324,7 +325,7 @@ requires-dist = [ { name = "crispy-bootstrap4", specifier = ">=2024.10" }, { name = "cryptography", marker = "extra == 'freeipa'", specifier = "==43.0.3" }, { name = "dbus-python", marker = "extra == 'freeipa'", specifier = ">=1.4.0" }, - { name = "django", specifier = ">4.2,<5" }, + { name = "django", specifier = ">5.2,<6" }, { name = "django-auth-ldap", marker = "extra == 'ldap'", specifier = ">=5.1.0" }, { name = "django-crispy-forms", specifier = ">=2.3" }, { name = "django-environ", specifier = ">=0.12.0" }, @@ -356,6 +357,7 @@ provides-extras = ["ldap", "freeipa", "iquota", "oidc", "mysql", "pg"] [package.metadata.requires-dev] dev = [ { name = "django-sslserver", specifier = ">=0.22" }, + { name = "django-upgrade", specifier = ">=1.29.1" }, { name = "factory-boy", specifier = ">=3.3.3" }, { name = "faker", specifier = ">=37.1.0" }, { name = "pytest-django", specifier = ">=4.11.1" }, @@ -443,16 +445,16 @@ wheels = [ [[package]] name = "django" -version = "4.2.27" +version = "5.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/ff/6aa5a94b85837af893ca82227301ac6ddf4798afda86151fb2066d26ca0a/django-4.2.27.tar.gz", hash = "sha256:b865fbe0f4a3d1ee36594c5efa42b20db3c8bbb10dff0736face1c6e4bda5b92", size = 10432781, upload-time = "2025-12-02T14:01:49.006Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/f5/1a2319cc090870bfe8c62ef5ad881a6b73b5f4ce7330c5cf2cb4f9536b12/django-4.2.27-py3-none-any.whl", hash = "sha256:f393a394053713e7d213984555c5b7d3caeee78b2ccb729888a0774dff6c11a8", size = 7995090, upload-time = "2025-12-02T14:01:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, ] [[package]] @@ -588,6 +590,18 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/d3/cf/5d5bdaff569468dba3053a7a623f64fcf5e36d5a936a5617a1c1972a7da4/django-su-1.0.0.tar.gz", hash = "sha256:1a3f98b2f757a3f47e33e90047c0a81cf965805fd7f91f67089292bdd461bd1a", size = 23677, upload-time = "2022-04-01T14:56:01.013Z" } +[[package]] +name = "django-upgrade" +version = "1.29.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tokenize-rt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/dc/8242d9fbf0ae64feccbdd781fa741db5cf0521127a26ed9361e5f2f31f1b/django_upgrade-1.29.1.tar.gz", hash = "sha256:8c53b6bcd326a638a5dc908a707f26d71593bad5789b33775c90a8dc8a76afd5", size = 39638, upload-time = "2025-10-23T16:34:57.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/b0/c9fe7b4dbfcacb2402169f8189d4f7fa1609237317d714f01143d167db1c/django_upgrade-1.29.1-py3-none-any.whl", hash = "sha256:39a4d71365189ce8a5ccab534b2d1f0ed69c71bea7f1ef4c46041918d5247e64", size = 68847, upload-time = "2025-10-23T16:34:55.511Z" }, +] + [[package]] name = "djangorestframework" version = "3.16.1" @@ -1511,6 +1525,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, ] +[[package]] +name = "tokenize-rt" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/ed/8f07e893132d5051d86a553e749d5c89b2a4776eb3a579b72ed61f8559ca/tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6", size = 5476, upload-time = "2025-05-23T23:48:00.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44", size = 6004, upload-time = "2025-05-23T23:47:58.812Z" }, +] + [[package]] name = "tomli" version = "2.3.0" From 4616b2cc41798a69bbf980b5347bf0d9882684f3 Mon Sep 17 00:00:00 2001 From: Cecilia Lau Date: Wed, 23 Jul 2025 12:18:02 -0400 Subject: [PATCH 100/110] add coldfront core setting for PROJECT_UPDATE_FIELDS This allows for center directors to configure which Project fields project managers are able to edit. Signed-off-by: Cecilia Lau --- coldfront/config/core.py | 13 +++++++++++++ coldfront/core/project/views.py | 14 +++++++++----- docs/pages/config.md | 1 + 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/coldfront/config/core.py b/coldfront/config/core.py index bdfa8356d3..7a9a8e438b 100644 --- a/coldfront/config/core.py +++ b/coldfront/config/core.py @@ -131,3 +131,16 @@ # ------------------------------------------------------------------------------ PROJECT_INSTITUTION_EMAIL_MAP = ENV.dict("PROJECT_INSTITUTION_EMAIL_MAP", default={}) + +# ------------------------------------------------------------------------------ +# Configure Project fields that project managers can update +# ------------------------------------------------------------------------------ + +PROJECT_UPDATE_FIELDS = ENV.list( + "PROJECT_UPDATE_FIELDS", + default=[ + "title", + "description", + "field_of_science", + ], +) diff --git a/coldfront/core/project/views.py b/coldfront/core/project/views.py index ab403844df..e282367683 100644 --- a/coldfront/core/project/views.py +++ b/coldfront/core/project/views.py @@ -76,6 +76,14 @@ PROJECT_CODE = import_from_settings("PROJECT_CODE", False) PROJECT_CODE_PADDING = import_from_settings("PROJECT_CODE_PADDING", False) +PROJECT_UPDATE_FIELDS = import_from_settings( + "PROJECT_UPDATE_FIELDS", + [ + "title", + "description", + "field_of_science", + ], +) logger = logging.getLogger(__name__) PROJECT_INSTITUTION_EMAIL_MAP = import_from_settings("PROJECT_INSTITUTION_EMAIL_MAP", False) @@ -611,11 +619,7 @@ def form_valid(self, form): class ProjectUpdateView(SuccessMessageMixin, LoginRequiredMixin, UserPassesTestMixin, UpdateView): model = Project template_name_suffix = "_update_form" - fields = [ - "title", - "description", - "field_of_science", - ] + fields = PROJECT_UPDATE_FIELDS success_message = "Project updated." def test_func(self): diff --git a/docs/pages/config.md b/docs/pages/config.md index 485feb8315..3e39363b31 100644 --- a/docs/pages/config.md +++ b/docs/pages/config.md @@ -125,6 +125,7 @@ The following settings are ColdFront specific settings related to the core appli | PROJECT_CODE | Specifies a custom internal project identifier. Default False, provide string value to enable. Must be no longer than 10 - PROJECT_CODE_PADDING characters in length. | yes | yes | | PROJECT_CODE_PADDING | Defines a optional padding value to be added before the Primary Key section of PROJECT_CODE. Default False, provide integer value to enable. | yes | yes | | PROJECT_INSTITUTION_EMAIL_MAP | Defines a dictionary where PI domain email addresses are keys and their corresponding institutions are values. Default is False, provide key-value pairs to enable this feature. | yes | yes | +| PROJECT_UPDATE_FIELDS | Defines a list of Project fields that project managers are able to update. Default is ['title', 'description', 'field_of_science']. | yes | yes | | ACCOUNT_CREATION_TEXT | | yes | yes | | ADDITIONAL_USER_SEARCH_CLASSES | | yes | no | | ADMIN_COMMENTS_SHOW_EMPTY | | no | yes | From 907885b892d57c52164e56cfda5fad4f7920b897 Mon Sep 17 00:00:00 2001 From: Cecilia Lau Date: Fri, 12 Dec 2025 11:13:27 -0500 Subject: [PATCH 101/110] UI Fixes Squashed commits: - fix project notifs - fix allocation change detail page button spacing - fix allocation change request edit button visibility - add request change button to allocation change requests section - formatting - make test pass - Add title for grant/research output pagees - Add "Creating/updating grant/research output for project: " to the appropriate pages. - Update button icons - Add space between folder icon and project title on home page - Fix select all for add user to project - Merge branch 'main' into ui_fixes - fix allocation change detail button margins Signed-off-by: Cecilia Lau --- .../allocation/allocation_change_detail.html | 25 ++++++++----------- .../allocation/allocation_detail.html | 11 +++++++- .../allocation/allocation_remove_users.html | 2 +- coldfront/core/allocation/views.py | 6 +++++ .../grant/templates/grant/grant_create.html | 1 + .../templates/grant/grant_update_form.html | 1 + .../templates/portal/authorized_home.html | 2 +- .../templates/project/project_detail.html | 8 +++--- coldfront/core/project/views.py | 3 +++ ...ication_add_publication_search_result.html | 2 +- .../research_output_create.html | 1 + 11 files changed, 39 insertions(+), 23 deletions(-) diff --git a/coldfront/core/allocation/templates/allocation/allocation_change_detail.html b/coldfront/core/allocation/templates/allocation/allocation_change_detail.html index e7e1489aeb..497094de8b 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_change_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_change_detail.html @@ -184,20 +184,17 @@

Alloc

Actions

- - - {% csrf_token %} - {{note_form.notes | as_crispy_field}} -
- {% if allocation_change.status.name == 'Pending' %} - - - {% endif %} - -
-
+ {% csrf_token %} + {{note_form.notes | as_crispy_field}} +
+ {% if allocation_change.status.name == 'Pending' %} + + + {% endif %} + +
{% endif %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_detail.html b/coldfront/core/allocation/templates/allocation/allocation_detail.html index 594eff08a6..b8076a8d1c 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_detail.html +++ b/coldfront/core/allocation/templates/allocation/allocation_detail.html @@ -281,6 +281,13 @@

{{attribute}}

Allocation Change Requests

{{allocation_changes.count}} +
+ {% if request.user.is_superuser and allocation.is_changeable and not allocation.is_locked and is_allowed_to_update_project and allocation.status.name in 'Active, Renewal Requested, Payment Pending, Payment Requested, Paid' %} + + Request Change + + {% endif %} +
@@ -311,7 +318,9 @@

Alloc {% else %} {% endif %} - Edit + {% if can_edit_allocation_changes %} + Edit + {% endif %} {% endfor %} diff --git a/coldfront/core/allocation/templates/allocation/allocation_remove_users.html b/coldfront/core/allocation/templates/allocation/allocation_remove_users.html index 3fe8016d75..2097e4fca5 100644 --- a/coldfront/core/allocation/templates/allocation/allocation_remove_users.html +++ b/coldfront/core/allocation/templates/allocation/allocation_remove_users.html @@ -48,7 +48,7 @@

Remove users from allocation for project: {{allocation.project.title}}

{{ formset.management_form }}
Back to Allocation diff --git a/coldfront/core/allocation/views.py b/coldfront/core/allocation/views.py index d863364aee..088095325c 100644 --- a/coldfront/core/allocation/views.py +++ b/coldfront/core/allocation/views.py @@ -183,6 +183,12 @@ def get_context_data(self, **kwargs): context["is_allowed_to_update_project"] = allocation_obj.project.has_perm( self.request.user, ProjectPermission.UPDATE ) + # Can the user edit allocation change requests? + # condition was taken from core.allocation.views.AllocationChangeDetailView; + # maybe better to make a static method that test_func() in that class will call? + context["can_edit_allocation_changes"] = self.request.user.has_perm( + "allocation.can_view_all_allocations" + ) or allocation_obj.has_perm(self.request.user, AllocationPermission.MANAGER) noteset = allocation_obj.allocationusernote_set.select_related("author") notes = noteset.all() if self.request.user.is_superuser else noteset.filter(is_private=False) diff --git a/coldfront/core/grant/templates/grant/grant_create.html b/coldfront/core/grant/templates/grant/grant_create.html index c44aa4ba93..2961a6d1c4 100644 --- a/coldfront/core/grant/templates/grant/grant_create.html +++ b/coldfront/core/grant/templates/grant/grant_create.html @@ -10,6 +10,7 @@ {% block content %} +

Creating grant for project: {{ project.title }}

{% csrf_token %} {{ form|crispy }} diff --git a/coldfront/core/grant/templates/grant/grant_update_form.html b/coldfront/core/grant/templates/grant/grant_update_form.html index c3fec5ee4b..3887ab8899 100644 --- a/coldfront/core/grant/templates/grant/grant_update_form.html +++ b/coldfront/core/grant/templates/grant/grant_update_form.html @@ -9,6 +9,7 @@ {% block content %} +

Updating grant for project: {{ project.title }}

{% csrf_token %} {{ form|crispy }} diff --git a/coldfront/core/portal/templates/portal/authorized_home.html b/coldfront/core/portal/templates/portal/authorized_home.html index 72bb046878..3c2ba90e73 100644 --- a/coldfront/core/portal/templates/portal/authorized_home.html +++ b/coldfront/core/portal/templates/portal/authorized_home.html @@ -13,7 +13,7 @@

Projects »