Skip to content

Commit 3993290

Browse files
authored
Merge pull request #1920 from aboutcode-org/pipeline-dashboard
Pipeline Dashboard improvements
2 parents d8e865f + 94e95cc commit 3993290

20 files changed

+279
-103
lines changed

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ decorator==5.1.1
2828
defusedxml==0.7.1
2929
distro==1.7.0
3030
Django==4.2.22
31+
django-altcha==0.2.0
3132
django-crispy-forms==2.3
3233
django-environ==0.11.2
3334
django-filter==24.3
34-
django-recaptcha==4.0.0
3535
django-widget-tweaks==1.5.0
3636
djangorestframework==3.15.2
3737
doc8==0.11.1

setup.cfg

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ install_requires =
6565
crispy-bootstrap4>=2024.1
6666
django-environ>=0.11.0
6767
gunicorn>=23.0.0
68+
django-altcha==0.2.0
6869

6970
# for the API doc
7071
drf-spectacular[sidecar]>=0.24.2
@@ -101,8 +102,6 @@ install_requires =
101102
python-dotenv
102103
texttable
103104

104-
django-recaptcha>=4.0.0
105-
106105

107106
[options.extras_require]
108107
dev =

vulnerabilities/admin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
from vulnerabilities.models import VulnerabilityReference
2222
from vulnerabilities.models import VulnerabilitySeverity
2323

24+
admin.site.site_header = "VulnerableCode Administration"
25+
admin.site.site_title = "VulnerableCode Admin Portal"
26+
admin.site.index_title = "Welcome to VulnerableCode Management"
27+
2428

2529
@admin.register(Vulnerability)
2630
class VulnerabilityAdmin(admin.ModelAdmin):
@@ -125,6 +129,7 @@ def __init__(self, *args, **kwargs):
125129

126130
def save(self, commit=True):
127131
group = super().save(commit=commit)
132+
group.save()
128133
self.save_m2m()
129134
group.user_set.set(self.cleaned_data["users"])
130135
return group

vulnerabilities/api_v2.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -769,15 +769,15 @@ def has_permission(self, request, view):
769769

770770
class PipelineRunAPISerializer(serializers.HyperlinkedModelSerializer):
771771
status = serializers.SerializerMethodField()
772-
execution_time = serializers.SerializerMethodField()
772+
runtime = serializers.SerializerMethodField()
773773
log = serializers.SerializerMethodField()
774774

775775
class Meta:
776776
model = PipelineRun
777777
fields = [
778778
"run_id",
779779
"status",
780-
"execution_time",
780+
"runtime",
781781
"run_start_date",
782782
"run_end_date",
783783
"run_exitcode",
@@ -791,9 +791,9 @@ class Meta:
791791
def get_status(self, run):
792792
return run.status
793793

794-
def get_execution_time(self, run):
795-
if run.execution_time:
796-
return round(run.execution_time, 2)
794+
def get_runtime(self, run):
795+
if run.runtime:
796+
return f"{round(run.runtime, 2)}s"
797797

798798
def get_log(self, run):
799799
"""Return only last 5000 character of log."""
@@ -802,7 +802,8 @@ def get_log(self, run):
802802

803803
class PipelineScheduleAPISerializer(serializers.HyperlinkedModelSerializer):
804804
url = serializers.HyperlinkedIdentityField(
805-
view_name="schedule-detail", lookup_field="pipeline_id"
805+
view_name="pipelines-detail",
806+
lookup_field="pipeline_id",
806807
)
807808
latest_run = serializers.SerializerMethodField()
808809
next_run_date = serializers.SerializerMethodField()
@@ -830,6 +831,12 @@ def get_latest_run(self, schedule):
830831
return PipelineRunAPISerializer(latest).data
831832
return None
832833

834+
def to_representation(self, schedule):
835+
representation = super().to_representation(schedule)
836+
representation["run_interval"] = f"{schedule.run_interval}hr"
837+
representation["execution_timeout"] = f"{schedule.execution_timeout}hr"
838+
return representation
839+
833840

834841
class PipelineScheduleCreateSerializer(serializers.ModelSerializer):
835842
class Meta:
@@ -883,6 +890,11 @@ def get_permissions(self):
883890
return [IsAdminWithSessionAuth()]
884891
return super().get_permissions()
885892

893+
def get_view_name(self):
894+
if self.detail:
895+
return "Pipeline Instance"
896+
return "Pipeline Jobs"
897+
886898

887899
class AdvisoriesPackageV2ViewSet(viewsets.ReadOnlyModelViewSet):
888900
queryset = PackageV2.objects.all().prefetch_related(

vulnerabilities/forms.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
from django import forms
1111
from django.contrib.admin.forms import AdminAuthenticationForm
1212
from django.core.validators import validate_email
13-
from django_recaptcha.fields import ReCaptchaField
14-
from django_recaptcha.widgets import ReCaptchaV2Checkbox
13+
from django_altcha import AltchaField
1514

1615
from vulnerabilities.models import ApiUser
1716

@@ -45,12 +44,12 @@ class AdvisorySearchForm(forms.Form):
4544

4645

4746
class ApiUserCreationForm(forms.ModelForm):
48-
"""
49-
Support a simplified creation for API-only users directly from the UI.
50-
"""
47+
"""Support a simplified creation for API-only users directly from the UI."""
5148

52-
captcha = ReCaptchaField(
53-
error_messages={"required": ("Captcha is required")}, widget=ReCaptchaV2Checkbox
49+
captcha = AltchaField(
50+
floating=True,
51+
hidefooter=True,
52+
hidelogo=True,
5453
)
5554

5655
class Meta:
@@ -110,9 +109,8 @@ class PipelineSchedulePackageForm(forms.Form):
110109

111110

112111
class AdminLoginForm(AdminAuthenticationForm):
113-
captcha = ReCaptchaField(
114-
error_messages={
115-
"required": ("Captcha is required"),
116-
},
117-
widget=ReCaptchaV2Checkbox(),
112+
captcha = AltchaField(
113+
floating=True,
114+
hidefooter=True,
115+
hidelogo=True,
118116
)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Generated by Django 4.2.22 on 2025-06-24 16:33
2+
3+
import django.core.validators
4+
from django.db import migrations, models
5+
from datetime import datetime
6+
from datetime import timezone
7+
8+
from aboutcode.pipeline import LoopProgress
9+
10+
class Migration(migrations.Migration):
11+
def update_old_interval(apps, schema_editor):
12+
Pipeline = apps.get_model("vulnerabilities", "PipelineSchedule")
13+
pipeline_schedule = Pipeline.objects.all()
14+
15+
log(f"\nUpdating interval for {pipeline_schedule.count():,d} pipeline")
16+
progress = LoopProgress(
17+
total_iterations=pipeline_schedule.count(),
18+
progress_step=10,
19+
logger=log,
20+
)
21+
for pipeline in progress.iter(pipeline_schedule.iterator()):
22+
pipeline.run_interval = pipeline.run_interval * 24
23+
pipeline.save()
24+
25+
def reverse_update_old_interval(apps, schema_editor):
26+
Pipeline = apps.get_model("vulnerabilities", "PipelineSchedule")
27+
pipeline_schedule = Pipeline.objects.all()
28+
29+
log(f"\nReverse interval for {pipeline_schedule.count():,d} pipeline")
30+
progress = LoopProgress(
31+
total_iterations=pipeline_schedule.count(),
32+
progress_step=10,
33+
logger=log,
34+
)
35+
for pipeline in progress.iter(pipeline_schedule.iterator()):
36+
pipeline.run_interval = pipeline.run_interval / 24
37+
pipeline.save()
38+
39+
dependencies = [
40+
("vulnerabilities", "0095_alter_apiuser_options"),
41+
]
42+
43+
operations = [
44+
migrations.AlterField(
45+
model_name="pipelineschedule",
46+
name="run_interval",
47+
field=models.PositiveSmallIntegerField(
48+
default=24,
49+
help_text="Number of hours to wait between run of this pipeline.",
50+
validators=[
51+
django.core.validators.MinValueValidator(
52+
1, message="Interval must be at least 1 hour."
53+
),
54+
django.core.validators.MaxValueValidator(
55+
8760, message="Interval must be at most 8760 hours."
56+
),
57+
],
58+
),
59+
),
60+
migrations.RunPython(
61+
code=update_old_interval,
62+
reverse_code=reverse_update_old_interval,
63+
),
64+
]
65+
66+
67+
def log(message):
68+
now_local = datetime.now(timezone.utc).astimezone()
69+
timestamp = now_local.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
70+
message = f"{timestamp} {message}"
71+
print(message)

vulnerabilities/models.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2023,6 +2023,7 @@ class Meta:
20232023

20242024
class Status(models.TextChoices):
20252025
UNKNOWN = "unknown"
2026+
DISABLED = "disabled"
20262027
RUNNING = "running"
20272028
SUCCESS = "success"
20282029
FAILURE = "failure"
@@ -2115,7 +2116,7 @@ def run_running(self):
21152116
return self.status == self.Status.RUNNING
21162117

21172118
@property
2118-
def execution_time(self):
2119+
def runtime(self):
21192120
"""Return the pipeline execution time."""
21202121
if not self.run_start_date or (not self.run_end_date and not self.run_running):
21212122
return
@@ -2278,11 +2279,11 @@ class PipelineSchedule(models.Model):
22782279

22792280
run_interval = models.PositiveSmallIntegerField(
22802281
validators=[
2281-
MinValueValidator(1, message="Interval must be at least 1 day."),
2282-
MaxValueValidator(365, message="Interval must be at most 365 days."),
2282+
MinValueValidator(1, message="Interval must be at least 1 hour."),
2283+
MaxValueValidator(8760, message="Interval must be at most 8760 hours."),
22832284
],
2284-
default=1,
2285-
help_text=("Number of days to wait between run of this pipeline."),
2285+
default=24,
2286+
help_text=("Number of hours to wait between run of this pipeline."),
22862287
)
22872288

22882289
schedule_work_id = models.CharField(
@@ -2360,14 +2361,21 @@ def latest_run_date(self):
23602361
latest_run = self.pipelineruns.values("run_start_date").first()
23612362
return latest_run["run_start_date"]
23622363

2364+
@property
2365+
def latest_run_end_date(self):
2366+
if not self.pipelineruns.exists():
2367+
return
2368+
latest_run = self.pipelineruns.values("run_end_date").first()
2369+
return latest_run["run_end_date"]
2370+
23632371
@property
23642372
def next_run_date(self):
23652373
if not self.is_active:
23662374
return
23672375

23682376
current_date_time = datetime.datetime.now(tz=datetime.timezone.utc)
23692377
if self.latest_run_date:
2370-
next_execution = self.latest_run_date + datetime.timedelta(days=self.run_interval)
2378+
next_execution = self.latest_run_date + datetime.timedelta(hours=self.run_interval)
23712379
if next_execution > current_date_time:
23722380
return next_execution
23732381

@@ -2376,7 +2384,7 @@ def next_run_date(self):
23762384
@property
23772385
def status(self):
23782386
if not self.is_active:
2379-
return
2387+
return PipelineRun.Status.DISABLED
23802388

23812389
if self.pipelineruns.exists():
23822390
latest = self.pipelineruns.only("pk").first()

vulnerabilities/schedules.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from redis.exceptions import ConnectionError
1515

1616
from vulnerabilities.tasks import enqueue_pipeline
17-
from vulnerablecode.settings import VULNERABLECODE_PIPELINE_TIMEOUT
1817

1918
log = logging.getLogger(__name__)
2019
scheduler = django_rq.get_scheduler()
@@ -29,14 +28,13 @@ def schedule_execution(pipeline_schedule, execute_now=False):
2928
if not execute_now:
3029
first_execution = pipeline_schedule.next_run_date
3130

32-
interval_in_seconds = pipeline_schedule.run_interval * 24 * 60 * 60
31+
interval_in_seconds = pipeline_schedule.run_interval * 60 * 60
3332

3433
job = scheduler.schedule(
3534
scheduled_time=first_execution,
3635
func=enqueue_pipeline,
3736
args=[pipeline_schedule.pipeline_id],
3837
interval=interval_in_seconds,
39-
result_ttl=f"{VULNERABLECODE_PIPELINE_TIMEOUT}h",
4038
repeat=None,
4139
)
4240
return job._id

vulnerabilities/tasks.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ def set_run_failure(job, connection, type, value, traceback):
106106

107107
def enqueue_pipeline(pipeline_id):
108108
pipeline_schedule = models.PipelineSchedule.objects.get(pipeline_id=pipeline_id)
109+
if pipeline_schedule.status in [
110+
models.PipelineRun.Status.RUNNING,
111+
models.PipelineRun.Status.QUEUED,
112+
]:
113+
logger.warning(
114+
(
115+
f"Cannot enqueue a new execution for {pipeline_id} "
116+
"until the previous one has finished."
117+
)
118+
)
119+
return
120+
109121
run = models.PipelineRun.objects.create(
110122
pipeline=pipeline_schedule,
111123
)

vulnerabilities/templates/admin_login.html

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,38 @@
33

44
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" href="{% static "admin/css/login.css" %}">
55
{{ form.media }}
6+
7+
<style>
8+
/* Altcha theme correction for dark mode */
9+
10+
html[data-theme="dark"] altcha-widget {
11+
--altcha-color-base: #1e1e1e;
12+
--altcha-color-text: #ffffff;
13+
--altcha-color-border: #444;
14+
--altcha-border-radius: 8px;
15+
--altcha-border-width: 2px;
16+
--altcha-color-footer-bg: #2a2a2a;
17+
}
18+
19+
html[data-theme="light"] altcha-widget {
20+
--altcha-color-base: #ffffff;
21+
--altcha-color-text: #000000;
22+
--altcha-color-border: #ccc;
23+
--altcha-border-radius: 8px;
24+
--altcha-color-footer-bg: #f9f9f9;
25+
}
26+
27+
@media (prefers-color-scheme: dark) {
28+
altcha-widget {
29+
--altcha-color-base: #1e1e1e;
30+
--altcha-color-text: #ffffff;
31+
--altcha-color-border: #444;
32+
--altcha-border-radius: 8px;
33+
--altcha-border-width: 2px;
34+
--altcha-color-footer-bg: #2a2a2a;
35+
}
36+
}
37+
</style>
638
{% endblock %}
739

840
{% block bodyclass %}{{ block.super }} login{% endblock %}
@@ -57,10 +89,8 @@
5789
<a href="{{ password_reset_url }}">{% translate 'Forgotten your password or username?' %}</a>
5890
</div>
5991
{% endif %}
60-
<div class="field" style="padding-top: 1rem; text-align: center;">
61-
<div class="control" style="display: inline-block;">
62-
{{ form.captcha }}
63-
</div>
92+
<div class="field" style="padding-top: 0.5rem;">
93+
{{ form.captcha }}
6494
</div>
6595
<div class="submit-row">
6696
<input type="submit" value="{% translate 'Log in' %}">

0 commit comments

Comments
 (0)