From 836a9bc3a35c8e8b2377d07e674cebb421dbb433 Mon Sep 17 00:00:00 2001 From: Colin Copeland Date: Fri, 10 Jan 2025 11:08:34 -0500 Subject: [PATCH] publish forms --- apps/odk_publish/etl/load.py | 2 +- apps/odk_publish/etl/odk/client.py | 7 +++- apps/odk_publish/etl/odk/publish.py | 64 ++++++++++++++++++++++++++--- apps/odk_publish/models.py | 21 +++++++--- apps/odk_publish/tables.py | 1 + apps/odk_publish/views.py | 9 ++-- 6 files changed, 86 insertions(+), 18 deletions(-) diff --git a/apps/odk_publish/etl/load.py b/apps/odk_publish/etl/load.py index ebdd86b..1cd7731 100644 --- a/apps/odk_publish/etl/load.py +++ b/apps/odk_publish/etl/load.py @@ -22,7 +22,7 @@ def create_or_update_app_users(form_template: FormTemplate): ) # Link form assignments to app users locally for app_user_form in app_user_forms: - app_users[app_user_form.app_user.name].form_ids.append(app_user_form.form_id) + app_users[app_user_form.app_user.name].xml_form_ids.append(app_user_form.xml_form_id) # Create or update the form assignments on the server client.odk_publish.assign_forms( app_users=app_users.values(), project_id=form_template.project.project_id diff --git a/apps/odk_publish/etl/odk/client.py b/apps/odk_publish/etl/odk/client.py index 075f4b2..2cce2b7 100644 --- a/apps/odk_publish/etl/odk/client.py +++ b/apps/odk_publish/etl/odk/client.py @@ -18,7 +18,7 @@ class ODKPublishClient(Client): """Extended pyODK Client for interacting with ODK Central.""" - def __init__(self, base_url: str): + def __init__(self, base_url: str, project_id: int | None = None): """Create an ODK Central-configured client without a config file.""" # Create stub config file if it doesn't exist, so that pyodk doesn't complain config_path = Path("/tmp/.pyodk_config.toml") @@ -33,7 +33,7 @@ def __init__(self, base_url: str): password=settings.ODK_CENTRAL_PASSWORD, cache_path=None, ) - super().__init__(config_path=config_path, session=session) + super().__init__(config_path=config_path, session=session, project_id=project_id) # Update the stub config with the environment-provided authentication # details self.config: config.Config = config.objectify_config( @@ -48,3 +48,6 @@ def __init__(self, base_url: str): # Create a ODK Publish service for this client, which provides # additional functionality for interacting with ODK Central self.odk_publish: PublishService = PublishService(client=self) + + def __enter__(self) -> "ODKPublishClient": + return self.open() diff --git a/apps/odk_publish/etl/odk/publish.py b/apps/odk_publish/etl/odk/publish.py index 4ead75e..516c078 100644 --- a/apps/odk_publish/etl/odk/publish.py +++ b/apps/odk_publish/etl/odk/publish.py @@ -1,8 +1,10 @@ +from os import PathLike from typing import TYPE_CHECKING import structlog from pyodk._endpoints import bases from pyodk._endpoints.form_assignments import FormAssignmentService +from pyodk._endpoints.forms import Form from pyodk._endpoints.project_app_users import ProjectAppUser, ProjectAppUserService from pyodk.errors import PyODKError @@ -18,7 +20,7 @@ class ProjectAppUserAssignment(ProjectAppUser): """Extended ProjectAppUser with additional form_ids attribute.""" - form_ids: list[str] = [] + xml_form_ids: list[str] = [] class PublishService(bases.Service): @@ -39,7 +41,9 @@ def __init__(self, client: "ODKPublishClient"): def get_app_users( self, project_id: int, display_names: list[str] = None ) -> dict[str, ProjectAppUserAssignment]: - """Return a mapping of display names to ProjectAppUserAssignments for the given project.""" + """Return a mapping of display names to ProjectAppUserAssignments for + the given project, filtered by display names if provided. + """ app_users = { user.displayName: ProjectAppUserAssignment(**dict(user)) for user in self.project_users.list(project_id=project_id) @@ -69,22 +73,70 @@ def get_or_create_app_users( logger.debug("Retrieved app users", users=list(app_users.keys()), project_id=project_id) return app_users + def get_forms(self, project_id: int | None = None) -> dict[str, Form]: + """Return a mapping of form IDs to Form objects for the given project.""" + forms = self.client.forms.list(project_id=project_id) + return {form.xmlFormId: form for form in forms} + + def create_or_update_form( + self, + xml_form_id: str, + definition: PathLike | bytes, + attachments: list[PathLike | bytes] | None = None, + project_id: int | None = None, + ) -> Form: + """Return forms for the given form IDs, creating them if they don't exist.""" + central_forms = self.get_forms(project_id=project_id) + # Updated an existing form if it exists + if xml_form_id in central_forms: + self.client.forms.update( + form_id=xml_form_id, + definition=definition, + attachments=attachments, + project_id=project_id, + ) + # Retrieve updated form to get the version + form = self.client.forms.get(form_id=xml_form_id, project_id=project_id) + logger.info( + "Updated form", + project_id=form.projectId, + xml_form_id=form.xmlFormId, + version=form.version, + name=form.name, + ) + else: + # Create form if it doesn't exist + form = self.client.forms.create( + form_id=xml_form_id, + definition=definition, + attachments=attachments, + project_id=project_id, + ) + logger.info( + "Created form", + project_id=form.projectId, + xml_form_id=form.xmlFormId, + version=form.version, + name=form.name, + ) + return form + def assign_app_users_forms( self, app_users: list[ProjectAppUserAssignment], project_id: int ) -> None: """Assign forms to app users.""" for app_user in app_users: - for form_id in app_user.form_ids: + for xml_form_id in app_user.form_ids: try: self.form_assignments.assign( role_id=APP_USER_ROLE_ID, user_id=app_user.id, - form_id=form_id, + form_id=xml_form_id, project_id=project_id, ) logger.debug( "Assigned form", - form_id=form_id, + form_id=xml_form_id, app_user=app_user.displayName, project_id=project_id, ) @@ -93,7 +145,7 @@ def assign_app_users_forms( raise logger.debug( "Form already assigned", - form_id=form_id, + form_id=xml_form_id, app_user=app_user.displayName, project_id=project_id, ) diff --git a/apps/odk_publish/models.py b/apps/odk_publish/models.py index e725a23..7daa5bb 100644 --- a/apps/odk_publish/models.py +++ b/apps/odk_publish/models.py @@ -100,7 +100,9 @@ def create_next_version(self, user: User) -> "FormTemplateVersion": 2. Download the Google Sheet Excel file for this form template. 3. Create a new FormTemplateVersion instance with the downloaded file. """ - with ODKPublishClient(base_url=self.project.central_server.base_url) as client: + with ODKPublishClient( + base_url=self.project.central_server.base_url, project_id=self.project.project_id + ) as client: version = get_unique_version_by_form_id( client=client, project_id=self.project.project_id, form_id_base=self.form_id_base ) @@ -109,7 +111,12 @@ def create_next_version(self, user: User) -> "FormTemplateVersion": version = FormTemplateVersion.objects.create( form_template=self, user=user, file=file, version=version ) - version.create_app_user_versions() + app_user_versions = version.create_app_user_versions() + for app_user_version in app_user_versions: + client.odk_publish.create_or_update_form( + xml_form_id=app_user_version.app_user_form_template.xml_form_id, + definition=app_user_version.file.read(), + ) return version @@ -131,10 +138,12 @@ class Meta: def __str__(self): return self.file.name - def create_app_user_versions(self): + def create_app_user_versions(self) -> list["AppUserFormVersion"]: + app_user_versions = [] for app_user_form in AppUserFormTemplate.objects.filter(form_template=self.form_template): logger.info("Creating next AppUserFormVersion", app_user_form=app_user_form) - app_user_form.create_next_version(form_template_version=self) + app_user_versions.append(app_user_form.create_next_version(form_template_version=self)) + return app_user_versions class AppUserTemplateVariable(AbstractBaseModel): @@ -203,8 +212,8 @@ def __str__(self): return f"{self.app_user} - {self.form_template}" @property - def form_id(self) -> str: - """The ODK Central form_id for this AppUserFormTemplate.""" + def xml_form_id(self) -> str: + """The ODK Central xmlFormId for this AppUserFormTemplate.""" return f"{self.form_template.form_id_base}_{self.app_user.name}" def create_next_version(self, form_template_version: FormTemplateVersion): diff --git a/apps/odk_publish/tables.py b/apps/odk_publish/tables.py index 058bf4c..48ee3c9 100644 --- a/apps/odk_publish/tables.py +++ b/apps/odk_publish/tables.py @@ -19,6 +19,7 @@ class FormTemplateTable(tables.Table): text="Publish New Version", orderable=False, verbose_name="Actions", + attrs={"a": {"class": "text-primary-600 hover:underline"}}, ) class Meta: diff --git a/apps/odk_publish/views.py b/apps/odk_publish/views.py index b28457a..8a1cbe1 100644 --- a/apps/odk_publish/views.py +++ b/apps/odk_publish/views.py @@ -2,12 +2,12 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.db import models +from django.db import models, transaction from django.http import HttpRequest from django.shortcuts import get_object_or_404, redirect, render from .etl.load import generate_and_save_app_user_collect_qrcodes -from .models import FormTemplateVersion +from .models import FormTemplateVersion, FormTemplate from .nav import Breadcrumbs from .tables import FormTemplateTable @@ -58,8 +58,11 @@ def form_template_list(request: HttpRequest, odk_project_pk): @login_required +@transaction.atomic def form_template_publish_next_version(request: HttpRequest, odk_project_pk, form_template_id): - form_template = get_object_or_404(request.odk_project.form_templates, pk=form_template_id) + form_template: FormTemplate = get_object_or_404( + request.odk_project.form_templates, pk=form_template_id + ) version = form_template.create_next_version(user=request.user) messages.add_message(request, messages.SUCCESS, f"{version} published.") return redirect("odk_publish:form-template-list", odk_project_pk=odk_project_pk)