From dd661030671075f72aea37225c4ebea6228da6a5 Mon Sep 17 00:00:00 2001 From: Matt Griswold Date: Sat, 16 Nov 2019 21:32:27 -0600 Subject: [PATCH] Pr 502 593 (#602) * VerificationQueueItem - make unique in db schema (github-502) * multiple org records under a single sponsorship (github-593) fix migration branch --- config/facsimile/peeringdb.yaml | 3 + peeringdb_server/admin.py | 43 ++++++----- peeringdb_server/data_views.py | 11 +-- .../migrations/0020_sponsorship_multi_org.py | 47 ++++++++++++ .../migrations/0020_vqueue_item_unique.py | 20 +++++ ...sorship_drop_single_org_relation_fields.py | 27 +++++++ peeringdb_server/models.py | 49 +++++++++--- .../notify-sponsorship-admin-expiration.txt | 4 +- .../templates/site/sponsorships.html | 54 ++++++++------ peeringdb_server/views.py | 10 +-- tests/test_search.py | 3 +- tests/test_sponsors.py | 74 ++++++++++++++----- tests/test_veriqueue.py | 18 +++++ 13 files changed, 277 insertions(+), 86 deletions(-) create mode 100644 peeringdb_server/migrations/0020_sponsorship_multi_org.py create mode 100644 peeringdb_server/migrations/0020_vqueue_item_unique.py create mode 100644 peeringdb_server/migrations/0021_sponsorship_drop_single_org_relation_fields.py diff --git a/config/facsimile/peeringdb.yaml b/config/facsimile/peeringdb.yaml index 14911221..f4f44ba5 100644 --- a/config/facsimile/peeringdb.yaml +++ b/config/facsimile/peeringdb.yaml @@ -169,6 +169,9 @@ install: - $SRC_DIR$/peeringdb_server/migrations/0017_ixf_ixp_import_enabled.py - $SRC_DIR$/peeringdb_server/migrations/0018_set_ixf_ixp_import_enabled.py - $SRC_DIR$/peeringdb_server/migrations/0019_auto_20190819_1133.py + - $SRC_DIR$/peeringdb_server/migrations/0020_vqueue_item_unique.py + - $SRC_DIR$/peeringdb_server/migrations/0020_sponsorship_multi_org.py + - $SRC_DIR$/peeringdb_server/migrations/0021_sponsorship_drop_single_org_relation_fields.py - $SRC_DIR$/fixtures/initial_data.json - $SRC_DIR$/peeringdb_server/templates/admin/admin_extended.html - $SRC_DIR$/peeringdb_server/templates/admin/user-organizations.html diff --git a/peeringdb_server/admin.py b/peeringdb_server/admin.py index 019d8b4e..25fe4493 100644 --- a/peeringdb_server/admin.py +++ b/peeringdb_server/admin.py @@ -15,6 +15,7 @@ from django.contrib.admin import helpers from django.contrib.admin.actions import delete_selected from django.contrib.admin.views.main import ChangeList from django import forms as baseForms +from django.utils import html from django.core import urlresolvers from django.core.exceptions import ValidationError from django.conf import settings @@ -33,7 +34,7 @@ import peeringdb_server.admin_commandline_tools as acltools from peeringdb_server.views import (JsonResponse, HttpResponseForbidden) from peeringdb_server.models import ( REFTAG_MAP, QUEUE_ENABLED, COMMANDLINE_TOOLS, OrganizationMerge, - OrganizationMergeEntity, Sponsorship, Partnership, + OrganizationMergeEntity, Sponsorship, SponsorshipOrganization, Partnership, UserOrgAffiliationRequest, VerificationQueueItem, Organization, Facility, InternetExchange, Network, InternetExchangeFacility, IXLan, IXLanIXFMemberImportLog, IXLanIXFMemberImportLogEntry, IXLanPrefix, @@ -621,25 +622,24 @@ class IXLanIXFMemberImportLogAdmin(admin.ModelAdmin): def source(self, obj): return obj.ixlan.ixf_ixp_member_list_url - -class SponsorshipAdminForm(baseForms.ModelForm): - def __init__(self, *args, **kwargs): - super(SponsorshipAdminForm, self).__init__(*args, **kwargs) - fk_handleref_filter(self, "org") - +class SponsorshipOrganizationInline(admin.TabularInline): + model = SponsorshipOrganization + extra = 0 + raw_id_fields = ('org',) + autocomplete_lookup_fields = { + 'fk': ['org'], + } class SponsorshipAdmin(admin.ModelAdmin): - list_display = ('org_name', 'start_date', 'end_date', 'level', 'status') - readonly_fields = ('status', 'org_name', 'notify_date') - form = SponsorshipAdminForm + list_display = ('organizations', 'start_date', 'end_date', 'level', 'status') + readonly_fields = ('organizations', 'status', 'notify_date') + inlines = (SponsorshipOrganizationInline,) - def org_name(self, obj): - if not obj.org: - return "" - return obj.org.name + raw_id_fields = ('orgs',) - org_name.admin_order_field = "org__name" - org_name.short_description = _("Organization") + autocomplete_lookup_fields = { + 'm2m': ['orgs'], + } def status(self, obj): now = datetime.datetime.now().replace(tzinfo=UTC()) @@ -647,8 +647,9 @@ class SponsorshipAdmin(admin.ModelAdmin): return _("Not Set") if obj.start_date <= now and obj.end_date >= now: - if not obj.logo: - return _("Logo Missing") + for row in obj.sponsorshiporg_set.all(): + if not row.logo: + return _("Logo Missing") return _("Active") elif now > obj.end_date: return _("Over") @@ -658,6 +659,12 @@ class SponsorshipAdmin(admin.ModelAdmin): status.allow_tags = True + def organizations(self, obj): + qset = obj.orgs.all().order_by("name") + return "
\n".join([html.escape(org.name) for org in qset]) + + organizations.allow_tags = True + class PartnershipAdminForm(baseForms.ModelForm): def __init__(self, *args, **kwargs): super(PartnershipAdminForm, self).__init__(*args, **kwargs) diff --git a/peeringdb_server/data_views.py b/peeringdb_server/data_views.py index 41cb4dc9..90b8f98c 100644 --- a/peeringdb_server/data_views.py +++ b/peeringdb_server/data_views.py @@ -84,15 +84,12 @@ def sponsorships(request): Returns all sponsorships """ - now = datetime.datetime.now().replace(tzinfo=models.UTC()) - qset = Sponsorship.objects.filter(start_date__lte=now, - end_date__gte=now) + sponsors = {} + for org, sponsorship in Sponsorship.active_by_org(): + sponsors[org.id] = {"id":org.id, "name":sponsorship.label.lower()} return JsonResponse({ - "sponsors": dict([(sponsor.org_id, { - "id": sponsor.org_id, - "name": sponsor.label.lower() - }) for sponsor in qset]) + "sponsors": sponsors, }) @login_required diff --git a/peeringdb_server/migrations/0020_sponsorship_multi_org.py b/peeringdb_server/migrations/0020_sponsorship_multi_org.py new file mode 100644 index 00000000..88404853 --- /dev/null +++ b/peeringdb_server/migrations/0020_sponsorship_multi_org.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-11-07 08:25 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + +def forwards_func(apps, schema_editor): + """ + move relation shit from sponsorship->org to org->sponsorship + to allow a many orgs to many sponsorship relation + """ + Sponsorship = apps.get_model("peeringdb_server", "Sponsorship") + SponsorshipOrganization = apps.get_model("peeringdb_server", "SponsorshipOrganization") + + for sponsorship in Sponsorship.objects.all(): + SponsorshipOrganization.objects.create( + org=sponsorship.org, + sponsorship=sponsorship, + url=sponsorship.url, + logo=sponsorship.logo) + + +class Migration(migrations.Migration): + + dependencies = [ + ('peeringdb_server', '0020_vqueue_item_unique'), + ] + + operations = [ + migrations.CreateModel( + name='SponsorshipOrganization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField(blank=True, help_text='If specified clicking the sponsorship will take the user to this location', null=True, verbose_name='URL')), + ('logo', models.FileField(blank=True, help_text='Allows you to upload and set a logo image file for this sponsorship', null=True, upload_to=b'logos/')), + ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sponsorshiporg_set', to='peeringdb_server.Organization')), + ('sponsorship', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sponsorshiporg_set', to='peeringdb_server.Sponsorship')), + ], + ), + migrations.AddField( + model_name='sponsorship', + name='orgs', + field=models.ManyToManyField(related_name='sponsorship_set', through='peeringdb_server.SponsorshipOrganization', to='peeringdb_server.Organization'), + ), + migrations.RunPython(forwards_func, migrations.RunPython.noop), + ] diff --git a/peeringdb_server/migrations/0020_vqueue_item_unique.py b/peeringdb_server/migrations/0020_vqueue_item_unique.py new file mode 100644 index 00000000..9ccddeaf --- /dev/null +++ b/peeringdb_server/migrations/0020_vqueue_item_unique.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-11-04 08:38 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('peeringdb_server', '0019_auto_20190819_1133'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='verificationqueueitem', + unique_together=set([('content_type', 'object_id')]), + ), + ] diff --git a/peeringdb_server/migrations/0021_sponsorship_drop_single_org_relation_fields.py b/peeringdb_server/migrations/0021_sponsorship_drop_single_org_relation_fields.py new file mode 100644 index 00000000..3035a855 --- /dev/null +++ b/peeringdb_server/migrations/0021_sponsorship_drop_single_org_relation_fields.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-11-07 10:02 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('peeringdb_server', '0020_sponsorship_multi_org'), + ] + + operations = [ + migrations.RemoveField( + model_name='sponsorship', + name='logo', + ), + migrations.RemoveField( + model_name='sponsorship', + name='org', + ), + migrations.RemoveField( + model_name='sponsorship', + name='url', + ), + ] diff --git a/peeringdb_server/models.py b/peeringdb_server/models.py index 394574db..359fc3db 100644 --- a/peeringdb_server/models.py +++ b/peeringdb_server/models.py @@ -373,6 +373,7 @@ class VerificationQueueItem(models.Model): class Meta(object): db_table = "peeringdb_verification_queue" + unique_together = (("content_type", "object_id"),) @classmethod def get_for_entity(cls, entity): @@ -638,7 +639,7 @@ class Organization(pdb_models.OrganizationBase): has no sponsorship ongoing return None """ now = datetime.datetime.now().replace(tzinfo=UTC()) - return self.sponsorships.filter( + return self.sponsorship_set.filter( start_date__lte=now, end_date__gte=now).order_by("-start_date").first() @@ -680,7 +681,7 @@ class Sponsorship(models.Model): for a designated timespan """ - org = models.ForeignKey(Organization, related_name="sponsorships") + orgs = models.ManyToManyField(Organization, through="peeringdb_server.SponsorshipOrganization", related_name="sponsorship_set") start_date = models.DateTimeField( _("Sponsorship starts on"), default=default_time_s) end_date = models.DateTimeField( @@ -688,21 +689,26 @@ class Sponsorship(models.Model): notify_date = models.DateTimeField( _("Expiration notification sent on"), null=True, blank=True) level = models.PositiveIntegerField(choices=SPONSORSHIP_LEVELS, default=1) - url = models.URLField( - _("URL"), help_text= - _("If specified clicking the sponsorship will take the user to this location" - ), blank=True, null=True) - - logo = models.FileField( - upload_to="logos/", null=True, blank=True, help_text= - _("Allows you to upload and set a logo image file for this sponsorship" - )) class Meta: db_table = "peeringdb_sponsorship" verbose_name = _("Sponsorship") verbose_name_plural = _("Sponsorships") + @classmethod + def active_by_org(cls): + """ + Yields (Organization, Sponsorship) for all currently + active sponsorships + """ + now = datetime.datetime.now().replace(tzinfo=UTC()) + qset = cls.objects.filter(start_date__lte=now, end_date__gte=now) + qset = qset.prefetch_related("sponsorshiporg_set") + for sponsorship in qset: + for org in sponsorship.orgs.all(): + yield org, sponsorship + + @property def label(self): """ @@ -724,8 +730,10 @@ class Sponsorship(models.Model): "instance": self }) + org_names = ", ".join([org.name for org in self.orgs.all()]) + mail = EmailMultiAlternatives((u'{}: {}').format( - _("Sponsorship Expired"), self.org.name), msg, + _("Sponsorship Expired"), org_names), msg, settings.DEFAULT_FROM_EMAIL, [settings.SPONSORSHIPS_EMAIL]) mail.attach_alternative(msg.replace("\n", "
\n"), "text/html") @@ -737,6 +745,23 @@ class Sponsorship(models.Model): return True +class SponsorshipOrganization(models.Model): + """ + Describes an organization->sponsorship relationship + """ + org = models.ForeignKey(Organization, related_name="sponsorshiporg_set") + sponsorship = models.ForeignKey(Sponsorship, related_name="sponsorshiporg_set") + url = models.URLField( + _("URL"), help_text= + _("If specified clicking the sponsorship will take the user to this location" + ), blank=True, null=True) + + logo = models.FileField( + upload_to="logos/", null=True, blank=True, help_text= + _("Allows you to upload and set a logo image file for this sponsorship" + )) + + class Partnership(models.Model): """ Allows an organization to be marked as a partner diff --git a/peeringdb_server/templates/email/notify-sponsorship-admin-expiration.txt b/peeringdb_server/templates/email/notify-sponsorship-admin-expiration.txt index 5eb6fb10..7343ed81 100644 --- a/peeringdb_server/templates/email/notify-sponsorship-admin-expiration.txt +++ b/peeringdb_server/templates/email/notify-sponsorship-admin-expiration.txt @@ -1,4 +1,6 @@ {% load i18n %} -{% blocktrans with i_label=instance.label io_name=instance.org.name i_end_date=instance.end_date trimmed%} +{% for org in instance.orgs.all %} +{% blocktrans with i_label=instance.label io_name=org.name i_end_date=instance.end_date trimmed%} {{ i_label }} level sponsorship from '{{ io_name }}' expired on {{ i_end_date }}. {% endblocktrans %} +{% endfor %} diff --git a/peeringdb_server/templates/site/sponsorships.html b/peeringdb_server/templates/site/sponsorships.html index aaa0ee1f..750579d0 100644 --- a/peeringdb_server/templates/site/sponsorships.html +++ b/peeringdb_server/templates/site/sponsorships.html @@ -12,12 +12,14 @@ {% trans "Diamond" %}
{% trans "Sponsors" %}
- {% for row in sponsorships.diamond|shuffle %} - {% if row.logo %} - {% if row.url %}{% endif %} - {{ row.org.name }} - {% if row.url %}{% endif %} - {% endif %} + {% for sponsorship in sponsorships.diamond|shuffle %} + {% for row in sponsorship.sponsorshiporg_set.all|shuffle %} + {% if row.logo %} + {% if row.url %}{% endif %} + {{ row.org.name }} + {% if row.url %}{% endif %} + {% endif %} + {% endfor %} {% endfor %}
@@ -27,12 +29,14 @@ {% trans "Platinum" %}
{% trans "Sponsors" %}
- {% for row in sponsorships.platinum|shuffle %} - {% if row.logo %} - {% if row.url %}{% endif %} - {{ row.org.name }} - {% if row.url %}{% endif %} - {% endif %} + {% for sponsorship in sponsorships.platinum|shuffle %} + {% for row in sponsorship.sponsorshiporg_set.all|shuffle %} + {% if row.logo %} + {% if row.url %}{% endif %} + {{ row.org.name }} + {% if row.url %}{% endif %} + {% endif %} + {% endfor %} {% endfor %}
@@ -42,12 +46,14 @@ {% trans "Gold" %}
{% trans "Sponsors" %}
- {% for row in sponsorships.gold|shuffle %} - {% if row.logo %} - {% if row.url %}{% endif %} - {{ row.org.name }} - {% if row.url %}{% endif %} - {% endif %} + {% for sponsorship in sponsorships.gold|shuffle %} + {% for row in sponsorship.sponsorshiporg_set.all|shuffle %} + {% if row.logo %} + {% if row.url %}{% endif %} + {{ row.org.name }} + {% if row.url %}{% endif %} + {% endif %} + {% endfor %} {% endfor %}
@@ -57,10 +63,14 @@ {% trans "Silver" %}
{% trans "Sponsors" %}
- {% for row in sponsorships.silver|shuffle %} - {% if row.logo %} - {{ row.org.name }} - {% endif %} + {% for sponsorship in sponsorships.silver|shuffle %} + {% for row in sponsorship.sponsorshiporg_set.all|shuffle %} + {% if row.logo %} + {% if row.url %}{% endif %} + {{ row.org.name }} + {% if row.url %}{% endif %} + {% endif %} + {% endfor %} {% endfor %}
diff --git a/peeringdb_server/views.py b/peeringdb_server/views.py index 71c1f925..765e8046 100644 --- a/peeringdb_server/views.py +++ b/peeringdb_server/views.py @@ -1462,8 +1462,7 @@ def view_sponsorships(request): template = loader.get_template("site/sponsorships.html") now = datetime.datetime.now().replace(tzinfo=UTC()) - qset = Sponsorship.objects.filter(start_date__lte=now, end_date__gte=now, - logo__isnull=False) + qset = Sponsorship.objects.filter(start_date__lte=now, end_date__gte=now) sponsorships = { "diamond": qset.filter(level=4), @@ -1563,12 +1562,7 @@ def request_search(request): result = search(q) - now = datetime.datetime.now().replace(tzinfo=UTC()) - - sponsors = dict([(s.org_id, s.label) - for s in - Sponsorship.objects.filter(start_date__lte=now, - end_date__gte=now)]) + sponsors = dict([(org.id, sponsorship.label.lower()) for org, sponsorship in Sponsorship.active_by_org()]) for tag, rows in result.items(): for item in rows: diff --git a/tests/test_search.py b/tests/test_search.py index 9ee3bbd4..b7d5dba9 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -53,10 +53,11 @@ class SearchTests(TestCase): # accordingly cls.org_w_sponsorship = models.Organization.objects.create(name="Sponsor org", status="ok") cls.sponsorship = models.Sponsorship.objects.create( - org=cls.org_w_sponsorship, start_date=datetime.datetime.now() - datetime.timedelta(days=1), end_date=datetime.datetime.now() + datetime.timedelta(days=1), level=1); + models.SponsorshipOrganization.objects.create(org=cls.org_w_sponsorship, + sponsorship=cls.sponsorship) for model in search.searchable_models: if model.handleref.tag == "net": diff --git a/tests/test_sponsors.py b/tests/test_sponsors.py index 707cb34e..69c83c2a 100644 --- a/tests/test_sponsors.py +++ b/tests/test_sponsors.py @@ -29,24 +29,63 @@ class ViewTestCase(TestCase): guest_group.user_set.add(cls.guest_user) # create organizations - cls.organizations = dict((k, + + cls.organizations = dict(("{}".format(k), models.Organization.objects.create( name="Sponsor Org %s" % k, status="ok")) - for k in ["a", "b", "c", "d"]) + for k in range(1,7)) # create sponsorships + cls.sponsorships = { - "a": models.Sponsorship.objects.create( - org=cls.organizations.get("a"), logo="fake.png", - url="org-a.com", level=1), - "b": models.Sponsorship.objects.create( - org=cls.organizations.get("b"), logo="fake.png", level=1), - "c": models.Sponsorship.objects.create( - org=cls.organizations.get("c"), logo="fake.png", level=2), - "d": models.Sponsorship.objects.create( - org=cls.organizations.get("d"), level=1) + "1": models.Sponsorship.objects.create(level=1), + "2": models.Sponsorship.objects.create(level=1), + "3": models.Sponsorship.objects.create(level=2), + "4": models.Sponsorship.objects.create(level=1), + "5_and_6": models.Sponsorship.objects.create(level=3) } + # org sponsorship with logo and url set lvl1 + + models.SponsorshipOrganization.objects.create(sponsorship=cls.sponsorships["1"], + org=cls.organizations["1"], + logo="fake.png", + url="org-1.com",) + + # org sponsorship with logo set lvl1 + + models.SponsorshipOrganization.objects.create(sponsorship=cls.sponsorships["2"], + org=cls.organizations["2"], + logo="fake.png",) + + + # org sponsorship with logo set lvl2 + + models.SponsorshipOrganization.objects.create(sponsorship=cls.sponsorships["3"], + org=cls.organizations["3"], + logo="fake.png",) + + + # org sponsorship without logo or url set lvl1 + + models.SponsorshipOrganization.objects.create(sponsorship=cls.sponsorships["4"], + org=cls.organizations["4"],) + + + # two orgs in one sponsorship + + models.SponsorshipOrganization.objects.create(sponsorship=cls.sponsorships["5_and_6"], + org=cls.organizations["5"], + logo="fake.png", + url="org-5.com",) + + + models.SponsorshipOrganization.objects.create(sponsorship=cls.sponsorships["5_and_6"], + org=cls.organizations["6"], + logo="fake.png", + url="org-6.com",) + + def setUp(self): self.factory = RequestFactory() @@ -54,8 +93,7 @@ class ViewTestCase(TestCase): c = Client() resp = c.get("/data/sponsors", follow=True) self.assertEqual(resp.status_code, 200) - - expected = {u'sponsors': {u'1': {u'id': 1, u'name': u'silver'}, u'3': {u'id': 3, u'name': u'gold'}, u'2': {u'id': 2, u'name': u'silver'}, u'4': {u'id': 4, u'name': u'silver'}}} + expected = {u'sponsors': {u'1': {u'id': 1, u'name': u'silver'}, u'3': {u'id': 3, u'name': u'gold'}, u'2': {u'id': 2, u'name': u'silver'}, u'5': {u'id': 5, u'name': u'platinum'}, u'4': {u'id': 4, u'name': u'silver'}, u'6': {u'id': 6, u'name': u'platinum'}}} self.assertEqual(resp.json(), expected) def test_view(self): @@ -64,12 +102,14 @@ class ViewTestCase(TestCase): self.assertEqual(resp.status_code, 200) #make sure org a,b and c exist in the sponsors page - self.assertGreater(resp.content.find(self.organizations["a"].name), -1) - self.assertGreater(resp.content.find(self.organizations["b"].name), -1) - self.assertGreater(resp.content.find(self.organizations["c"].name), -1) + self.assertGreater(resp.content.find(self.organizations["1"].name), -1) + self.assertGreater(resp.content.find(self.organizations["2"].name), -1) + self.assertGreater(resp.content.find(self.organizations["3"].name), -1) + self.assertGreater(resp.content.find(self.organizations["5"].name), -1) + self.assertGreater(resp.content.find(self.organizations["6"].name), -1) #make sure org d does not exist in the sponsors page - self.assertEqual(resp.content.find(self.organizations["d"].name), -1) + self.assertEqual(resp.content.find(self.organizations["4"].name), -1) #makre sure order is randomized with each view i = 0 diff --git a/tests/test_veriqueue.py b/tests/test_veriqueue.py index 4a6f9986..eeee56fc 100644 --- a/tests/test_veriqueue.py +++ b/tests/test_veriqueue.py @@ -2,6 +2,7 @@ import pytest from django.test import TestCase from django.contrib.auth.models import Group +from django.db import IntegrityError import peeringdb_server.models as models @@ -124,3 +125,20 @@ class VeriQueueTests(TestCase): # after denial vqi should no longer exist with self.assertRaises(models.VerificationQueueItem.DoesNotExist): vqi.refresh_from_db() + + + def test_unique(self): + """ + Test that only one verification queue item can exist for an entity + """ + + fac = self.inst.get("fac") + vqi = models.VerificationQueueItem.get_for_entity(fac) + + with self.assertRaises(IntegrityError): + models.VerificationQueueItem.objects.create( + content_type=models.ContentType.objects.get_for_model(type(fac)), + object_id=fac.id) + + +