From e6014d0d523d88fff095794bfe6b33b7bfcdebdf Mon Sep 17 00:00:00 2001 From: "Sean R. Abraham" Date: Wed, 26 Feb 2025 08:59:28 -0500 Subject: [PATCH] DO NOT MERGE - file upload progress checkin --- pyproject.toml | 2 + src/ims/application/_api.py | 107 ++++++++++ src/ims/config/_config.py | 9 + src/ims/config/_urls.py | 4 + src/ims/config/test/test_config.py | 1 + .../incident/incident_template/template.xhtml | 27 ++- src/ims/element/static/ims.js | 23 ++- src/ims/element/static/incident.js | 33 +++ src/ims/store/_db.py | 8 + src/ims/store/mysql/_store.py | 2 +- src/ims/store/mysql/schema/10-from-9.mysql | 9 + src/ims/store/mysql/schema/10.mysql | 163 +++++++++++++++ src/ims/store/mysql/test/test_store_core.py | 3 +- src/ims/store/sqlite/_store.py | 2 +- src/ims/store/sqlite/schema/7-from-6.sqlite | 9 + src/ims/store/sqlite/schema/7.sqlite | 193 ++++++++++++++++++ src/ims/store/sqlite/test/test_store_core.py | 3 +- uv.lock | 22 ++ 18 files changed, 605 insertions(+), 15 deletions(-) create mode 100644 src/ims/store/mysql/schema/10-from-9.mysql create mode 100644 src/ims/store/mysql/schema/10.mysql create mode 100644 src/ims/store/sqlite/schema/7-from-6.sqlite create mode 100644 src/ims/store/sqlite/schema/7.sqlite diff --git a/pyproject.toml b/pyproject.toml index 614030ca4..84a36bee2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "hyperlink==21.0.0", "jwcrypto==1.5.6", "klein==24.8.0", + "multipart>=1.2.1", + "puremagic>=1.28", "pymysql==1.1.1", "pyopenssl==25.0.0", "pyyaml==6.0.2", diff --git a/src/ims/application/_api.py b/src/ims/application/_api.py index 759f0d14e..0be0165de 100644 --- a/src/ims/application/_api.py +++ b/src/ims/application/_api.py @@ -31,17 +31,22 @@ from functools import partial from json import JSONDecodeError from typing import Any, ClassVar, cast +from uuid import uuid4 from attrs import frozen from hyperlink import URL from klein import KleinRenderable from klein._app import KleinSynchronousRenderable +from multipart import MultipartParser, parse_options_header +from puremagic import PureError +from puremagic import from_string as puremagic_from_string from twisted.internet.defer import Deferred from twisted.internet.error import ConnectionDone from twisted.logger import Logger from twisted.python.failure import Failure from twisted.web import http from twisted.web.iweb import IRequest +from twisted.web.static import File from ims.auth import Authorization, NotAuthorizedError from ims.config import Configuration, URLs @@ -684,6 +689,108 @@ def _cast(obj: Any) -> Any: return noContentResponse(request) + @router.route(_unprefix(URLs.incidentAttachments), methods=("POST",)) + async def attachFileToIncident( + self, + request: IRequest, + event_id: str, + incident_number: str, + ) -> KleinSynchronousRenderable: + eventId = event_id + incidentNumber = int(incident_number) + del event_id + del incident_number + + await self.config.authProvider.authorizeRequest( + request, eventId, Authorization.writeIncidents + ) + + _, options = parse_options_header(request.getHeader("Content-Type")) + p = MultipartParser( + request.content, + options.get("boundary", ""), + # Only allow one file per upload. This fits better with + # the REPORT_ENTRY model. + part_limit=1, + # TODO: move this to config + # Arbitrary 10 MB per file limit + partsize_limit=10 * 1024 * 1024, + ) + + parts = p.parts() + if len(parts) == 0: + # no files provided, nothing to do + return noContentResponse(request) + if len(parts) > 1: + return badRequestResponse(request, "Only one file is allowed per request") + part = parts[0] + + store = self.config.store + + eventsDir = self.config.attachmentsRoot / "events" + secretFilename = uuid4() + try: + extension = puremagic_from_string(part.raw, filename=part.filename) + except PureError: + self._log.info(f"failed to determine filetype for {part.filename}") + extension = "" + else: + self._log.info(f"detected file extension {extension} for {part.filename}") + + incidentDir = eventsDir / eventId / "incidents" / str(incidentNumber) + incidentDir.mkdir(parents=True, exist_ok=True) + dest = incidentDir / f"{secretFilename}{extension}" + part.save_as(dest) + self._log.info(f"saved {part.filename} as {dest}") + + user: IMSUser = request.user # type: ignore[attr-defined] + author = user.shortNames[0] + + now = DateTime.now(UTC) + + entry = ReportEntry( + id=-1, # will be assigned a valid ID on write to DB + author=author, + text=f"User uploaded file: {part.filename}", + created=now, + automatic=False, + stricken=False, + ) + + await store.addReportEntriesToIncident( + eventId, incidentNumber, (entry,), author + ) + return noContentResponse(request) + + @router.route(_unprefix(URLs.incidentAttachmentNumber), methods=("HEAD", "GET")) + async def retrieveIncidentAttachment( + self, + request: IRequest, + event_id: str, + incident_number: str, + attachment_number: str, + ) -> KleinSynchronousRenderable: + eventId = event_id + incidentNumber = int(incident_number) + del event_id + del incident_number + + await self.config.authProvider.authorizeRequest( + request, eventId, Authorization.readIncidents + ) + + # TODO: will need to look through report entries to get the secret file name + + folder = ( + self.config.attachmentsRoot + / "events" + / eventId + / "incidents" + / str(incidentNumber) + ) + + return File(folder / "b982735c-3165-43b7-8bd4-6724eab141a6.pdf") + @router.route(_unprefix(URLs.incident_reportEntry), methods=("POST",)) async def editIncidentReportEntryResource( self, diff --git a/src/ims/config/_config.py b/src/ims/config/_config.py index 268d17bfa..a5bde590e 100644 --- a/src/ims/config/_config.py +++ b/src/ims/config/_config.py @@ -226,6 +226,12 @@ def fromConfigFile(cls, configFile: Path | None) -> "Configuration": cachedResourcesRoot.mkdir(exist_ok=True) cls._log.info("CachedResources: {path}", path=cachedResourcesRoot) + attachmentsRoot = parser.pathFromConfig( + "ATTACHMENTS_ROOT", "Core", "AttachmentsRoot", serverRoot, ("attachments",) + ) + attachmentsRoot.mkdir(parents=True, exist_ok=True) + cls._log.info("AttachmentsRoot: {path}", path=attachmentsRoot) + logLevelName = parser.valueFromConfig("LOG_LEVEL", "Core", "LogLevel", "info") cls._log.info("LogLevel: {logLevel}", logLevel=logLevelName) @@ -393,6 +399,7 @@ def fromConfigFile(cls, configFile: Path | None) -> "Configuration": # return cls( + attachmentsRoot=attachmentsRoot, cachedResourcesRoot=cachedResourcesRoot, configFile=configFile, configRoot=configRoot, @@ -412,6 +419,7 @@ def fromConfigFile(cls, configFile: Path | None) -> "Configuration": tokenLifetime=tokenLifetime, ) + attachmentsRoot: Path cachedResourcesRoot: Path configFile: Path | None configRoot: Path @@ -467,6 +475,7 @@ def __str__(self) -> str: f"Core.Port: {self.port}\n" f"\n" f"Core.ServerRoot: {self.serverRoot}\n" + f"Core.AttachmentsRoot: {self.attachmentsRoot}\n" f"Core.ConfigRoot: {self.configRoot}\n" f"Core.DataRoot: {self.dataRoot}\n" f"Core.CachedResources: {self.cachedResourcesRoot}\n" diff --git a/src/ims/config/_urls.py b/src/ims/config/_urls.py index 3c5d0a841..51b80cf01 100644 --- a/src/ims/config/_urls.py +++ b/src/ims/config/_urls.py @@ -58,6 +58,10 @@ class URLs: incident_reportEntry: ClassVar[URL] = incident_reportEntries.child( "" ) + incidentAttachments: ClassVar[URL] = incidentNumber.child("attachments").child("") + incidentAttachmentNumber: ClassVar[URL] = incidentAttachments.child( + "" + ) fieldReports: ClassVar[URL] = event.child("field_reports").child("") fieldReport: ClassVar[URL] = fieldReports.child("") fieldReport_reportEntries: ClassVar[URL] = fieldReport.child("report_entries") diff --git a/src/ims/config/test/test_config.py b/src/ims/config/test/test_config.py index 644067661..52ab1eaaf 100644 --- a/src/ims/config/test/test_config.py +++ b/src/ims/config/test/test_config.py @@ -749,6 +749,7 @@ def test_str(self) -> None: f"Core.Port: {config.port}\n" f"\n" f"Core.ServerRoot: {config.serverRoot}\n" + f"Core.AttachmentsRoot: {config.attachmentsRoot}\n" f"Core.ConfigRoot: {config.configRoot}\n" f"Core.DataRoot: {config.dataRoot}\n" f"Core.CachedResources: {config.cachedResourcesRoot}\n" diff --git a/src/ims/element/incident/incident_template/template.xhtml b/src/ims/element/incident/incident_template/template.xhtml index 96688600e..d664c0097 100644 --- a/src/ims/element/incident/incident_template/template.xhtml +++ b/src/ims/element/incident/incident_template/template.xhtml @@ -253,14 +253,25 @@ autofocus="" onchange="reportEntryEdited()" /> - +
+ + + + + +
diff --git a/src/ims/element/static/ims.js b/src/ims/element/static/ims.js index 4d8c6b04d..cafd24b1d 100644 --- a/src/ims/element/static/ims.js +++ b/src/ims/element/static/ims.js @@ -188,9 +188,15 @@ async function fetchJsonNoThrow(url, init) { init["headers"]["Accept"] = "application/json"; if ("body" in init) { init["method"] = "POST"; - init["headers"]["Content-Type"] = "application/json"; - if (typeof(init["body"]) !== "string") { - init["body"] = JSON.stringify(init["body"]); + + if (init["body"].constructor.name === "FormData") { + // don't JSONify, don't set a Content-Type (fetch does it automatically) + } else { + // otherwise assume body is supposed to be json + init["headers"]["Content-Type"] = "application/json"; + if (typeof init["body"] !== "string") { + init["body"] = JSON.stringify(init["body"]); + } } } let response = null; @@ -277,6 +283,8 @@ function enable(element) { // Disable editing for an element function disableEditing() { disable($(".form-control")); + disable($("#entries-form :input")); + disable($("#attach-file-form :input")); enable($(":input[type=search]")); // Don't disable search fields $(document.documentElement).addClass("no-edit"); } @@ -285,9 +293,18 @@ function disableEditing() { // Enable editing for an element function enableEditing() { enable($(".form-control")); + enable($("#entries-form :input")); + enable($("#attach-file-form :input")); $(document.documentElement).removeClass("no-edit"); } +function hide(element) { + element.addClass("hidden"); +} + +function unhide(element) { + element.removeClass("hidden"); +} // Add an error indication to a control function controlHasError(element) { diff --git a/src/ims/element/static/incident.js b/src/ims/element/static/incident.js index a71bdabf5..b7acf11b4 100644 --- a/src/ims/element/static/incident.js +++ b/src/ims/element/static/incident.js @@ -188,6 +188,10 @@ async function loadAndDisplayIncident() { if (editingAllowed) { enableEditing(); } + + if (incident.number == null) { + hide($("#attach-file-form :input")); + } } // Do all the client-side rendering based on the state of allFieldReports. @@ -1074,3 +1078,32 @@ async function onStrikeSuccess() { renderFieldReportData(); clearErrorMessage(); } + +async function attachFile() { + if (incidentNumber == null) { + // Incident doesn't exist yet. Create it first. + const {err} = await sendEdits({}); + if (err != null) { + return; + } + } + const attachFile = document.getElementById("attach-file"); + const formData = new FormData(); + + for (const f of attachFile.files) { + formData.append("files", f); + } + + const attachURL = urlReplace(url_incidentAttachments).replace("", incidentNumber); + const {err} = await fetchJsonNoThrow(attachURL, { + body: formData + }); + if (err != null) { + const message = `Failed to attach file: ${err}`; + setErrorMessage(message); + return; + } + clearErrorMessage(); + attachFile.value = ""; + await loadAndDisplayIncident(); +} diff --git a/src/ims/store/_db.py b/src/ims/store/_db.py index 5125449ab..39b33c526 100644 --- a/src/ims/store/_db.py +++ b/src/ims/store/_db.py @@ -18,6 +18,7 @@ Incident Management System database tooling. """ +import re from abc import abstractmethod from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, Mapping @@ -366,6 +367,13 @@ async def createEvent(self, event: Event) -> None: """ See :meth:`IMSDataStore.createEvent`. """ + + eventIdPattern = r"^[\w-]+$" + if not re.search(eventIdPattern, event.id): + raise ValueError( + f"wanted EventID to match '{eventIdPattern}', got '{event.id}'" + ) + await self.runOperation(self.query.createEvent, {"eventID": event.id}) self._log.info( diff --git a/src/ims/store/mysql/_store.py b/src/ims/store/mysql/_store.py index f4f5b5715..af4ee86ed 100644 --- a/src/ims/store/mysql/_store.py +++ b/src/ims/store/mysql/_store.py @@ -100,7 +100,7 @@ class DataStore(DatabaseStore): _log: ClassVar[Logger] = Logger() - schemaVersion: ClassVar[int] = 9 + schemaVersion: ClassVar[int] = 10 schemaBasePath: ClassVar[Path] = Path(__file__).parent / "schema" sqlFileExtension: ClassVar[str] = "mysql" diff --git a/src/ims/store/mysql/schema/10-from-9.mysql b/src/ims/store/mysql/schema/10-from-9.mysql new file mode 100644 index 000000000..861b78739 --- /dev/null +++ b/src/ims/store/mysql/schema/10-from-9.mysql @@ -0,0 +1,9 @@ +/* Add support for file attachments */ + +alter table REPORT_ENTRY + add column ATTACHED_FILE varchar(128) +; + +/* Update schema version */ + +update `SCHEMA_INFO` set `VERSION` = 10; diff --git a/src/ims/store/mysql/schema/10.mysql b/src/ims/store/mysql/schema/10.mysql new file mode 100644 index 000000000..849363738 --- /dev/null +++ b/src/ims/store/mysql/schema/10.mysql @@ -0,0 +1,163 @@ +create table SCHEMA_INFO ( + VERSION smallint not null +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +insert into SCHEMA_INFO (VERSION) values (10); + + +create table EVENT ( + ID integer not null auto_increment, + NAME varchar(128) not null, + + primary key (ID), + unique key (NAME) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table CONCENTRIC_STREET ( + EVENT integer not null, + ID varchar(16) not null, + NAME varchar(128) not null, + + primary key (EVENT, ID) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table INCIDENT_TYPE ( + ID integer not null auto_increment, + NAME varchar(128) not null, + HIDDEN boolean not null, + + primary key (ID), + unique key (NAME) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +insert into INCIDENT_TYPE (NAME, HIDDEN) values ('Admin', 0); +insert into INCIDENT_TYPE (NAME, HIDDEN) values ('Junk' , 0); + + +create table REPORT_ENTRY ( + ID integer not null auto_increment, + AUTHOR varchar(64) not null, + TEXT text not null, + CREATED double not null, + GENERATED boolean not null, + STRICKEN boolean not null, + + ATTACHED_FILE varchar(128), + + -- FIXME: AUTHOR is an external non-primary key. + -- Primary key is DMS Person ID. + + primary key (ID) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table INCIDENT ( + EVENT integer not null, + NUMBER integer not null, + CREATED double not null, + PRIORITY tinyint not null, + + STATE enum( + 'new', 'on_hold', 'dispatched', 'on_scene', 'closed' + ) not null, + + SUMMARY varchar(1024), + + LOCATION_NAME varchar(1024), + LOCATION_CONCENTRIC varchar(64), + LOCATION_RADIAL_HOUR tinyint, + LOCATION_RADIAL_MINUTE tinyint, + LOCATION_DESCRIPTION varchar(1024), + + foreign key (EVENT) references EVENT(ID), + + foreign key (EVENT, LOCATION_CONCENTRIC) + references CONCENTRIC_STREET(EVENT, ID), + + primary key (EVENT, NUMBER) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table INCIDENT__RANGER ( + EVENT integer not null, + INCIDENT_NUMBER integer not null, + RANGER_HANDLE varchar(64) not null, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, INCIDENT_NUMBER) references INCIDENT(EVENT, NUMBER), + + -- FIXME: RANGER_HANDLE is an external non-primary key. + -- Primary key is DMS Person ID. + + primary key (EVENT, INCIDENT_NUMBER, RANGER_HANDLE) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table INCIDENT__INCIDENT_TYPE ( + EVENT integer not null, + INCIDENT_NUMBER integer not null, + INCIDENT_TYPE integer not null, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, INCIDENT_NUMBER) references INCIDENT(EVENT, NUMBER), + foreign key (INCIDENT_TYPE) references INCIDENT_TYPE(ID), + + primary key (EVENT, INCIDENT_NUMBER, INCIDENT_TYPE) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table INCIDENT__REPORT_ENTRY ( + EVENT integer not null, + INCIDENT_NUMBER integer not null, + REPORT_ENTRY integer not null, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, INCIDENT_NUMBER) references INCIDENT(EVENT, NUMBER), + foreign key (REPORT_ENTRY) references REPORT_ENTRY(ID), + + primary key (EVENT, INCIDENT_NUMBER, REPORT_ENTRY) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table EVENT_ACCESS ( + EVENT integer not null, + EXPRESSION varchar(128) not null, + + MODE enum ('read', 'write', 'report') not null, + VALIDITY enum ('always', 'onsite') not null default 'always', + + foreign key (EVENT) references EVENT(ID), + + primary key (EVENT, EXPRESSION) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table FIELD_REPORT ( + EVENT integer not null, + NUMBER integer not null, + CREATED double not null, + + SUMMARY varchar(1024), + INCIDENT_NUMBER integer, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, INCIDENT_NUMBER) references INCIDENT(EVENT, NUMBER), + + primary key (EVENT, NUMBER) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + +create table FIELD_REPORT__REPORT_ENTRY ( + EVENT integer not null, + FIELD_REPORT_NUMBER integer not null, + REPORT_ENTRY integer not null, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, FIELD_REPORT_NUMBER) + references FIELD_REPORT(EVENT, NUMBER), + foreign key (REPORT_ENTRY) references REPORT_ENTRY(ID), + + primary key (EVENT, FIELD_REPORT_NUMBER, REPORT_ENTRY) +) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/ims/store/mysql/test/test_store_core.py b/src/ims/store/mysql/test/test_store_core.py index 7826e8364..78b0f3f56 100644 --- a/src/ims/store/mysql/test/test_store_core.py +++ b/src/ims/store/mysql/test/test_store_core.py @@ -144,7 +144,7 @@ async def test_printSchema(self) -> None: self.assertEqual( dedent( """ - Version: 9 + Version: 10 CONCENTRIC_STREET: 1: EVENT(int) not null 2: ID(varchar(16)) not null @@ -202,6 +202,7 @@ async def test_printSchema(self) -> None: 4: CREATED(double) not null 5: GENERATED(tinyint) not null 6: STRICKEN(tinyint) not null + 7: ATTACHED_FILE(varchar(128)) SCHEMA_INFO: 1: VERSION(smallint) not null """[1:] diff --git a/src/ims/store/sqlite/_store.py b/src/ims/store/sqlite/_store.py index fbaaf0570..daeb637f6 100644 --- a/src/ims/store/sqlite/_store.py +++ b/src/ims/store/sqlite/_store.py @@ -56,7 +56,7 @@ class DataStore(DatabaseStore): _log: ClassVar[Logger] = Logger() - schemaVersion: ClassVar[int] = 6 + schemaVersion: ClassVar[int] = 7 schemaBasePath: ClassVar[Path] = Path(__file__).parent / "schema" sqlFileExtension: ClassVar[str] = "sqlite" diff --git a/src/ims/store/sqlite/schema/7-from-6.sqlite b/src/ims/store/sqlite/schema/7-from-6.sqlite new file mode 100644 index 000000000..10c1db80d --- /dev/null +++ b/src/ims/store/sqlite/schema/7-from-6.sqlite @@ -0,0 +1,9 @@ +-- Add support for file attachments + +alter table REPORT_ENTRY + add column ATTACHED_FILE text +; + +-- Update schema version + +update SCHEMA_INFO set VERSION = 7; diff --git a/src/ims/store/sqlite/schema/7.sqlite b/src/ims/store/sqlite/schema/7.sqlite new file mode 100644 index 000000000..863a1f2e7 --- /dev/null +++ b/src/ims/store/sqlite/schema/7.sqlite @@ -0,0 +1,193 @@ +create table SCHEMA_INFO ( + VERSION integer not null +); + +insert into SCHEMA_INFO (VERSION) values (7); + + +create table EVENT ( + ID integer not null, + NAME text not null, + + primary key (ID), + unique (NAME) +); + + +create table CONCENTRIC_STREET ( + EVENT integer not null, + ID text not null, + NAME text not null, + + primary key (EVENT, ID) +); + + +create table INCIDENT_STATE ( + ID text not null, + + primary key (ID) +); + +insert into INCIDENT_STATE (ID) values ('new'); +insert into INCIDENT_STATE (ID) values ('on_hold'); +insert into INCIDENT_STATE (ID) values ('dispatched'); +insert into INCIDENT_STATE (ID) values ('on_scene'); +insert into INCIDENT_STATE (ID) values ('closed'); + + +create table INCIDENT_TYPE ( + ID integer not null, + NAME text not null, + HIDDEN numeric not null, + + primary key (ID), + unique (NAME) +); + +insert into INCIDENT_TYPE (NAME, HIDDEN) values ('Admin', 0); +insert into INCIDENT_TYPE (NAME, HIDDEN) values ('Junk', 0); + + +create table REPORT_ENTRY ( + ID integer not null, + AUTHOR text not null, + TEXT text not null, + CREATED real not null, + GENERATED numeric not null, + STRICKEN numeric not null, + + ATTACHED_FILE text, + + -- FIXME: AUTHOR is an external non-primary key. + -- Primary key is DMS Person ID. + + primary key (ID) +); + + +create table INCIDENT ( + EVENT integer not null, + NUMBER integer not null, + CREATED real not null, + PRIORITY integer not null, + STATE integer not null, + SUMMARY text, + + LOCATION_NAME text, + LOCATION_CONCENTRIC text, + LOCATION_RADIAL_HOUR integer, + LOCATION_RADIAL_MINUTE integer, + LOCATION_DESCRIPTION text, + + foreign key (EVENT) references EVENT(ID), + foreign key (STATE) references INCIDENT_STATE(ID), + + foreign key (EVENT, LOCATION_CONCENTRIC) + references CONCENTRIC_STREET(EVENT, ID), + + primary key (EVENT, NUMBER) +); + + +create table INCIDENT__RANGER ( + EVENT integer not null, + INCIDENT_NUMBER integer not null, + RANGER_HANDLE text not null, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, INCIDENT_NUMBER) references INCIDENT(EVENT, NUMBER), + + -- FIXME: RANGER_HANDLE is an external non-primary key. + -- Primary key is DMS Person ID. + + primary key (EVENT, INCIDENT_NUMBER, RANGER_HANDLE) +); + + +create table INCIDENT__INCIDENT_TYPE ( + EVENT integer not null, + INCIDENT_NUMBER integer not null, + INCIDENT_TYPE integer not null, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, INCIDENT_NUMBER) references INCIDENT(EVENT, NUMBER), + foreign key (INCIDENT_TYPE) references INCIDENT_TYPE(ID), + + primary key (EVENT, INCIDENT_NUMBER, INCIDENT_TYPE) +); + + +create table INCIDENT__REPORT_ENTRY ( + EVENT integer not null, + INCIDENT_NUMBER integer not null, + REPORT_ENTRY integer not null, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, INCIDENT_NUMBER) references INCIDENT(EVENT, NUMBER), + foreign key (REPORT_ENTRY) references REPORT_ENTRY(ID), + + primary key (EVENT, INCIDENT_NUMBER, REPORT_ENTRY) +); + + +create table ACCESS_MODE ( + ID text not null, + + primary key (ID) +); + +insert into ACCESS_MODE (ID) values ('read' ); +insert into ACCESS_MODE (ID) values ('write' ); +insert into ACCESS_MODE (ID) values ('report'); + +create table ACCESS_VALIDITY ( + ID text not null, + + primary key (ID) +); + +insert into ACCESS_VALIDITY (ID) values ('always'); +insert into ACCESS_VALIDITY (ID) values ('onsite'); + +create table EVENT_ACCESS ( + EVENT integer not null, + EXPRESSION text not null, + MODE text not null, + VALIDITY text not null default ('always'), + + foreign key (EVENT) references EVENT(ID), + foreign key (MODE) references ACCESS_MODE(ID), + foreign key (VALIDITY) references ACCESS_VALIDITY(ID), + + primary key (EVENT, EXPRESSION) +); + + +create table FIELD_REPORT ( + EVENT integer not null, + NUMBER integer not null, + CREATED real not null, + + SUMMARY text, + INCIDENT_NUMBER integer, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, INCIDENT_NUMBER) references INCIDENT(EVENT, NUMBER), + + primary key (EVENT, NUMBER) +); + + +create table FIELD_REPORT__REPORT_ENTRY ( + EVENT integer not null, + FIELD_REPORT_NUMBER integer not null, + REPORT_ENTRY integer not null, + + foreign key (EVENT) references EVENT(ID), + foreign key (EVENT, FIELD_REPORT_NUMBER) + references FIELD_REPORT(EVENT, NUMBER), + foreign key (REPORT_ENTRY) references REPORT_ENTRY(ID), + + primary key (EVENT, FIELD_REPORT_NUMBER, REPORT_ENTRY) +); diff --git a/src/ims/store/sqlite/test/test_store_core.py b/src/ims/store/sqlite/test/test_store_core.py index e346d1700..f4a183b28 100644 --- a/src/ims/store/sqlite/test/test_store_core.py +++ b/src/ims/store/sqlite/test/test_store_core.py @@ -76,7 +76,7 @@ def test_printSchema(self) -> None: schemaInfo.lower(), dedent( """ - Version: 6 + Version: 7 ACCESS_MODE: 0: ID(text) not null *1 ACCESS_VALIDITY: @@ -140,6 +140,7 @@ def test_printSchema(self) -> None: 3: CREATED(real) not null 4: GENERATED(numeric) not null 5: STRICKEN(numeric) not null + 6: ATTACHED_FILE(text) SCHEMA_INFO: 0: VERSION(integer) not null """[1:] diff --git a/uv.lock b/uv.lock index c98050e2d..c8ce7a088 100644 --- a/uv.lock +++ b/uv.lock @@ -330,6 +330,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/20/471f41173930550f279ccb65596a5ac19b9ac974a8d93679bcd3e0c31498/mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744", size = 30938 }, ] +[[package]] +name = "multipart" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/91/6c93b6a95e6a99ef929a99d019fbf5b5f7fd3368389a0b1ec7ce0a23565b/multipart-1.2.1.tar.gz", hash = "sha256:829b909b67bc1ad1c6d4488fcdc6391c2847842b08323addf5200db88dbe9480", size = 36507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/d1/3598d1e73385baaab427392856f915487db7aa10abadd436f8f2d3e3b0f9/multipart-1.2.1-py3-none-any.whl", hash = "sha256:c03dc203bc2e67f6b46a599467ae0d87cf71d7530504b2c1ff4a9ea21d8b8c8c", size = 13730 }, +] + [[package]] name = "mypy" version = "1.13.0" @@ -371,6 +380,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/db/ee6e5840d85904c7525afee6d7eaa9cd24bb177ea82d1ad705e19468151a/mypy_zope-1.0.9-py3-none-any.whl", hash = "sha256:6666c1556891a3cb186137519dbd7a58cb30fb72b2504798cad47b35391921ba", size = 32412 }, ] +[[package]] +name = "puremagic" +version = "1.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/2d/40599f25667733e41bbc3d7e4c7c36d5e7860874aa5fe9c584e90b34954d/puremagic-1.28.tar.gz", hash = "sha256:195893fc129657f611b86b959aab337207d6df7f25372209269ed9e303c1a8c0", size = 314945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/53/200a97332d10ed3edd7afcbc5f5543920ac59badfe5762598327999f012e/puremagic-1.28-py3-none-any.whl", hash = "sha256:e16cb9708ee2007142c37931c58f07f7eca956b3472489106a7245e5c3aa1241", size = 43241 }, +] + [[package]] name = "pyasn1" version = "0.6.1" @@ -473,6 +491,8 @@ dependencies = [ { name = "hyperlink" }, { name = "jwcrypto" }, { name = "klein" }, + { name = "multipart" }, + { name = "puremagic" }, { name = "pymysql" }, { name = "pyopenssl" }, { name = "pyyaml" }, @@ -506,6 +526,8 @@ requires-dist = [ { name = "hyperlink", specifier = "==21.0.0" }, { name = "jwcrypto", specifier = "==1.5.6" }, { name = "klein", specifier = "==24.8.0" }, + { name = "multipart", specifier = ">=1.2.1" }, + { name = "puremagic", specifier = ">=1.28" }, { name = "pymysql", specifier = "==1.1.1" }, { name = "pyopenssl", specifier = "==25.0.0" }, { name = "pyyaml", specifier = "==6.0.2" },