Browse Source

feat: UUID upload paths, canonical API URLs, nginx internal media (RFC §6.3, §6.4, §10)

- MetadataFileField.generate_filename: substitutes a UUID for the original
  filename in the upload_to path so new files land at a stable, unguessable
  storage path (e.g. sapl/public/norma/2025/9395/<uuid>.pdf).  For
  replacements the existing UUID is reused; OverwriteStorage replaces bytes
  in-place and the public /documentos/<uuid>/ URL never changes.  A fresh UUID
  is stashed on the instance (_pending_uuid_<field>) and wired into the new
  FileMetadata row in pre_save so FileMetadata.uuid always matches the path.

- pre_save Case 2 fix: skip explicit default_storage.delete() when old and new
  storage paths are identical (UUID-based replacement) to avoid deleting the
  freshly written file that OverwriteStorage already placed at that path.

- MetadataFileFieldSerializer: overrides to_representation only (inherits
  DRF FileField for writes) to emit /documentos/<uuid>/ for API responses
  instead of the semantic alias .url() returns.  SaplSerializerMixin wires it
  in via build_standard_field while preserving all normal field kwargs.

- nginx sapl.conf: adds `internal` and `etag on` to /media/ so clients can no
  longer fetch files directly; only Django's X-Accel-Redirect reaches the
  location.

- settings.py: adds ConditionalGetMiddleware after CommonMiddleware to set
  ETag and Last-Modified on Django responses and short-circuit with 304.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
file-metafields
Edward Ribeiro 2 weeks ago
parent
commit
60d88f3105
  1. 2
      docker/config/nginx/sapl.conf
  2. 30
      sapl/api/serializers.py
  3. 64
      sapl/base/fields.py
  4. 1
      sapl/settings.py

2
docker/config/nginx/sapl.conf

@ -50,7 +50,9 @@ server {
}
location /media/ {
internal;
alias /var/interlegis/sapl/media/;
etag on;
}
location / {

30
sapl/api/serializers.py

@ -15,10 +15,40 @@ from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura
from sapl.sessao.models import OrdemDia, SessaoPlenaria
class MetadataFileFieldSerializer(serializers.FileField):
"""
Serializes a MetadataFileField as the canonical /documentos/<uuid>/ URL.
Inherits DRF FileField so that write operations (multipart file uploads
via the API) continue to work unchanged. Only to_representation is
overridden: API consumers receive the stable /documentos/<uuid>/ form
rather than the semantic alias returned by .url(), so that model/field
renames don't silently break external integrations (RFC §10).
"""
def to_representation(self, value):
if not value:
return None
meta = getattr(value.instance, f'{value.field.name}_metadata', None)
if meta is None:
return None
return f'/documentos/{meta.uuid}/'
class SaplSerializerMixin(DrfAutoApiSerializerMixin):
link_detail_backend = serializers.SerializerMethodField()
metadata = SerializerMethodField()
def build_standard_field(self, field_name, model_field):
from sapl.base.fields import MetadataFileField
if isinstance(model_field, MetadataFileField):
# Keep the normal field kwargs (required, allow_null, max_length…)
# so validation behaviour is unchanged; only swap the serializer
# class so to_representation emits the canonical UUID URL.
_, field_kwargs = super().build_standard_field(field_name, model_field)
return MetadataFileFieldSerializer, field_kwargs
return super().build_standard_field(field_name, model_field)
class Meta(DrfAutoApiSerializerMixin.Meta):
fields = '__all__'

64
sapl/base/fields.py

@ -1,5 +1,7 @@
import hashlib
import posixpath
from pathlib import Path
from uuid import uuid4
from django.core.files import File
from django.core.files.storage import default_storage
@ -108,6 +110,44 @@ class MetadataFileField(models.FileField):
)
cls.add_to_class(f'{name}_metadata', fk)
def generate_filename(self, instance, filename):
"""
Override: substitute a UUID for the filename in the upload_to path so
that newly uploaded files get stable, unguessable storage paths like
sapl/public/normajuridica/2025/9395/<uuid>.pdf (RFC §6.3).
For replacement uploads (Case 2) the existing meta UUID is reused so
the physical path and therefore /documentos/<uuid>/ stays stable.
For first uploads (Case 1) a fresh UUID is generated and stashed on the
instance under _pending_uuid_<fieldname> for pre_save to pick up when
creating the FileMetadata row.
"""
# 1. Let upload_to produce the directory + original filename.
if callable(self.upload_to):
upload_name = self.upload_to(instance, filename)
else:
upload_name = posixpath.join(self.upload_to, filename)
# 2. Determine the UUID to embed in the path.
meta_attr = f'{self.name}_metadata'
meta = getattr(instance, meta_attr, None)
if meta is not None:
# Replacement (Case 2): reuse existing UUID — path stays identical,
# OverwriteStorage replaces the bytes in-place.
file_uuid = str(meta.uuid)
else:
# First upload (Case 1): generate a fresh UUID and stash it so
# pre_save can wire it into the new FileMetadata row.
file_uuid = str(uuid4())
setattr(instance, f'_pending_uuid_{self.name}', file_uuid)
# 3. Replace the filename portion with <uuid><ext>.
ext = Path(filename).suffix.lower()
directory = posixpath.dirname(upload_name)
new_name = posixpath.join(directory, f'{file_uuid}{ext}') if directory else f'{file_uuid}{ext}'
return self.storage.generate_filename(new_name)
def pre_save(self, instance, add):
from sapl.base.models import FileMetadata
@ -127,7 +167,7 @@ class MetadataFileField(models.FileField):
original_filename = ''
file = super().pre_save(instance, add)
# file.name is now the full storage path produced by upload_to,
# file.name is now the UUID-based storage path from generate_filename,
# e.g. "sapl/public/normajuridica/2025/9395/<uuid>.pdf"
if is_clearing:
@ -144,18 +184,34 @@ class MetadataFileField(models.FileField):
if meta_before is None:
# Case 1: first upload — create a new FileMetadata row.
meta = FileMetadata(
# Use the UUID that generate_filename already baked into the path
# so that FileMetadata.uuid matches the on-disk filename.
pending_uuid = getattr(instance, f'_pending_uuid_{self.name}', None)
meta_kwargs = dict(
storage_name=storage_name,
original_filename=original_filename,
file_size_bytes=size,
content_hash=digest,
)
if pending_uuid:
from uuid import UUID
meta_kwargs['uuid'] = UUID(pending_uuid)
# Clean up the temporary stash attribute.
try:
delattr(instance, f'_pending_uuid_{self.name}')
except AttributeError:
pass
meta = FileMetadata(**meta_kwargs)
meta.save()
setattr(instance, f'{meta_attr}_id', meta.pk)
else:
# Case 2: replacement — reuse the existing row so the stable uuid
# (and /documentos/<uuid>/) never changes. Delete old physical file
# only after the new one is confirmed saved (super() already returned).
# (and /documentos/<uuid>/) never changes.
# For UUID-based paths the old and new paths are identical
# (same uuid, same ext) — OverwriteStorage already replaced the
# bytes in-place, so we must NOT delete the newly written file.
if meta_before.storage_name != storage_name:
# Legacy (non-UUID) paths: old path differs → delete old file.
try:
default_storage.delete(meta_before.storage_name)
except OSError:

1
sapl/settings.py

@ -143,6 +143,7 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.http.ConditionalGetMiddleware',
'sapl.middleware.endpoint_restriction.EndpointRestrictionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',

Loading…
Cancel
Save