diff --git a/ERD.png b/ERD.png index 49cbde7..d13e460 100644 Binary files a/ERD.png and b/ERD.png differ diff --git a/migrate/versions/2023_11_20_220060b4adb3_create_archive_and_package_entities.py b/migrate/versions/2023_11_20_220060b4adb3_create_archive_and_package_entities.py new file mode 100644 index 0000000..fb8d530 --- /dev/null +++ b/migrate/versions/2023_11_20_220060b4adb3_create_archive_and_package_entities.py @@ -0,0 +1,64 @@ +"""Create archive and package entities + +Revision ID: 220060b4adb3 +Revises: df57d06e1ee5 +Create Date: 2023-11-20 14:59:44.964701 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '220060b4adb3' +down_revision = 'df57d06e1ee5' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic ### + op.create_table('archive', + sa.Column('id', sa.String(), nullable=False), + sa.Column('url', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('archive_resource', + sa.Column('id', sa.String(), nullable=False), + sa.Column('key', sa.String(), nullable=False), + sa.Column('path', sa.String(), nullable=True), + sa.Column('name', sa.String(), nullable=True), + sa.Column('type', sa.String(), nullable=True), + sa.Column('size', sa.Integer(), nullable=True), + sa.Column('md5', sa.String(), nullable=True), + sa.Column('timestamp', sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column('archive_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['archive_id'], ['archive.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('archive_id', 'key', name='uix_archive_resource_archive_id_key') + ) + op.create_table('package', + sa.Column('id', sa.String(), nullable=False), + sa.Column('metadata_', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('timestamp', sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column('record_id', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['record_id'], ['record.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('package_resource', + sa.Column('package_id', sa.String(), nullable=False), + sa.Column('resource_id', sa.String(), nullable=False), + sa.ForeignKeyConstraint(['package_id'], ['package.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['resource_id'], ['archive_resource.id'], ondelete='RESTRICT'), + sa.PrimaryKeyConstraint('package_id', 'resource_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic ### + op.drop_table('package_resource') + op.drop_table('package') + op.drop_table('archive_resource') + op.drop_table('archive') + # ### end Alembic commands ### diff --git a/odp/db/models/__init__.py b/odp/db/models/__init__.py index b3628e1..1ae9a78 100644 --- a/odp/db/models/__init__.py +++ b/odp/db/models/__init__.py @@ -1,6 +1,8 @@ +from .archive import Archive, ArchiveResource from .catalog import Catalog, CatalogRecord, CatalogRecordFacet from .client import Client, ClientCollection, ClientScope from .collection import Collection, CollectionAudit, CollectionTag, CollectionTagAudit +from .package import Package, PackageResource from .provider import Provider, ProviderAudit from .record import PublishedRecord, Record, RecordAudit, RecordTag, RecordTagAudit from .role import Role, RoleCollection, RoleScope diff --git a/odp/db/models/archive.py b/odp/db/models/archive.py new file mode 100644 index 0000000..09960df --- /dev/null +++ b/odp/db/models/archive.py @@ -0,0 +1,49 @@ +import uuid + +from sqlalchemy import Column, ForeignKey, Integer, String, TIMESTAMP, UniqueConstraint +from sqlalchemy.orm import relationship + +from odp.db import Base + + +class Archive(Base): + """An archive represents a data store that provides + long-term preservation of and access to digital resources.""" + + __tablename__ = 'archive' + + id = Column(String, primary_key=True) + url = Column(String, nullable=False) + + _repr_ = 'id', 'url' + + +class ArchiveResource(Base): + """A reference to a file or dataset in an archive. + + `key` is the archive's unique identifier for the resource. + `path`, if specified, is relative to `url` of the archive. + """ + + __tablename__ = 'archive_resource' + + __table_args__ = ( + UniqueConstraint( + 'archive_id', 'key', + name='uix_archive_resource_archive_id_key', + ), + ) + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + key = Column(String, nullable=False) + path = Column(String) + name = Column(String) + type = Column(String) + size = Column(Integer) + md5 = Column(String) + timestamp = Column(TIMESTAMP(timezone=True)) + + archive_id = Column(String, ForeignKey('archive.id', ondelete='CASCADE'), nullable=False) + archive = relationship('Archive') + + _repr_ = 'id', 'key', 'path', 'name', 'type', 'size', 'archive_id' diff --git a/odp/db/models/package.py b/odp/db/models/package.py new file mode 100644 index 0000000..57e644f --- /dev/null +++ b/odp/db/models/package.py @@ -0,0 +1,40 @@ +import uuid + +from sqlalchemy import Column, ForeignKey, String, TIMESTAMP +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship + +from odp.db import Base + + +class Package(Base): + """A package represents a set of resources that constitute + a digital object. + + A package may include non-authoritative metadata, which can + be used when creating/updating the linked record. + """ + + __tablename__ = 'package' + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + metadata_ = Column(JSONB) + timestamp = Column(TIMESTAMP(timezone=True), nullable=False) + + record_id = Column(String, ForeignKey('record.id', ondelete='SET NULL')) + record = relationship('Record') + + _repr_ = 'id', 'record_id', + + +class PackageResource(Base): + """Model of a many-to-many package-resource association, + representing the set of archived resources constituting a package.""" + + __tablename__ = 'package_resource' + + package_id = Column(String, ForeignKey('package.id', ondelete='CASCADE'), primary_key=True) + resource_id = Column(String, ForeignKey('archive_resource.id', ondelete='RESTRICT'), primary_key=True) + + package = relationship('Package', viewonly=True) + resource = relationship('ArchiveResource', viewonly=True)