Skip to content

Commit 15e8537

Browse files
authored
Merge branch 'development' into IVS-741-copy-from-ui
2 parents eeaae33 + bc7999b commit 15e8537

28 files changed

+1096
-490
lines changed

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@ jobs:
4848
environment:
4949
name: development
5050

51+
services:
52+
redis:
53+
image: redis:8.4-alpine
54+
options: >-
55+
--health-cmd "redis-cli ping"
56+
--health-interval 10s
57+
--health-timeout 5s
58+
--health-retries 5
59+
ports:
60+
- 6379:6379
61+
volumes:
62+
- redis_data:/data
63+
64+
env:
65+
CELERY_BROKER_URL: redis://localhost:6379/0
66+
5167
steps:
5268
- name: Checkout sources
5369
uses: actions/checkout@v4

.github/workflows/ci_cd.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,22 @@ jobs:
4949
environment:
5050
name: development
5151

52+
services:
53+
redis:
54+
image: redis:8.4-alpine
55+
options: >-
56+
--health-cmd "redis-cli ping"
57+
--health-interval 10s
58+
--health-timeout 5s
59+
--health-retries 5
60+
ports:
61+
- 6379:6379
62+
volumes:
63+
- redis_data:/data
64+
65+
env:
66+
CELERY_BROKER_URL: redis://localhost:6379/0
67+
5268
steps:
5369
- name: Checkout sources
5470
uses: actions/checkout@v4

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ rebuild-frontend:
4646
rebuild-backend:
4747
docker stop backend || true
4848
docker stop worker || true
49+
docker stop scheduler || true
4950
docker rmi --force $$(docker images -q 'buildingsmart/validationsvc-backend:latest' | uniq) || true
5051
docker compose build \
5152
--build-arg GIT_COMMIT_HASH="$$(git rev-parse --short HEAD)" \
@@ -54,6 +55,7 @@ rebuild-backend:
5455
clean:
5556
docker stop backend || true
5657
docker stop worker || true
58+
docker stop scheduler || true
5759
docker stop frontend || true
5860
docker rmi --force $$(docker images -q 'buildingsmart/validationsvc-frontend:latest' | uniq) || true
5961
docker rmi --force $$(docker images -q 'buildingsmart/validationsvc-backend:latest' | uniq) || true
@@ -63,6 +65,7 @@ clean:
6365
clean-all:
6466
docker stop backend || true
6567
docker stop worker || true
68+
docker stop scheduler || true
6669
docker stop frontend || true
6770
docker rmi --force $$(docker images -q 'buildingsmart/validationsvc-frontend' | uniq) || true
6871
docker rmi --force $$(docker images -q 'buildingsmart/validationsvc-backend' | uniq) || true

backend/Makefile

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ start-backend:
4949
start-worker:
5050
$(PYTHON) -m celery --app=core worker --loglevel=DEBUG --concurrency 2 --task-events --hostname=worker@%n
5151

52+
start-worker-with-scheduler:
53+
$(PYTHON) -m celery --app=core worker --loglevel=DEBUG --concurrency 2 --task-events --hostname=worker@%n --beat --scheduler django_celery_beat.schedulers:DatabaseScheduler
54+
5255
start-worker2:
5356
$(PYTHON) -m celery --app=core worker --loglevel=DEBUG --concurrency 2 --task-events --hostname=worker2@%n
5457

@@ -58,9 +61,6 @@ start-worker3:
5861
start-worker4:
5962
$(PYTHON) -m celery --app=core worker --loglevel=DEBUG --concurrency 2 --task-events --hostname=worker4@%n
6063

61-
start-worker-scheduler:
62-
$(PYTHON) -m celery --app=core worker --loglevel=DEBUG --concurrency 5 --task-events --beat
63-
6464
.PHONY: stop-worker
6565
stop-worker:
6666
-$(PYTHON) -m celery -A core control shutdown \
@@ -89,11 +89,17 @@ test-magic-and-av-task:
8989
test-utils:
9090
$(PYTHON) manage.py test core.tests.test_utils --debug-mode --verbosity 3
9191

92-
archive-dry-run:
93-
$(PYTHON) manage.py archive_requests --days 180 --all --dry-run
92+
archive-files:
93+
$(PYTHON) manage.py archive_files --days 90 --all --dry-run
94+
95+
archive-files-confirm:
96+
$(PYTHON) manage.py archive_files --days 90 --all --confirm
97+
98+
remove-files:
99+
$(PYTHON) manage.py remove_files --days 180 --all --dry-run
94100

95-
archive:
96-
$(PYTHON) manage.py archive_requests --days 180 --all --confirm
101+
remove-files-confirm:
102+
$(PYTHON) manage.py remove_files --days 180 --all --confirm
97103

98104
clean:
99105
rm -rf .dev

backend/apps/ifc_validation/admin.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,20 @@ def has_add_permission(self, request):
5959
class ValidationRequestAdmin(BaseAdmin, NonAdminAddable):
6060

6161
fieldsets = [
62-
('General Information', {"classes": ("wide"), "fields": ["id", "public_id", "file_name", "file", "file_size_text", "model", "deleted"]}),
62+
('General Information', {"classes": ("wide"), "fields": ["id", "public_id", "model" ]}),
63+
('File Information', {"classes": ("wide"), "fields": ["file_name", "file", "file_size_text", "deleted", "file_removed" ]}),
6364
('Status Information', {"classes": ("wide"), "fields": ["status", "status_reason", "progress", "started", "completed", "channel" ]}),
6465
('Auditing Information', {"classes": ("wide"), "fields": [("created", "created_by"), ("updated", "updated_by")]})
6566
]
6667

6768
list_display = ["id", "public_id", "file_name", "file_size_text", "authoring_tool_link", "model_link", "status", "progress", "queue_time_text", "duration_text", "is_vendor", "is_vendor_self_declared", "is_deleted", "channel_text", "created", "created_by_link", "updated", "updated_by"]
68-
readonly_fields = ["id", "public_id", "deleted", "file_name", "file", "file_size_text", "duration_text", "started", "completed", "channel", "created", "created_by", "updated", "updated_by"]
69+
readonly_fields = ["id", "public_id", "deleted", "file_name", "file", "file_size_text", "duration_text", "started", "completed", "channel", "created", "created_by", "updated", "updated_by", "file_removed"]
6970
date_hierarchy = "created"
7071

7172
list_filter = [
7273
"status",
73-
"deleted",
74+
"deleted",
75+
"file_removed",
7476
"model__produced_by",
7577
ModelProducedByAdvancedFilter,
7678
"channel",
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import os
2+
import gzip
3+
import shutil
4+
import logging
5+
from datetime import timedelta
6+
7+
from django.core.management.base import BaseCommand
8+
from django.utils import timezone
9+
from django.db import transaction
10+
11+
from apps.ifc_validation_models.models import ValidationRequest
12+
from apps.ifc_validation_models.decorators import requires_django_user_context
13+
14+
from apps.ifc_validation.tasks.utils import get_absolute_file_path
15+
from core.utils import format_human_readable_file_size
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class Command(BaseCommand):
21+
22+
help = (
23+
'Archive or Remove Validation Request files matching certain pruning criteria (eg. age, deletion status).',
24+
'Either ompresses *.ifc files to *.ifc.gz (archive) or removes *.ifc/*.ifc.gz files (remove) and updates database records accordingly.'
25+
)
26+
27+
def add_arguments(self, parser):
28+
29+
# how many days to look back (default: 90)
30+
parser.add_argument(
31+
'--days', '-d',
32+
type=int,
33+
default=90,
34+
help='Number of days to look back for old Validation Requests (default: 90).'
35+
)
36+
37+
# action to perform: archive or remove (default: archive)
38+
parser.add_argument(
39+
'--action',
40+
choices=['archive', 'remove'],
41+
default='archive',
42+
help='Action to perform on matching old files: "archive" (compress to .gz) or "remove" (delete completely). Default: archive'
43+
)
44+
45+
# dry-run mode: perform a simulation, do not modify any files or database records
46+
# just logs intended actions and outcomes to stdout
47+
dry_group = parser.add_mutually_exclusive_group()
48+
dry_group.add_argument(
49+
'--dry-run', '--simulate', '--recon',
50+
dest='dry_run',
51+
action='store_true',
52+
help='Dry run: show what would happen without modifying files or database.'
53+
)
54+
dry_group.add_argument(
55+
'--confirm', '--apply',
56+
dest='dry_run',
57+
action='store_false',
58+
help='Apply the changes (archive/remove files and update DB).'
59+
)
60+
parser.set_defaults(dry_run=True)
61+
62+
@requires_django_user_context
63+
def handle(self, *args, **options):
64+
days = options['days']
65+
action = options['action']
66+
archive = (action == 'archive')
67+
dry_run = options['dry_run']
68+
69+
cutoff_date = timezone.now() - timedelta(days=days)
70+
71+
qs = ValidationRequest.objects.filter(created__lt=cutoff_date)
72+
if archive:
73+
qs = qs.filter(file__iendswith='.ifc').exclude(file__exact='')
74+
else:
75+
qs = qs.filter(file__isnull=False).exclude(file__exact='')
76+
77+
total = qs.count()
78+
mode_str = "archiving" if archive else "removal"
79+
logger.info(f"Found {total} Validation Request(s) older than {days} day(s) eligible for {mode_str}.")
80+
81+
if dry_run:
82+
logger.warning("NOTE: Running in DRY-RUN mode. No changes will be made. Use --confirm to apply.")
83+
84+
processed = 0
85+
skipped = 0
86+
total_savings = 0 # bytes
87+
88+
for request in qs.iterator():
89+
90+
# validate presence of file
91+
try:
92+
file_path = get_absolute_file_path(request.file.name)
93+
original_size = os.path.getsize(file_path)
94+
except FileNotFoundError:
95+
logger.warning(f"File not found for ValidationRequest id={request.id} ({request.file.name}) - skipping")
96+
skipped += 1
97+
continue
98+
99+
# original and target names
100+
original_name = request.file.name
101+
gz_filename = file_path + '.gz'
102+
gz_name_only = original_name + '.gz'
103+
104+
# only report what would happen
105+
if dry_run:
106+
107+
if archive:
108+
msg = f"[DRY-RUN] Would archive {request.file.name}{gz_name_only} (id={request.id})"
109+
total_savings += original_size * 0.80 # rough estimate
110+
else:
111+
msg = f"[DRY-RUN] Would remove {request.file.name} (id={request.id})"
112+
total_savings += original_size
113+
114+
processed += 1
115+
logger.info(msg)
116+
continue
117+
118+
# execute action
119+
if action == 'archive':
120+
121+
try:
122+
# create gzip archive
123+
with open(file_path, 'rb') as f_in, gzip.open(gz_filename, 'wb') as f_out:
124+
shutil.copyfileobj(f_in, f_out)
125+
126+
gz_size = os.path.getsize(gz_filename)
127+
savings = original_size - gz_size
128+
129+
# update database and remove original file
130+
with transaction.atomic():
131+
request.file.name = gz_name_only
132+
request.save(update_fields=['file'])
133+
os.remove(file_path)
134+
135+
logger.info(f"Archived and updated Validation Request with id={request.id}: {gz_name_only} (saved ~{format_human_readable_file_size(savings)})")
136+
total_savings += savings
137+
processed += 1
138+
139+
except Exception as e:
140+
141+
# ensure DB not updated and clean up the created gzip to keep state unchanged
142+
if os.path.exists(gz_filename):
143+
try:
144+
os.remove(gz_filename)
145+
except Exception:
146+
pass
147+
logger.error(f"Failed to archive Validation Request with id={request.id}: {e}")
148+
skipped += 1
149+
150+
elif action == 'remove':
151+
152+
try:
153+
# update database and remove original file
154+
with transaction.atomic():
155+
original_name = request.file.name
156+
request.file = None
157+
request.save(update_fields=['file'])
158+
os.remove(file_path)
159+
160+
logger.info(f"Removed file and updated Validation Request with id={request.id}: {original_name}")
161+
total_savings += original_size
162+
processed += 1
163+
164+
except Exception as e:
165+
logger.error(f"Failed to remove file for id={request.id}: {e}")
166+
skipped += 1
167+
168+
# show summary
169+
savings_str = format_human_readable_file_size(total_savings)
170+
if dry_run:
171+
logger.info(
172+
f"Dry-run summary: would {action} {processed}, skip {skipped}, consider {total}. Estimated space saved: {savings_str}"
173+
)
174+
else:
175+
logger.info(
176+
f"Completed {mode_str} of {processed}, skipped {skipped}, total considered {total}. Freed up {savings_str}."
177+
)

0 commit comments

Comments
 (0)