Skip to content

Commit

Permalink
Add Secrets to the admin page (#939)
Browse files Browse the repository at this point in the history
* Add Secrets to the admin page

* Apply suggestions from code review

Co-authored-by: Ivan Ivanov <[email protected]>

* Fix syntax errors

* Switch to regex based env var validation

* Make the project field an autocomplete field (fixes performance of add form)

* Fix linting errors

* Remove regex based value validation

* Add validate_pg_service_conf

* Ensure project is the first field in the ProjectSecretForm

* Apply suggestions from code review

Co-authored-by: Ivan Ivanov <[email protected]>

* Apply suggestions from code review

Co-authored-by: Ivan Ivanov <[email protected]>

* Rename secret util to pg_service_file

* Fixes from review and ProjectSecretInline

* Prefer using `admin` decorator

* Hack a way to add extra HTML in `InlineTabular` admin templates

* Secret Admin: Fix "Created by" column name

* Fix linting errors

---------

Co-authored-by: Ivan Ivanov <[email protected]>
Co-authored-by: Ivan Ivanov <[email protected]>
  • Loading branch information
3 people authored May 27, 2024
1 parent 46a29ef commit 1e9868a
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 3 deletions.
146 changes: 143 additions & 3 deletions docker-app/qfieldcloud/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from django.contrib import admin, messages
from django.contrib.admin.templatetags.admin_urls import admin_urlname
from django.contrib.admin.views.main import ChangeList
from django.core.exceptions import PermissionDenied
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Q, QuerySet
from django.db.models.fields.json import JSONField
from django.db.models.functions import Lower
Expand All @@ -31,6 +31,7 @@
from django.urls import path, reverse
from django.utils.decorators import method_decorator
from django.utils.html import escape, format_html
from django.utils.http import urlencode
from django.utils.safestring import SafeText
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
Expand All @@ -48,14 +49,15 @@
Person,
Project,
ProjectCollaborator,
Secret,
Team,
TeamMember,
User,
UserAccount,
)
from qfieldcloud.core.paginators import LargeTablePaginator
from qfieldcloud.core.templatetags.filters import filesizeformat10
from qfieldcloud.core.utils2 import delta_utils, jobs
from qfieldcloud.core.utils2 import delta_utils, jobs, pg_service_file
from rest_framework.authtoken.models import TokenProxy

admin.site.unregister(LogEntry)
Expand Down Expand Up @@ -115,6 +117,15 @@ def has_delete_permission(self, request, obj=None):
return super().has_delete_permission(request, obj)


class QFieldCloudInlineAdmin(admin.TabularInline):
template = "admin/edit_inline/tabular_customized.html"

def get_formset(self, request, obj=None, **kwargs):
self.parent_obj = obj

return super().get_formset(request, obj, **kwargs)


def admin_urlname_by_obj(value, arg):
if isinstance(value, User):
if value.is_person:
Expand Down Expand Up @@ -584,6 +595,134 @@ def queryset(self, request, queryset):
return queryset.filter(owner__type=value)


class ProjectSecretForm(ModelForm):
class Meta:
model = Secret
fields = ("project", "name", "type", "value", "created_by")

name = fields.CharField(widget=widgets.TextInput)
value = fields.CharField(widget=widgets.Textarea)

def get_initial_for_field(self, field, field_name):
if self.instance.pk and field_name == "value":
return ""
return super().get_initial_for_field(field, field_name)

def clean(self):
cleaned_data = super().clean()

if self.instance.pk:
type = self.instance.type
else:
type = cleaned_data.get("type")
if type == Secret.Type.PGSERVICE:
# validate the pg_service.conf
value = cleaned_data.get("value")
if value:
try:
pg_service_file.validate_pg_service_conf(value)
except ValidationError as err:
raise ValidationError({"value": err.message})

# ensure name with PGSERVICE_SECRET_NAME_PREFIX
name = cleaned_data.get("name")
if name and not name.startswith(
pg_service_file.PGSERVICE_SECRET_NAME_PREFIX
):
cleaned_data[
"name"
] = f"{pg_service_file.PGSERVICE_SECRET_NAME_PREFIX}{name}"

return cleaned_data


class SecretAdmin(QFieldCloudModelAdmin):
model = Secret
form = ProjectSecretForm
fields = ("project", "name", "type", "value", "created_by")
readonly_fields = ("created_by",)
list_display = ("name", "type", "created_by__link", "project__name")
autocomplete_fields = ("project",)

search_fields = (
"name__icontains",
"project__name__icontains",
)

@admin.display(ordering="created_by")
def created_by__link(self, instance):
return model_admin_url(instance.created_by)

created_by__link.short_description = "Created by" # type: ignore

@admin.display(ordering="project__name")
def project__name(self, instance):
return model_admin_url(instance.project, instance.project.name)

def get_readonly_fields(self, request, obj=None):
readonly_fields = super().get_readonly_fields(request, obj)

if obj:
return (*readonly_fields, "name", "type", "project")

return readonly_fields

def save_model(self, request, obj, form, change):
# only set created_by during the first save
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)

def get_changeform_initial_data(self, request):
project_id = request.GET.get("project_id")

if project_id:
project = Project.objects.get(id=project_id)
else:
project = None

return {"project": project}


class ProjectSecretInline(QFieldCloudInlineAdmin):
model = Secret
fields = ("link_to_secret", "type", "created_by")
readonly_fields = ("link_to_secret",)
max_num = 0
extra = 0

@admin.display(description=_("Name"))
def link_to_secret(self, obj):
url = reverse("admin:core_secret_change", args=[obj.pk])
return format_html('<a href="{}">{}</a>', url, obj.name)

def has_add_permission(self, request, obj=None):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False

@property
def bottom_html(self):
if self.parent_obj:
return format_html(
"""
<a href="{url}?{query_params}" class="btn btn-default form-control">
<i class="fa fa-plus-circle"></i>
{text}
</a>
""",
url=reverse("admin:core_secret_add"),
query_params=urlencode({"project_id": self.parent_obj.pk}),
text="Add Secret",
)
else:
return ""


class ProjectForm(ModelForm):
project_files = fields.CharField(
disabled=True, required=False, widget=ProjectFilesWidget
Expand Down Expand Up @@ -642,7 +781,7 @@ class ProjectAdmin(QFieldCloudModelAdmin):
"data_last_packaged_at",
"project_details__pre",
)
inlines = (ProjectCollaboratorInline,)
inlines = (ProjectCollaboratorInline, ProjectSecretInline)
search_fields = (
"id",
"name__icontains",
Expand Down Expand Up @@ -1368,6 +1507,7 @@ class LogEntryAdmin(
admin.site.register(Organization, OrganizationAdmin)
admin.site.register(Team, TeamAdmin)
admin.site.register(Project, ProjectAdmin)
admin.site.register(Secret, SecretAdmin)
admin.site.register(Delta, DeltaAdmin)
admin.site.register(Job, JobAdmin)
admin.site.register(Geodb, GeodbAdmin)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% if qfc_admin_inline_included != 1 %}
{% include "admin/edit_inline/tabular_extended.html" with qfc_admin_inline_included=1 %}
{% endif %}

{{ fieldset.opts.bottom_html }}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% extends "admin/edit_inline/tabular.html" %}
24 changes: 24 additions & 0 deletions docker-app/qfieldcloud/core/utils2/pg_service_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import configparser
import io

from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _

PGSERVICE_SECRET_NAME_PREFIX = "PG_SERVICE_"


def validate_pg_service_conf(value: str) -> None:
"""Checks if a string is a valid `pg_service.conf` file contents, otherwise throws a `ValueError`"""
try:
buffer = io.StringIO(value)
config = configparser.ConfigParser()
config.readfp(buffer)

if len(config.sections()) != 1:
raise ValidationError(
_("The `.pg_service.conf` must have exactly one service definition.")
)
except ValidationError as err:
raise err
except Exception:
raise ValidationError(_("Failed to parse the `.pg_service.conf` file."))

0 comments on commit 1e9868a

Please sign in to comment.