Skip to content

Commit

Permalink
Merge pull request #250 from makinacorpus/add_block_buttons_edition
Browse files Browse the repository at this point in the history
✨ Add duplication
  • Loading branch information
LePetitTim authored Jan 10, 2023
2 parents dd3b406 + 8ef83c3 commit 47e557b
Show file tree
Hide file tree
Showing 20 changed files with 496 additions and 45 deletions.
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ CHANGELOG

- Support django 4.1
- Add block in detail template to allow overriding attachments navigation tab
- Add blocks for actions buttons for every detail template (after / before other blocks)
- Add duplicate action


8.2.1 (2022-08-16)
Expand Down
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ FROM makinacorpus/geodjango:bionic-3.6
RUN apt-get update -qq && apt-get install -y -qq \
libsqlite3-mod-spatialite \
libjpeg62 libjpeg62-dev zlib1g-dev libcairo2 libpango-1.0-0 \
libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info && \
libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info \
libldap2-dev libsasl2-dev && \
apt-get clean all && rm -rf /var/apt/lists/* && rm -rf /var/cache/apt/*
RUN mkdir -p /code
RUN useradd -ms /bin/bash django
Expand Down
22 changes: 22 additions & 0 deletions mapentity/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from django.conf import settings
from django.contrib.gis.gdal.error import GDALException
from django.contrib.gis.geos import GEOSException, fromstr
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http import HttpResponse
from django.template.exceptions import TemplateDoesNotExist
from django.template.loader import get_template
Expand Down Expand Up @@ -299,3 +300,24 @@ def smart_get_template(model, suffix):
except TemplateDoesNotExist:
pass
return None


def clone_attachment(attachment, field_file, attrs=None):
if attrs is None:
attrs = {}
fields = attachment._meta.get_fields()
clone_values = {}
for field in fields:
if not field.auto_created:
if field.name in attrs.keys():
if callable(attrs.get(field.name)):
clone_values[field.name] = attrs.get(field.name)(getattr(attachment, field.name))
else:
clone_values[field.name] = attrs.get(field.name)
elif field.name == field_file:
attachment_content = getattr(attachment, field_file).read()
attachment_name = getattr(attachment, field_file).name.split("/")[-1]
clone_values[field_file] = SimpleUploadedFile(attachment_name, attachment_content)
else:
clone_values[field.name] = getattr(attachment, field.name, None)
attachment._meta.model.objects.create(**clone_values)
17 changes: 13 additions & 4 deletions mapentity/locale/en/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-20 13:47+0000\n"
"POT-Creation-Date: 2022-11-28 10:43+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
Expand Down Expand Up @@ -78,9 +78,6 @@ msgstr ""
msgid "Internal error"
msgstr ""

msgid "Details"
msgstr ""

msgid "List"
msgstr ""

Expand Down Expand Up @@ -160,6 +157,9 @@ msgstr ""
msgid "History"
msgstr ""

msgid "Duplicate"
msgstr ""

msgid "Update"
msgstr ""

Expand Down Expand Up @@ -238,6 +238,15 @@ msgstr ""
msgid "Your form contains errors"
msgstr ""

msgid "Duplication is not available for this object"
msgstr ""

msgid "has been duplicated successfully"
msgstr ""

msgid "An error occurred during duplication"
msgstr ""

msgid "No map available for this object."
msgstr ""

Expand Down
17 changes: 13 additions & 4 deletions mapentity/locale/fr/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-20 13:47+0000\n"
"POT-Creation-Date: 2022-11-28 10:43+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
Expand Down Expand Up @@ -77,9 +77,6 @@ msgstr "Page non trouvée"
msgid "Internal error"
msgstr "Erreur interne"

msgid "Details"
msgstr "Détails"

msgid "List"
msgstr "Liste"

Expand Down Expand Up @@ -159,6 +156,9 @@ msgstr "Fichiers liés"
msgid "History"
msgstr "Historique"

msgid "Duplicate"
msgstr "Dupliquer"

msgid "Update"
msgstr "Modifier"

Expand Down Expand Up @@ -237,6 +237,15 @@ msgstr "Créé"
msgid "Your form contains errors"
msgstr "Le formulaire contient des erreurs"

msgid "Duplication is not available for this object"
msgstr "La duplication n'est pas disponible pour cet objet."

msgid "has been duplicated successfully"
msgstr "a été dupliqué avec succès"

msgid "An error occurred during duplication"
msgstr "Une erreur s'est produite lors de la duplication"

msgid "No map available for this object."
msgstr "Pas de carte disponible pour cet objet."

Expand Down
77 changes: 73 additions & 4 deletions mapentity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, ObjectDoesNotExist
from django.db import models
from django.db import models, transaction
from django.db.utils import OperationalError
from django.urls import reverse, NoReverseMatch
from django.utils.formats import localize
from django.utils.timezone import utc
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions as rest_permissions

from paperclip.settings import get_attachment_model
from mapentity.templatetags.mapentity_tags import humanize_timesince
from .helpers import smart_urljoin, is_file_uptodate, capture_map_image, extract_attributes_html
from .helpers import smart_urljoin, is_file_uptodate, capture_map_image, extract_attributes_html, clone_attachment
from .settings import app_settings, API_SRID

# Used to create the matching url name
Expand All @@ -30,14 +31,15 @@
ENTITY_MAPIMAGE = "mapimage"
ENTITY_DOCUMENT = "document"
ENTITY_MARKUP = "markup"
ENTITY_DUPLICATE = "duplicate"
ENTITY_CREATE = "add"
ENTITY_UPDATE = "update"
ENTITY_DELETE = "delete"
ENTITY_UPDATE_GEOM = "update_geom"

ENTITY_KINDS = (
ENTITY_LAYER, ENTITY_LIST, ENTITY_DATATABLE_LIST, ENTITY_FORMAT_LIST, ENTITY_DETAIL, ENTITY_MAPIMAGE,
ENTITY_DOCUMENT, ENTITY_MARKUP, ENTITY_CREATE, ENTITY_UPDATE, ENTITY_DELETE, ENTITY_UPDATE_GEOM
ENTITY_DOCUMENT, ENTITY_MARKUP, ENTITY_CREATE, ENTITY_DUPLICATE, ENTITY_UPDATE, ENTITY_DELETE, ENTITY_UPDATE_GEOM
)

ENTITY_PERMISSION_CREATE = 'add'
Expand Down Expand Up @@ -69,7 +71,72 @@ class MapEntityRestPermissions(rest_permissions.DjangoModelPermissions):
}


class BaseMapEntityMixin(models.Model):
class DuplicateMixin(object):
can_duplicate = True

def get_duplicate_url(self):
if self.can_duplicate:
return reverse(self._entity.url_name(ENTITY_DUPLICATE), args=[str(self.pk)])
return None

def duplicate(self, **kwargs):
if not self.can_duplicate:
return None
sid = transaction.savepoint()
try:
avoid_fields = kwargs.pop('avoid_fields', [])
attachments = kwargs.pop('attachments', {})
skip_attachments = kwargs.pop('skip_attachments', False)
clone = self._meta.model.objects.get(pk=self.pk)
clone.pk = None
setattr(clone, clone._meta.pk.name, None)
for key, value in kwargs.items():
if key not in avoid_fields:
if callable(value):
setattr(clone, key, value(getattr(self, key)))
else:
setattr(clone, key, value)
clone.save()

# Scan fields to get relations
fields = clone._meta.get_fields()
for field in fields:
if field.name not in avoid_fields:
# Manage M2M fields by replicating all related records
# found on parent "obj" into "clone"
if not field.auto_created and field.many_to_many:
for row in getattr(self, field.name).all():
getattr(clone, field.name).add(row)

# Manage 1-N and 1-1 relations by cloning child objects
if field.auto_created and field.is_relation:
if field.many_to_many:
# do nothing
pass
else:
# provide "clone" object to replace "obj"
# on remote field
attrs = {
field.remote_field.name: clone,
'skip_attachments': True
}
children = field.related_model.objects.filter(**{field.remote_field.name: self})

for child in children:
child.duplicate(**attrs)

if not skip_attachments:
for attachment in get_attachment_model().objects.filter(object_id=self.pk):
attachments["content_object"] = clone
clone_attachment(attachment, 'attachment_file', attachments)
transaction.savepoint_commit(sid)
except Exception as exc:
transaction.savepoint_rollback(sid)
raise exc
return clone


class BaseMapEntityMixin(DuplicateMixin, models.Model):
_entity = None
capture_map_image_waitfor = '.leaflet-tile-loaded'

Expand All @@ -88,6 +155,7 @@ def get_create_label(cls):
def get_entity_kind_permission(cls, entity_kind):
operations = {
ENTITY_CREATE: ENTITY_PERMISSION_CREATE,
ENTITY_DUPLICATE: ENTITY_PERMISSION_CREATE,
ENTITY_UPDATE: ENTITY_PERMISSION_UPDATE,
ENTITY_UPDATE_GEOM: ENTITY_PERMISSION_UPDATE_GEOM,
ENTITY_DELETE: ENTITY_PERMISSION_DELETE,
Expand Down Expand Up @@ -276,6 +344,7 @@ class Meta:
class LogEntry(BaseMapEntityMixin, BaseLogEntry):
geom = None
object_verbose_name = _("object")
can_duplicate = False

class Meta:
proxy = True
Expand Down
4 changes: 4 additions & 0 deletions mapentity/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ def _url_path(self, view_kind):
kind_to_urlpath[mapentity_models.ENTITY_DOCUMENT] = r'^document/{modelname}-(?P<pk>\d+).pdf$'
else:
kind_to_urlpath[mapentity_models.ENTITY_DOCUMENT] = r'^document/{modelname}-(?P<pk>\d+).odt$'
if self.model.can_duplicate:
kind_to_urlpath[mapentity_models.ENTITY_DUPLICATE] = r'^{modelname}/duplicate/(?P<pk>\d+)/$'
if view_kind == mapentity_models.ENTITY_DUPLICATE and not self.model.can_duplicate:
return
url_path = kind_to_urlpath[view_kind]
url_path = url_path.format(modelname=self.modelname)
return url_path
Expand Down
23 changes: 18 additions & 5 deletions mapentity/templates/mapentity/mapentity_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,24 @@ <h1 class="details-title">{{ object }}</h1>
<span class="d-none d-sm-inline">{% trans "History" %}</span></a>
</li>
{% endif %}
{% if can_edit %}
<a class="btn btn-primary ml-auto" href="{{ object.get_update_url }}"><i class="bi bi-pencil-square"></i> {% trans "Update" %}</a>
{% else %}
<span class="btn disabled ml-auto" href="#"><i class="bi bi-pencil-square"></i> {% trans "Update" %}</span>
{% endif %}
<div class="btn-group ml-auto" role="group">
{% block buttons_before_action %}
{% endblock %}
{% if can_add and model.can_duplicate %}
<form class="btn-success ml-auto" action="{{ object.get_duplicate_url }}" method="post">{% csrf_token %}
<button class="btn btn-success ml-auto" type="submit"><i class="bi bi-files"></i> {% trans "Duplicate" %}</button>
</form>
{% else %}
<span class="btn disabled ml-auto" href="#"><i class="bi bi-files"></i> {% trans "Duplicate" %}</span>
{% endif %}
{% if can_edit %}
<a class="btn btn-primary ml-auto" href="{{ object.get_update_url }}"><i class="bi bi-pencil-square"></i> {% trans "Update" %}</a>
{% else %}
<span class="btn disabled ml-auto" href="#"><i class="bi bi-pencil-square"></i> {% trans "Update" %}</span>
{% endif %}
{% block buttons_after_action %}
{% endblock %}
</div>
</ul>
<div class="tab-content scrollable">

Expand Down
58 changes: 57 additions & 1 deletion mapentity/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase, LiveServerTestCase
from django.test.testcases import to_list
Expand All @@ -23,11 +25,13 @@
from django.utils.translation import gettext_lazy as _
from freezegun import freeze_time

from .factories import SuperUserFactory
from .factories import AttachmentFactory, SuperUserFactory, UserFactory
from ..forms import MapEntityForm
from ..helpers import smart_urljoin
from ..settings import app_settings

from paperclip.settings import get_attachment_model


class AdjustDebugLevel:
def __init__(self, name, level):
Expand Down Expand Up @@ -229,6 +233,58 @@ def _check_update_geom_permission(self, response):
else:
self.assertIn(b'.modifiable = false;', response.content)

def test_duplicate(self):
if self.model is None or not self.model.can_duplicate:
return # Abstract test should not run
user = UserFactory()
obj = self.modelfactory.create()
for perm in Permission.objects.exclude(codename=f'add_{obj._meta.model_name}'):
user.user_permissions.add(perm)
self.client.force_login(user=user)

AttachmentFactory.create(content_object=obj, title='attachment')

self.assertEqual(obj._meta.model.objects.count(), 1)
self.assertEqual(get_attachment_model().objects.count(), 1)

response = self.client.post(obj.get_duplicate_url())
self.assertEqual(response.status_code, 403)
self.assertEqual(obj._meta.model.objects.count(), 1)
self.assertEqual(get_attachment_model().objects.count(), 1)

user.user_permissions.add(Permission.objects.get(codename=f'add_{obj._meta.model_name}'))
self.client.force_login(user=user)

response = self.client.post(obj.get_duplicate_url())
self.assertEqual(response.status_code, 302)
self.assertEqual(obj._meta.model.objects.count(), 2)
self.assertEqual(get_attachment_model().objects.count(), 2)

msg = [str(message) for message in messages.get_messages(response.wsgi_request)]
self.assertIn(f"{self.model._meta.verbose_name} has been duplicated successfully", msg)

with patch('mapentity.models.DuplicateMixin.duplicate') as mocked:
mocked.side_effect = Exception('Error')
response = self.client.post(obj.get_duplicate_url())

self.assertEqual(response.status_code, 302)
self.assertEqual(obj._meta.model.objects.count(), 2)
self.assertEqual(get_attachment_model().objects.count(), 2)

msg = [str(message) for message in messages.get_messages(response.wsgi_request)]
self.assertIn("An error occurred during duplication", msg)

with patch('mapentity.models.DuplicateMixin.duplicate') as mocked:
mocked.return_value = None
response = self.client.post(obj.get_duplicate_url())

self.assertEqual(response.status_code, 302)
self.assertEqual(obj._meta.model.objects.count(), 2)
self.assertEqual(get_attachment_model().objects.count(), 2)

msg = [str(message) for message in messages.get_messages(response.wsgi_request)]
self.assertIn("An error occurred during duplication", msg)

def test_crud_status(self):
if self.model is None:
return # Abstract test should not run
Expand Down
Loading

0 comments on commit 47e557b

Please sign in to comment.