Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e2017cc
fix(api): avoid updating project row on POST /file/new
valyo Mar 20, 2026
6044719
fix aquasecurity actions versions
valyo Mar 20, 2026
a3a7f00
Merge branch 'dev' into improve-project-updating-during-upload
valyo Mar 23, 2026
42a9c3b
add endpoint for project update at the end of upload
valyo Mar 31, 2026
fcd7e14
fix test
valyo Mar 31, 2026
8901707
Merge branch 'dev' into improve-project-updating-during-upload
valyo Apr 2, 2026
71f7669
Merge branch 'dev' into improve-project-updating-during-upload
i-oden Apr 24, 2026
101a676
Merge branch 'dev' into improve-project-updating-during-upload
valyo Apr 29, 2026
b90bcd9
Merge branch 'dev' into improve-project-updating-during-upload
valyo May 5, 2026
385938f
Merge branch 'dev' into improve-project-updating-during-upload
valyo May 7, 2026
931a2d7
Merge branch 'dev' into improve-project-updating-during-upload
valyo Jun 4, 2026
5e5c4ac
clarify why date_updated is set explicitly in ProjectUploadComplete
valyo Jun 10, 2026
069d499
test: add role coverage for POST /proj/upload/complete
valyo Jun 10, 2026
f232cd5
test: use freezegun in test_proj_upload_complete_updates_timestamp fo…
valyo Jun 10, 2026
78733de
test: clarify why project param is omitted in test_init maintenance/s…
valyo Jun 10, 2026
da53d04
black
valyo Jun 10, 2026
d15775c
fix: apply no-project-UPDATE fix to PUT /file/new (overwrite path)
valyo Jun 11, 2026
a7d9f63
revert unnecessary change
valyo Jun 11, 2026
cfa4f74
sprintlog
valyo Jun 11, 2026
7eb55aa
Merge branch 'dev' into improve-project-updating-during-upload
valyo Jun 12, 2026
0e72058
Merge branch 'dev' into improve-project-updating-during-upload
valyo Jun 25, 2026
474f6ae
tests: move ProjectUploadComplete tests to test_project_upload.py
valyo Jun 25, 2026
6c5312c
tests: add ProjectUploadComplete status-guard tests (Available, Expired)
valyo Jun 25, 2026
827af52
tests: add ProjectUploadComplete DB failure test
valyo Jun 25, 2026
6b9d3c4
tests: add ProjectUploadComplete missing-project test
valyo Jun 26, 2026
1221181
tests: assert POST /file/new does not update project timestamp
valyo Jun 29, 2026
480ad7d
tests: assert PUT /file/new version is linked to both file and project
valyo Jun 29, 2026
2c80c7b
remove redundant project_id=project from Version constructor in PUT /…
valyo Jun 29, 2026
0299d99
Merge branch 'dev' into improve-project-updating-during-upload
valyo Jun 30, 2026
6ee30ac
sprintlog
valyo Jun 30, 2026
f3a4fce
Merge branch 'dev' into improve-project-updating-during-upload
valyo Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SPRINTLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,3 +660,4 @@ _Nothing merged during this sprint_

- Update dependency sass to v1.98.0([#1799]https://github.com/ScilifelabDataCentre/dds_web/pull/1799)
- Update dependency cryptography to v50 [SECURITY] ([#1857]https://github.com/ScilifelabDataCentre/dds_web/pull/1857)
- Reduce DB contention: skip project UPDATE when registering new files ([#1813]https://github.com/ScilifelabDataCentre/dds_web/pull/1813)
3 changes: 3 additions & 0 deletions dds_web/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ def add_resources(api):
api.add_resource(project.ProjectAccess, "/proj/access", endpoint="project_access")
api.add_resource(project.ProjectBusy, "/proj/busy", endpoint="project_busy")
api.add_resource(project.ProjectInfo, "/proj/info", endpoint="project_info")
api.add_resource(
project.ProjectUploadComplete, "/proj/upload/complete", endpoint="project_upload_complete"
)

# User management ################################################################ User management #
api.add_resource(user.RetrieveUserInfo, "/user/info", endpoint="user_info")
Expand Down
9 changes: 5 additions & 4 deletions dds_web/api/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@ def post(self):
# Verify that project has correct status for upload
check_eligibility_for_upload(status=project.current_status)

# Create new files
# Create new files (new_file is not attached via project.files so add explicitly)
new_file = file_schemas.NewFileSchema().load(
{**flask.request.get_json(silent=True), "project": project.public_id}
)
db.session.add(new_file)

try:
db.session.commit()
Expand Down Expand Up @@ -176,11 +177,11 @@ def put(self):
size_stored=file_info.get("size_processed"),
time_uploaded=new_timestamp,
active_file=existing_file.id,
project_id=project,
)

# Update foreign keys and relationships
project.file_versions.append(new_version)
# Set FK directly so we do not modify the project row (avoids UPDATE on projects
# and reduces lock contention during concurrent PUT /file/new).
new_version.project_id = project.id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker:

We don't need both

            new_version = models.Version(
               [...]
                project_id=project,
            )

above and

            new_version.project_id = project.id

Not a huge deal, but might be confusing in the future.

There are 2 alternatives I think (I tested locally) work the same way without us repeating the project connection:

  1. remove the new_version.project_id = project.id row and change project to project.id in the version:
            # New version
            new_version = models.Version(
                size_stored=file_info.get("size_processed"),
                time_uploaded=new_timestamp,
                active_file=existing_file.id,
                project_id=project.id,
            )
    
            existing_file.versions.append(new_version)
  2. Remove the project_id=project in the version definition and keep the new_version.project_id = project.id.
            # New version
            new_version = models.Version(
                size_stored=file_info.get("size_processed"),
                time_uploaded=new_timestamp,
                active_file=existing_file.id,
            )
    
            # Set FK directly so we do not modify the project row (avoids UPDATE on projects
            # and reduces lock contention during concurrent PUT /file/new).
            new_version.project_id = project.id
            existing_file.versions.append(new_version)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @i-oden , all your comments are addressed now

existing_file.versions.append(new_version)

db.session.add(new_version)
Expand Down
36 changes: 35 additions & 1 deletion dds_web/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
json_required,
logging_bind_request,
)
from dds_web.api.files import check_eligibility_for_deletion
from dds_web.api.files import check_eligibility_for_deletion, check_eligibility_for_upload
from dds_web.api.schemas import project_schemas, user_schemas
from dds_web.api.user import AddUser
from dds_web.database import models
Expand Down Expand Up @@ -1313,3 +1313,37 @@ def put(self):
}

return return_message


class ProjectUploadComplete(flask_restful.Resource):
"""Update ``date_updated`` / ``last_updated_by`` once after a batch upload.

The CLI calls this at the end of ``dds data put`` when at least one file was
registered in the database, so project metadata stays accurate without
updating the project row on every ``POST /file/new``.
"""

@auth.login_required(role=["Unit Admin", "Unit Personnel"])
@logging_bind_request
@handle_validation_errors
def post(self):
project = project_schemas.ProjectRequiredSchema().load(flask.request.args)
check_eligibility_for_upload(status=project.current_status)
# Dirty the row so the before_update listener in models.py sets date_updated and last_updated_by.
project.date_updated = dds_web.utils.current_time()
try:
db.session.commit()
except (sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.OperationalError) as err:
flask.current_app.logger.debug(err)
db.session.rollback()
raise DatabaseError(
message=str(err),
alt_message="Failed to update project timestamp after upload"
+ (
": Database malfunction."
if isinstance(err, sqlalchemy.exc.OperationalError)
else "."
),
) from err

return {"message": "Project upload timestamp updated."}
7 changes: 4 additions & 3 deletions dds_web/api/schemas/file_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ def return_items(self, data, **kwargs):
)

project = data.get("project_row")
# Update foreign keys
project.file_versions.append(new_version)
project.files.append(new_file)
# Set FKs directly so we do not modify the project row (avoids UPDATE on projects
# and reduces lock contention during concurrent POST /file/new).
new_file.project_id = project.id
new_version.project_id = project.id
Comment on lines +145 to +146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve duplicate-file protection under concurrent /file/new

By setting project_id directly here, POST /file/new no longer touches the projects row, so concurrent requests for the same project are no longer serialized. In this code path, duplicate prevention is still an application-level precheck (verify_file_not_exists) and files has no DB uniqueness constraint on (project_id, name) (see dds_web/database/models.py), so two simultaneous registrations of the same path can both pass validation and insert duplicate active file rows. That creates data-integrity issues (later PUT/delete paths use .first() and may operate on an arbitrary duplicate).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify, the race actually existed pre-PR too: the UPDATE projects from the cascade serializes commits but not the SELECT-vs-INSERT ordering, so two workers can both pass verify_file_not_exists and both insert under REPEATABLE READ. This PR widens the race rather than introducing it.
Either way, the right fix is a DB-level UNIQUE (project_id, name) on files, with the schema-level precheck kept as a fast path and a try/except IntegrityError → FileExistsError at commit. Happy to do this in a follow-up PR or fold it in here — let me know which is preferred. Need to confirm "delete" semantics first (hard vs soft) since that affects whether a partial constraint is needed.

new_file.versions.append(new_version)

return new_file
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ class DDSEndpoint:
PROJECT_BUSY = BASE_ENDPOINT + "/proj/busy"
PROJECT_BUSY_ANY = BASE_ENDPOINT + "/proj/busy/any"
PROJECT_INFO = BASE_ENDPOINT + "/proj/info"
PROJ_UPLOAD_COMPLETE = BASE_ENDPOINT + "/proj/upload/complete"

# Listing urls
LIST_PROJ = BASE_ENDPOINT + "/proj/list"
Expand Down
67 changes: 67 additions & 0 deletions tests/test_files_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,35 @@ def test_new_file_database_error(client):
assert rollback.called


def test_new_file_post_does_not_update_project_timestamp(client):
"""POST /file/new must not update project.date_updated or last_updated_by."""
import freezegun
import datetime

project_1 = project_row(project_id="file_testing_project")
assert project_1

frozen_time = datetime.datetime(2000, 1, 1, 12, 0, 0)
with freezegun.freeze_time(frozen_time):
project_1.date_updated = frozen_time
db.session.commit()

original_date_updated = project_1.date_updated
original_last_updated_by = project_1.last_updated_by

response = client.post(
tests.DDSEndpoint.FILE_NEW,
headers=tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client),
query_string={"project": "file_testing_project"},
json=FIRST_NEW_FILE,
)
assert response.status_code == http.HTTPStatus.OK

db.session.refresh(project_1)
assert project_1.date_updated == original_date_updated
assert project_1.last_updated_by == original_last_updated_by


def test_new_file(client):
"""Add and overwrite file to database."""

Expand Down Expand Up @@ -462,6 +491,44 @@ def test_new_file(client):
assert f"File '{updated_file['name']}' updated in db." in response.json["message"]


def test_new_file_put_version_linked_to_file_and_project(client):
"""PUT /file/new creates a Version row tied to both the file and the project."""
project_1 = project_row(project_id="file_testing_project")
assert project_1

# First add the file
response = client.post(
tests.DDSEndpoint.FILE_NEW,
headers=tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client),
query_string={"project": "file_testing_project"},
json=FIRST_NEW_FILE,
)
assert response.status_code == http.HTTPStatus.OK

# Overwrite the file via PUT
updated_file = {**FIRST_NEW_FILE, "size": 1200, "size_processed": 600}
response = client.put(
tests.DDSEndpoint.FILE_NEW,
headers=tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client),
query_string={"project": "file_testing_project"},
json=updated_file,
)
assert response.status_code == http.HTTPStatus.OK

# Fetch the file row and verify the new version is linked correctly
file_row = models.File.query.filter_by(
name=FIRST_NEW_FILE["name"], project_id=project_1.id
).one_or_none()
assert file_row is not None

new_version = models.Version.query.filter_by(
active_file=file_row.id, time_deleted=None
).one_or_none()
assert new_version is not None
assert new_version.active_file == file_row.id
assert new_version.project_id == project_1.id


def test_update_nonexistent_file(client):
"""Try to update a non existent file"""
response = client.put(
Expand Down
17 changes: 17 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,15 @@ def test_block_if_maintenance_active_none_approved_users(client: flask.testing.F
assert response.status_code == http.HTTPStatus.SERVICE_UNAVAILABLE
assert response.json and response.json.get("message") == "Maintenance of DDS is ongoing."

# ProjectUploadComplete - "/proj/upload/complete"
# No project param needed — maintenance middleware blocks before the handler runs.
response = client.post(
DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=token,
)
assert response.status_code == http.HTTPStatus.SERVICE_UNAVAILABLE
assert response.json and response.json.get("message") == "Maintenance of DDS is ongoing."

# RetrieveUserInfo - "/user/info"
response = client.get(
DDSEndpoint.USER_INFO,
Expand Down Expand Up @@ -630,6 +639,14 @@ def test_block_if_maintenance_active_superadmin_ok(client: flask.testing.FlaskCl
)
assert response.status_code == http.HTTPStatus.BAD_REQUEST

# ProjectUploadComplete - "/proj/upload/complete"
# No project param needed — role check fires before schema validation.
response = client.post(
DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=token,
)
assert response.status_code == http.HTTPStatus.FORBIDDEN

# RetrieveUserInfo - "/user/info"
response = client.get(
DDSEndpoint.USER_INFO,
Expand Down
147 changes: 147 additions & 0 deletions tests/test_project_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# IMPORTS ################################################################################ IMPORTS #

# Standard library
import http
import datetime

# Installed
import freezegun
import sqlalchemy
from unittest.mock import patch

# Own
from dds_web import db
import tests
from tests.test_files_new import project_row


# TESTS #################################################################################### TESTS #

# ProjectUploadComplete - "/proj/upload/complete"


def test_proj_upload_complete_updates_timestamp(client):
"""POST /proj/upload/complete refreshes date_updated and last_updated_by."""
project_1 = project_row(project_id="file_testing_project")
assert project_1

frozen_before = datetime.datetime(2000, 1, 1, 12, 0, 0)
frozen_after = datetime.datetime(2000, 1, 2, 12, 0, 0)

with freezegun.freeze_time(frozen_before):
project_1.date_updated = frozen_before
db.session.commit()

token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client)

with freezegun.freeze_time(frozen_after):
response = client.post(
tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=token,
query_string={"project": "file_testing_project"},
)
assert response.status_code == http.HTTPStatus.OK
assert response.json.get("message") == "Project upload timestamp updated."

db.session.refresh(project_1)
assert project_1.date_updated == frozen_after
assert project_1.last_updated_by == "unitadmin"


def test_proj_upload_complete_unit_personnel_allowed(client):
"""Unit Personnel (non-admin unit user) can call POST /proj/upload/complete."""
response = client.post(
tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=tests.UserAuth(tests.USER_CREDENTIALS["unituser"]).token(client),
query_string={"project": "file_testing_project"},
)
assert response.status_code == http.HTTPStatus.OK


def test_proj_upload_complete_unauthorized_roles_denied(client):
"""Researcher and Project Owner cannot call POST /proj/upload/complete."""
for role in ("researcher", "projectowner"):
response = client.post(
tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=tests.UserAuth(tests.USER_CREDENTIALS[role]).token(client),
query_string={"project": "file_testing_project"},
)
assert (
response.status_code == http.HTTPStatus.FORBIDDEN
), f"Expected 403 for role '{role}', got {response.status_code}"


def test_proj_upload_complete_missing_project(client):
"""POST /proj/upload/complete returns 400 when project query param is missing."""
response = client.post(
tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client),
)
assert response.status_code == http.HTTPStatus.BAD_REQUEST
assert response.json.get("project", {}).get("message") == "Project ID required."


def test_proj_upload_complete_db_failure(client):
"""POST /proj/upload/complete returns 500 on database error."""
token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client)

with patch("dds_web.db.session.commit") as mock_commit:
mock_commit.side_effect = sqlalchemy.exc.SQLAlchemyError()

response = client.post(
tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=token,
query_string={"project": "file_testing_project"},
)
assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR
assert "Failed to update project timestamp after upload" in response.json["message"]


def test_proj_upload_complete_no_update_if_available(client, boto3_session):
"""POST /proj/upload/complete returns 400 when project status is Available."""
token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client)

# Move project to Available
response = client.post(
tests.DDSEndpoint.PROJECT_STATUS,
headers=token,
query_string={"project": "file_testing_project"},
json={"new_status": "Available"},
)
assert response.status_code == http.HTTPStatus.OK

response = client.post(
tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=token,
query_string={"project": "file_testing_project"},
)
assert response.status_code == http.HTTPStatus.BAD_REQUEST


def test_proj_upload_complete_no_update_if_expired(client, boto3_session, mock_queue_redis):
"""POST /proj/upload/complete returns 400 when project status is Expired."""
token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client)

# Move project to Available then Expired
response = client.post(
tests.DDSEndpoint.PROJECT_STATUS,
headers=token,
query_string={"project": "file_testing_project"},
json={"new_status": "Available"},
)
assert response.status_code == http.HTTPStatus.OK

response = client.post(
tests.DDSEndpoint.PROJECT_STATUS,
headers=token,
query_string={"project": "file_testing_project"},
json={"new_status": "Expired"},
)
assert response.status_code == http.HTTPStatus.OK

response = client.post(
tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE,
headers=token,
query_string={"project": "file_testing_project"},
)
assert response.status_code == http.HTTPStatus.BAD_REQUEST
1 change: 1 addition & 0 deletions tests/tests_v3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ class DDSEndpoint:
PROJECT_BUSY = BASE_ENDPOINT + "/proj/busy"
PROJECT_BUSY_ANY = BASE_ENDPOINT + "/proj/busy/any"
PROJECT_INFO = BASE_ENDPOINT + "/proj/info"
PROJ_UPLOAD_COMPLETE = BASE_ENDPOINT + "/proj/upload/complete"

# Listing urls
LIST_PROJ = BASE_ENDPOINT + "/proj/list"
Expand Down
Loading
Loading