From 8ff8c511ba162cb75f42d0464dcc508b9f22ff48 Mon Sep 17 00:00:00 2001
From: Serghei Mihai <smihai@entrouvert.com>
Date: Thu, 10 Mar 2022 17:39:47 +0100
Subject: [PATCH 1/2] misc: add logo and text color for service and OU

---
 .../migrations/0027_auto_20220331_1521.py     | 31 +++++++++++++++++++
 src/authentic2/a2_rbac/models.py              |  9 ++++++
 src/authentic2/manager/forms.py               |  3 ++
 .../migrations/0037_auto_20220331_1513.py     | 31 +++++++++++++++++++
 src/authentic2/models.py                      | 12 +++++++
 src/authentic2/validators.py                  |  9 ++++++
 tests/idp_oidc/test_misc.py                   |  4 +++
 tests/test_validators.py                      | 13 +++++++-
 8 files changed, 111 insertions(+), 1 deletion(-)
 create mode 100644 src/authentic2/a2_rbac/migrations/0027_auto_20220331_1521.py
 create mode 100644 src/authentic2/migrations/0037_auto_20220331_1513.py

diff --git a/src/authentic2/a2_rbac/migrations/0027_auto_20220331_1521.py b/src/authentic2/a2_rbac/migrations/0027_auto_20220331_1521.py
new file mode 100644
index 000000000..3378197ef
--- /dev/null
+++ b/src/authentic2/a2_rbac/migrations/0027_auto_20220331_1521.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.2.24 on 2022-03-31 13:21
+
+from django.db import migrations, models
+
+import authentic2.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('a2_rbac', '0026_add_roleparenting_soft_delete'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='organizationalunit',
+            name='colour',
+            field=models.CharField(
+                blank=True,
+                max_length=32,
+                null=True,
+                validators=[authentic2.validators.HexaColourValidator()],
+                verbose_name='Colour',
+            ),
+        ),
+        migrations.AddField(
+            model_name='organizationalunit',
+            name='logo',
+            field=models.ImageField(blank=True, upload_to='services/logos', verbose_name='Logo'),
+        ),
+    ]
diff --git a/src/authentic2/a2_rbac/models.py b/src/authentic2/a2_rbac/models.py
index 48d150a9f..53f0acf69 100644
--- a/src/authentic2/a2_rbac/models.py
+++ b/src/authentic2/a2_rbac/models.py
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import os
 from collections import namedtuple
 
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
@@ -28,6 +29,7 @@ from django.utils.translation import ugettext_lazy as _
 
 from authentic2.decorators import errorcollector
 from authentic2.utils.cache import GlobalCache
+from authentic2.validators import HexaColourValidator
 from django_rbac import utils as rbac_utils
 from django_rbac.models import (
     VIEW_OP,
@@ -113,6 +115,10 @@ class OrganizationalUnit(OrganizationalUnitAbstractBase):
         default=760,  # two years + 1 month
     )
     home_url = models.URLField(verbose_name=_('Home URL'), max_length=256, null=True, blank=True)
+    logo = models.ImageField(verbose_name=_('Logo'), blank=True, upload_to='services/logos')
+    colour = models.CharField(
+        verbose_name=_('Colour'), null=True, blank=True, max_length=32, validators=[HexaColourValidator()]
+    )
 
     objects = managers.OrganizationalUnitManager()
 
@@ -166,6 +172,9 @@ class OrganizationalUnit(OrganizationalUnitAbstractBase):
         )
 
     def delete(self, *args, **kwargs):
+        if self.logo and os.path.exists(self.logo.path):
+            os.unlink(self.logo.path)
+
         Permission.objects.filter(ou=self).delete()
         return super(OrganizationalUnitAbstractBase, self).delete(*args, **kwargs)
 
diff --git a/src/authentic2/manager/forms.py b/src/authentic2/manager/forms.py
index b6163d6f1..19781c1d5 100644
--- a/src/authentic2/manager/forms.py
+++ b/src/authentic2/manager/forms.py
@@ -633,6 +633,7 @@ class OUEditForm(SlugMixin, CssClass, forms.ModelForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.fields['name'].label = _('label').title()
+        self.fields['colour'].widget = forms.TextInput(attrs={'type': 'color'})
 
     class Meta:
         model = OrganizationalUnit
@@ -650,6 +651,8 @@ class OUEditForm(SlugMixin, CssClass, forms.ModelForm):
             'clean_unused_accounts_alert',
             'clean_unused_accounts_deletion',
             'home_url',
+            'logo',
+            'colour',
         )
 
 
diff --git a/src/authentic2/migrations/0037_auto_20220331_1513.py b/src/authentic2/migrations/0037_auto_20220331_1513.py
new file mode 100644
index 000000000..6a9df4011
--- /dev/null
+++ b/src/authentic2/migrations/0037_auto_20220331_1513.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.2.24 on 2022-03-31 13:13
+
+from django.db import migrations, models
+
+import authentic2.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('authentic2', '0036_service_profile_types'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='service',
+            name='colour',
+            field=models.CharField(
+                blank=True,
+                max_length=32,
+                null=True,
+                validators=[authentic2.validators.HexaColourValidator()],
+                verbose_name='Colour',
+            ),
+        ),
+        migrations.AddField(
+            model_name='service',
+            name='logo',
+            field=models.ImageField(blank=True, upload_to='services/logos', verbose_name='Logo'),
+        ),
+    ]
diff --git a/src/authentic2/models.py b/src/authentic2/models.py
index 1aa2dc055..ee6ab70c0 100644
--- a/src/authentic2/models.py
+++ b/src/authentic2/models.py
@@ -15,6 +15,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import datetime
+import os
 import time
 import urllib.parse
 import uuid
@@ -37,6 +38,7 @@ from model_utils.managers import QueryManager
 
 from authentic2.a2_rbac.models import Role
 from authentic2.utils.crypto import base64url_decode, base64url_encode
+from authentic2.validators import HexaColourValidator
 
 # install our natural_key implementation
 from . import managers
@@ -382,6 +384,10 @@ class Service(models.Model):
         verbose_name=_('callback url when unauthorized'), max_length=256, null=True, blank=True
     )
     home_url = models.URLField(verbose_name=_('Home URL'), max_length=256, null=True, blank=True)
+    logo = models.ImageField(verbose_name=_('Logo'), blank=True, upload_to='services/logos')
+    colour = models.CharField(
+        verbose_name=_('Colour'), null=True, blank=True, max_length=32, validators=[HexaColourValidator()]
+    )
 
     profile_types = models.ManyToManyField(
         to='custom_user.ProfileType',
@@ -470,6 +476,12 @@ class Service(models.Model):
             urls.update(service.get_base_urls())
         return list(urls)
 
+    def delete(self, *args, **kwargs):
+        if self.logo and os.path.exists(self.logo.path):
+            os.unlink(self.logo.path)
+
+        return super().delete(*args, **kwargs)
+
 
 Service._meta.natural_key = [['slug', 'ou']]
 
diff --git a/src/authentic2/validators.py b/src/authentic2/validators.py
index 1d400a690..8b321efeb 100644
--- a/src/authentic2/validators.py
+++ b/src/authentic2/validators.py
@@ -124,3 +124,12 @@ class ProhibitNullCharactersValidator:
 
     def __eq__(self, other):
         return isinstance(other, self.__class__) and self.message == other.message and self.code == other.code
+
+
+class HexaColourValidator(RegexValidator):
+    """Validates that the string is a hexadecimal colour"""
+
+    def __init__(self, *args, **kwargs):
+        self.regex = '#[0-9a-fA-F]{6}'
+        self.message = _('Hexadecimal value only allowed.')
+        super().__init__(*args, **kwargs)
diff --git a/tests/idp_oidc/test_misc.py b/tests/idp_oidc/test_misc.py
index 0f18cd223..264cd8b28 100644
--- a/tests/idp_oidc/test_misc.py
+++ b/tests/idp_oidc/test_misc.py
@@ -85,6 +85,7 @@ OIDC_CLIENT_PARAMS = [
         'authorization_flow': OIDCClient.FLOW_IMPLICIT,
         'idtoken_duration': datetime.timedelta(hours=1),
         'post_logout_redirect_uris': 'https://example.com/',
+        'home_url': 'https://example.com/',
     },
     {
         'frontchannel_logout_uri': 'https://example.com/southpark/logout/',
@@ -92,6 +93,7 @@ OIDC_CLIENT_PARAMS = [
     {
         'frontchannel_logout_uri': 'https://example.com/southpark/logout/',
         'frontchannel_timeout': 3000,
+        'colour': '#ff00ff',
     },
     {
         'identifier_policy': OIDCClient.POLICY_PAIRWISE_REVERSIBLE,
@@ -120,6 +122,8 @@ def test_admin(other_attributes, app, superuser, oidc_settings):
     response.form.set('unauthorized_url', 'https://example.com/southpark/')
     response.form.set('redirect_uris', 'https://example.com/callbac%C3%A9')
     for key, value in other_attributes.items():
+        if isinstance(value, datetime.timedelta):
+            value = f'{value.total_seconds()}'
         response.form.set(key, value)
     response = response.form.submit().follow()
     assert OIDCClient.objects.count() == 1
diff --git a/tests/test_validators.py b/tests/test_validators.py
index 064adf504..9a319e3c0 100644
--- a/tests/test_validators.py
+++ b/tests/test_validators.py
@@ -21,7 +21,7 @@ from unittest import mock
 import pytest
 from django.core.exceptions import ValidationError
 
-from authentic2.validators import EmailValidator, validate_password
+from authentic2.validators import EmailValidator, HexaColourValidator, validate_password
 
 
 def test_validate_password():
@@ -34,6 +34,17 @@ def test_validate_password():
     validate_password('000aaaaZZZZ')
 
 
+def test_validate_colour():
+    validator = HexaColourValidator()
+    with pytest.raises(ValidationError):
+        validator('abc')
+    with pytest.raises(ValidationError):
+        validator('blue')
+    with pytest.raises(ValidationError):
+        validator('#green')
+    validator('#ff00ff')
+
+
 def test_digits_password_policy(settings):
     settings.A2_PASSWORD_POLICY_REGEX = '^[0-9]{8}$'
     settings.A2_PASSWORD_POLICY_REGEX_ERROR_MSG = 'pasbon'
-- 
2.35.1

