diff --git a/notifications/README.md b/admin_notifications/README.md similarity index 100% rename from notifications/README.md rename to admin_notifications/README.md diff --git a/notifications/__init__.py b/admin_notifications/__init__.py similarity index 100% rename from notifications/__init__.py rename to admin_notifications/__init__.py diff --git a/admin_notifications/apps.py b/admin_notifications/apps.py new file mode 100644 index 000000000..48789da9a --- /dev/null +++ b/admin_notifications/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AdminNotificationsConfig(AppConfig): + name = 'admin_notifications' diff --git a/notifications/migrations/0001_initial.py b/admin_notifications/migrations/0001_initial.py similarity index 83% rename from notifications/migrations/0001_initial.py rename to admin_notifications/migrations/0001_initial.py index 8fbdf6886..26e227f93 100644 --- a/notifications/migrations/0001_initial.py +++ b/admin_notifications/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.14 on 2022-07-29 13:17 +# Generated by Django 3.2.25 on 2025-08-08 09:24 from django.db import migrations, models @@ -13,13 +13,13 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Notification', + name='AdminNotification', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('head', models.CharField(max_length=255)), ('body', models.TextField()), ('url', models.URLField(blank=True, null=True)), - ('groups', models.ManyToManyField(related_name='notifications', to='auth.Group')), + ('groups', models.ManyToManyField(related_name='admin_notifications', to='auth.Group')), ], ), ] diff --git a/notifications/migrations/__init__.py b/admin_notifications/migrations/__init__.py similarity index 100% rename from notifications/migrations/__init__.py rename to admin_notifications/migrations/__init__.py diff --git a/notifications/models.py b/admin_notifications/models.py similarity index 80% rename from notifications/models.py rename to admin_notifications/models.py index 464f46cf7..82b661427 100644 --- a/notifications/models.py +++ b/admin_notifications/models.py @@ -1,12 +1,12 @@ from django.db import models - -class Notification(models.Model): +class AdminNotification(models.Model): head = models.CharField(max_length=255) body = models.TextField() url = models.URLField(null=True, blank=True) - groups = models.ManyToManyField(to='auth.Group', related_name='notifications') + groups = models.ManyToManyField(to='auth.Group', related_name='admin_notifications') def __str__(self): return self.head + diff --git a/notifications/processors.py b/admin_notifications/processors.py similarity index 100% rename from notifications/processors.py rename to admin_notifications/processors.py diff --git a/admin_notifications/views.py b/admin_notifications/views.py new file mode 100644 index 000000000..76a415536 --- /dev/null +++ b/admin_notifications/views.py @@ -0,0 +1,61 @@ +import json +from user_notifications.models import NotificationMeta, NotificationLog +from wagtail.contrib.modeladmin.views import CreateView +from webpush import send_user_notification +from notifications.signals import notify +from notifications.models import Notification +from iogt_users.models import User + + +class CreateNotificationView(CreateView): + def form_valid(self, form): + payload = form.cleaned_data.copy() + groups = payload.pop('groups') + users = User.objects.filter(groups__in=groups).distinct() + for user in users: + try: + # 1. Create Notification + notify.send( + sender=self.request.user, + recipient=user, + verb=payload.get('head', 'New Notification'), + description=payload.get('body', ''), + url=payload.get("url", "/") + ) + + # 2. Get latest Notification for user (created just now) + notif_instance = Notification.objects.filter(recipient=user).order_by('-timestamp').first() + if not notif_instance: + continue # Shouldn't happen, but guard just in case + + # 3. Avoid duplicate meta creation + NotificationMeta.objects.get_or_create(notification=notif_instance) + + # 4. Send Web Push + send_user_notification( + user=user, + payload={ + "title": payload.get("head", "IoGT Notification"), + "body": payload.get("body", ""), + "url": payload.get("url", "/"), + "notification_id": notif_instance.id + }, + ttl=1000) + NotificationLog.objects.create( + user=user, + notification_key=payload.get("head", "IoGT Notification"), + tags='', + state="sent", + notification=notif_instance + ) + except Exception as e: + # Optional: log failure for user if user or template not found + NotificationLog.objects.create( + user=user, + notification_key=payload.get("head", "IoGT Notification"), + tags='', + state="failed", + error_message=f"Task failure: {str(e)}", + notification=None + ) + return super().form_valid(form) diff --git a/notifications/wagtail_hooks.py b/admin_notifications/wagtail_hooks.py similarity index 60% rename from notifications/wagtail_hooks.py rename to admin_notifications/wagtail_hooks.py index a5582b6ad..6c4fac2a7 100644 --- a/notifications/wagtail_hooks.py +++ b/admin_notifications/wagtail_hooks.py @@ -1,13 +1,12 @@ from django.conf import settings from wagtail.contrib.modeladmin.options import ModelAdmin, modeladmin_register - -from notifications.models import Notification -from notifications.views import CreateNotificationView +from admin_notifications.models import AdminNotification +from admin_notifications.views import CreateNotificationView class NotificationModelAdmin(ModelAdmin): - model = Notification - menu_label = 'Notifications' + model = AdminNotification + menu_label = 'Admin Notifications' menu_icon = 'mail' list_display = ('head', 'body', 'url',) list_filter = ('groups',) @@ -15,6 +14,3 @@ class NotificationModelAdmin(ModelAdmin): menu_order = 601 create_view_class = CreateNotificationView - -if settings.PUSH_NOTIFICATION: - modeladmin_register(NotificationModelAdmin) diff --git a/common/translation_utils/translation_status.csv b/common/translation_utils/translation_status.csv index a5d3fb642..4c3b67cd4 100644 --- a/common/translation_utils/translation_status.csv +++ b/common/translation_utils/translation_status.csv @@ -716,3 +716,7 @@ Custom permissions,,,,needs translation,,, Submit,,translate,,has partial translation,,, Farsi,,not needed,,not needed,,, Burmese,,not needed,,not needed,,, +Hausa,,not needed,,not needed,,, +Yoruba,,not needed,,not needed,,, +Igbo,,not needed,,not needed,,, +Pidgin,,not needed,,not needed,,, diff --git a/common/translation_utils/translations.csv b/common/translation_utils/translations.csv index 3ad9e949a..3e651baed 100644 --- a/common/translation_utils/translations.csv +++ b/common/translation_utils/translations.csv @@ -1,7 +1,7 @@ -Row type,is in use,comment,English,Spanish,French,Portuguese,Arabic,Swahili,Chichewa,Kinyarwanda,Ndebele,Shona,Kirundi,Malagasy,Nepali,Urdu,Kichwa/Quichua,Russian,Zulu,Tigrinya,Tajik,Kurdish,Khmer,Uzbek,Karakalpak,Indonesian,Sinhala,Tamil,Bengali,Dari,Pashto,Hindi,Ukraine,Turkish,Farsi,Burmese -Language,,,English,Spanish,French,Portuguese,Arabic,Swahili,Chichewa,Kinyarwanda,Ndebele,Shona,Kirundi,Malagasy,Nepali,Urdu,Kichwa/Quichua,Russian,Zulu,Tigrinya,Tajik,Kurdish,Khmer,Uzbek,Karakalpak,Indonesian,Sinhala,Tamil,Bengali,Dari,Pashto,Hindi,Ukraine,Turkish,Farsi,Burmese -ll-LL,,,en,es,fr-CG,pt-MZ,ar-MA,sw-KE,ny-MW,rw-RW,nr-ZW,sn-ZW,rn-BI,mg-MG,ne-NP,ur-PK,qu-EC,ru-RU,zu-ZA,ti-ET,tg-TJ,ku,km-KH,uz-UZ,kaa,id,si,ta,bn-BN,prs,ps,hi,uk,tr,fa,my -ll,,,en,es,fr,pt,ar,sw,ny,rw,nr,sn,rn,mg,ne,ur,qu,ru,zu,ti,tg,ku,km,uz,kaa,id,si,ta,bn,prs,ps,hi,uk,tr,fa,my +Row type,is in use,comment,English,Spanish,French,Portuguese,Arabic,Swahili,Chichewa,Kinyarwanda,Ndebele,Shona,Kirundi,Malagasy,Nepali,Urdu,Kichwa/Quichua,Russian,Zulu,Tigrinya,Tajik,Kurdish,Khmer,Uzbek,Karakalpak,Indonesian,Sinhala,Tamil,Bengali,Dari,Pashto,Hindi,Ukraine,Turkish,Farsi,Burmese,Hausa,Yoruba,Igbo,Pidgin +Language,,,English,Spanish,French,Portuguese,Arabic,Swahili,Chichewa,Kinyarwanda,Ndebele,Shona,Kirundi,Malagasy,Nepali,Urdu,Kichwa/Quichua,Russian,Zulu,Tigrinya,Tajik,Kurdish,Khmer,Uzbek,Karakalpak,Indonesian,Sinhala,Tamil,Bengali,Dari,Pashto,Hindi,Ukraine,Turkish,Farsi,Burmese,Hausa,Yoruba,Igbo,Pidgin +ll-LL,,,en,es,fr-CG,pt-MZ,ar-MA,sw-KE,ny-MW,rw-RW,nr-ZW,sn-ZW,rn-BI,mg-MG,ne-NP,ur-PK,qu-EC,ru-RU,zu-ZA,ti-ET,tg-TJ,ku,km-KH,uz-UZ,kaa,id,si,ta,bn-BN,prs,ps,hi,uk,tr,fa,my,ha,yo,ig,pcm +ll,,,en,es,fr,pt,ar,sw,ny,rw,nr,sn,rn,mg,ne,ur,qu,ru,zu,ti,tg,ku,km,uz,kaa,id,si,ta,bn,prs,ps,hi,uk,tr,fa,my,ha,yo,ig,pcm Region,,,English,Latin America,"West African, DRC","Angola, Mozambique",Morocco,Kenya,"Malawi, Zambia",Rwanda,Zimbabwe,Zimbabwe,Burundi,Madagascar,Nepal,Pakistan,Ecuador,Russia,South Africa,Ethiopia,Tajikistan,Sorani,Cambodia,Uzbekistan,Karakalpakstan,Indonesia,Sri Lanka,Sri Lanka,Bangladesh,Afghanistan,Afghanistan,India,Ukraine,Turkey,Iran,Myanmar Section,,,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Homepage,Головна,,, ,,,Polls,Encuestas,Sondages,Votações,استطلاعات الرأي,Utafiti,Kafukufuku ofunsa maganizo a anthu,Amatora,Ikhetho,Sarudzo,Amatora,Fitsapan-kevitra,चुनावहरू,پولز,Tapuykuna,Голосования,Ukuvota,መረጻታት,Назарсанҷиҳо,دەنگدانەکان,ការបោះឆ្នោតស្ទង់មតិ,Tanlovlar,Sorawnama,Jajak pendapat,,,,,,,Опитування,,, diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 445b2fcf0..4efbcd761 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -19,4 +19,4 @@ services: environment: POSTGRES_USER: iogt POSTGRES_PASSWORD: iogt - POSTGRES_DB: iogt + POSTGRES_DB: iogt \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3bd1b4755..99316da04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,10 +5,43 @@ services: target: dev args: requirements: requirements.dev.txt - environment: - DJANGO_SETTINGS_MODULE: iogt.settings.dev + env_file: + - .env image: iogt:latest + ports: - "8000:8000" + depends_on: + - db volumes: - ./:/app/ +# db: +# image: postgres:14-alpine +# environment: +# POSTGRES_USER: postgres +# POSTGRES_PASSWORD: postgresiogt +# POSTGRES_DB: iogt + celery: + build: + context: ./ + target: dev + args: + requirements: requirements.dev.txt + command: celery -A iogt worker -l info + depends_on: + - django + - redis + env_file: + - .env + volumes: + - ./:/app/ + + redis: + image: redis:7 + + db: + image: postgres:14-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgresiogt + POSTGRES_DB: iogt \ No newline at end of file diff --git a/home/migrations/0060_auto_20250703_0253.py b/home/migrations/0060_auto_20250703_0253.py new file mode 100644 index 000000000..c5e3f151a --- /dev/null +++ b/home/migrations/0060_auto_20250703_0253.py @@ -0,0 +1,88 @@ +# Generated by Django 3.2.25 on 2025-07-03 02:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0059_burmese_locale'), + ] + + operations = [ + migrations.AlterField( + model_name='articlefeedback', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='articlerecommendation', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='articletaggeditem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='featuredcontent', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='feedbacksettings', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='homepagebanner', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='iogtflatmenuitem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='localedetail', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='manifestsettings', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='sectiontaggeditem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='sitesettings', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='svgtopngmap', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='themesettings', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='v1pageurltov2pagemap', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='v1tov2objectmap', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/home/migrations/0061_article_notification_tags.py b/home/migrations/0061_article_notification_tags.py new file mode 100644 index 000000000..bbbceb1e2 --- /dev/null +++ b/home/migrations/0061_article_notification_tags.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.25 on 2025-07-28 12:22 + +from django.db import migrations +import modelcluster.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_notifications', '0003_auto_20250728_1222'), + ('home', '0060_auto_20250703_0253'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='notification_tags', + field=modelcluster.fields.ParentalManyToManyField(blank=True, to='user_notifications.NotificationTag'), + ), + ] diff --git a/home/migrations/0062_alter_manifestsettings_language.py b/home/migrations/0062_alter_manifestsettings_language.py new file mode 100644 index 000000000..d634d7a82 --- /dev/null +++ b/home/migrations/0062_alter_manifestsettings_language.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2025-08-18 10:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0061_article_notification_tags'), + ] + + operations = [ + migrations.AlterField( + model_name='manifestsettings', + name='language', + field=models.CharField(choices=[('ar', 'Arabic'), ('bn', 'Bengali'), ('ny', 'Chichewa'), ('prs', 'Dari'), ('en', 'English'), ('fa', 'Farsi'), ('fr', 'French'), ('hi', 'Hindi'), ('id', 'Indonesian'), ('kaa', 'Karakalpak'), ('km', 'Khmer'), ('rw', 'Kinyarwanda'), ('rn', 'Kirundi'), ('ku', 'Kurdish'), ('mg', 'Malagasy'), ('my', 'Burmese'), ('ne', 'Nepali'), ('nr', 'Ndebele'), ('ps', 'Pashto'), ('pt', 'Portuguese'), ('qu', 'Quechua'), ('ru', 'Russian'), ('sn', 'Shona'), ('si', 'Sinhala'), ('es', 'Spanish'), ('sw', 'Swahili'), ('tg', 'Tajik'), ('ta', 'Tamil'), ('ti', 'Tigrinya'), ('tr', 'Turkish'), ('uk', 'Ukraine'), ('ur', 'Urdu'), ('uz', 'Uzbek'), ('zu', 'Zulu'), ('xy', 'Testing'), ('ha', 'Hausa'), ('yo', 'Yoruba'), ('ig', 'Igbo'), ('pcm', 'Pidgin')], default='en', help_text='Choose language', max_length=3, verbose_name='Language'), + ), + ] diff --git a/home/models.py b/home/models.py index e29442647..d2ce54ac7 100644 --- a/home/models.py +++ b/home/models.py @@ -14,8 +14,8 @@ from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from modelcluster.contrib.taggit import ClusterTaggableManager +from modelcluster.fields import ParentalKey, ParentalManyToManyField from iogt.settings.base import WAGTAIL_CONTENT_LANGUAGES -from modelcluster.fields import ParentalKey from rest_framework import status from taggit.models import TaggedItemBase from wagtail.admin.panels import ( @@ -55,7 +55,7 @@ ) import iogt.iogt_globals as globals_ from django.db.models import Avg, Count - +from user_notifications.models import NotificationTag, NotificationPreference User = get_user_model() logger = logging.getLogger(__name__) @@ -125,8 +125,12 @@ def get_context(self, request): ): banners.append(banner_specific) context['banners'] = banners + show_notification_nudge = False + if request.user and request.user.is_authenticated: + pref = NotificationPreference.objects.filter(user=request.user).first() + context["notification_preference"] = pref + context['user'] = request.user return context - @property def offline_urls(self): return [self.url] + collect_urls_from_streamfield(self.home_featured_content) @@ -482,7 +486,7 @@ class Article(AbstractArticle): # New fields for precomputed values average_rating = models.FloatField(default=0.0, null=True) number_of_reviews = models.PositiveIntegerField(default=0, null=True) - + notification_tags = ParentalManyToManyField(NotificationTag, blank=True) content_panels = AbstractArticle.content_panels + [ MultiFieldPanel([ InlinePanel('recommended_articles', @@ -492,7 +496,7 @@ class Article(AbstractArticle): ] promote_panels = AbstractArticle.promote_panels + [ - MultiFieldPanel([FieldPanel("tags"), ], heading='Metadata'), + MultiFieldPanel([FieldPanel("tags"), FieldPanel("notification_tags"),], heading='Metadata'), ] edit_handler_list = [ @@ -573,7 +577,6 @@ class BannerIndexPage(Page): parent_page_types = ['home.HomePage'] subpage_types = ['home.BannerPage'] - class BannerPage(Page, PageUtilsMixin): parent_page_types = ['home.BannerIndexPage'] subpage_types = [] diff --git a/home/static/css/global/global.css b/home/static/css/global/global.css index 4df37737f..f7bed26fc 100644 --- a/home/static/css/global/global.css +++ b/home/static/css/global/global.css @@ -1,4 +1,3 @@ - /* home start */ .home-page__featured-content { padding: 0 22px; @@ -23,6 +22,7 @@ .banner-holder { margin-bottom: 20px; } + .banner-holder img { margin-bottom: 6px; width: 100vw; @@ -30,6 +30,7 @@ border-radius: 12px; } } + /* home end */ @@ -52,11 +53,11 @@ display: -ms-flexbox; display: flex; -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; } .featured-content a { @@ -71,7 +72,8 @@ text-align: left; } -.featured-content svg, .section-featured-content svg { +.featured-content svg, +.section-featured-content svg { stroke: currentColor; } @@ -82,7 +84,9 @@ margin-bottom: 19px; padding: 13px 12px; } - .featured-content a, .section-featured-content-title { + + .featured-content a, + .section-featured-content-title { font-size: 16px; line-height: 22px; } @@ -122,14 +126,14 @@ display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; padding: 8px 0px; border-bottom: 1px solid #EFEFEF; font-style: normal; @@ -178,11 +182,11 @@ } } -.rtl .quiz__imageholder { +.rtl .quiz__imageholder { transform: rotate(180deg); } -.rtl .featured-content__imageholder { +.rtl .featured-content__imageholder { transform: rotate(180deg); } @@ -231,7 +235,8 @@ margin-bottom: 10px; } -.article__content--video a, .article__content--audio a { +.article__content--video a, +.article__content--audio a { color: #303030; } @@ -243,14 +248,14 @@ display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; padding: 8px 16px; border: 1px solid #303030; border-radius: 8px; @@ -298,8 +303,8 @@ display: -ms-flexbox; display: flex; -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; padding-top: 12px; } @@ -309,14 +314,14 @@ display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; padding: 8px 12px; text-decoration: none; width: 105px; @@ -346,18 +351,21 @@ .article__navigation { padding-top: 24px; } + .article__navigation a { padding: 12px 16px; width: 132px; height: 46px; border-radius: 24px; } + .article__navigation--previous { height: 22px; font-size: 16px; line-height: 22px; border: 1.5px solid #d8d8d8; } + .article__navigation--next { height: 22px; font-size: 16px; @@ -377,11 +385,11 @@ display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; padding: 12px; background: #F7F7F9; font-size: 10px; @@ -408,11 +416,11 @@ display: -ms-flexbox; display: flex; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; margin-left: 10px; } @@ -455,25 +463,25 @@ display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; padding: 0; background-color: #303030; border-radius: 24px; -webkit-box-flex: 0; - -ms-flex-positive: 0; - flex-grow: 0; + -ms-flex-positive: 0; + flex-grow: 0; margin: 16px 10px; width: 100%; } -.comments__submit + .comments__submit { +.comments__submit+.comments__submit { margin-left: 10px; } @@ -493,15 +501,18 @@ .comments { padding-top: 40px; } + .comments h2 { padding: 20px; font-size: 16px; line-height: 22px; } + .comments h3 { font-size: 24px; line-height: 28px; } + .comments__count { width: 30px; height: 30px; @@ -509,18 +520,22 @@ line-height: 16px; margin-left: 10px; } + .comments__form { padding: 0px 20px; } + .comments__login { padding: 20px; font-size: 16px; line-height: 24px; } + .comments__submit { padding: 0; margin: 16px 0px; } + .comments__submit input, .comments__submit a { font-size: 16px; @@ -550,8 +565,8 @@ display: -ms-flexbox; display: flex; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; margin: 0; } @@ -566,11 +581,11 @@ display: -ms-flexbox; display: flex; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; } .individual-comment span p:nth-of-type(2n) { @@ -586,11 +601,11 @@ display: -ms-flexbox; display: flex; -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; } .individual-comment__date { @@ -601,7 +616,8 @@ opacity: 0.4; } -.individual-comment .content, .individual-comment__moderator { +.individual-comment .content, +.individual-comment__moderator { font-weight: 500; font-size: 10px; line-height: 16px; @@ -634,34 +650,42 @@ .individual-comment { padding: 20px; } + .individual-comment span p { font-size: 16px; line-height: 24px; margin: 0; } + .individual-comment span p:first-of-type { width: 32px; margin-right: 8px; height: 32px; border-radius: 50%; } + .individual-comment span p:nth-of-type(2n) { font-size: 16px; line-height: 24px; letter-spacing: 0.01em; } + .individual-comment__date { font-size: 12px; line-height: 12px; } - .individual-comment .content, .individual-comment__moderator { + + .individual-comment .content, + .individual-comment__moderator { font-size: 16px; line-height: 24px; } + .individual-comment .report-comment { font-size: 14px; line-height: 20px; } + .individual-comment .report-comment__disclaimer { font-size: 14px; line-height: 20px; @@ -676,7 +700,7 @@ width: 100%; border: 1px solid #EFEFEF; -webkit-box-sizing: border-box; - box-sizing: border-box; + box-sizing: border-box; border-radius: 8px; padding: 7px 12px; font-style: normal; @@ -697,14 +721,17 @@ line-height: 24px; } } + /*# sourceMappingURL=article.css.map */ -.article-card .img-holder, .section-card .img-holder { +.article-card .img-holder, +.section-card .img-holder { overflow: hidden; position: relative; } -.article-card .img-holder.complete:after, .section-card .img-holder.complete:after { +.article-card .img-holder.complete:after, +.section-card .img-holder.complete:after { position: absolute; left: 0; right: 0; @@ -715,7 +742,8 @@ background: linear-gradient(0deg, rgba(26, 144, 144, 0.7), rgba(26, 144, 144, 0.7)); } -.article-card .img-holder.complete:before, .section-card .img-holder.complete:before { +.article-card .img-holder.complete:before, +.section-card .img-holder.complete:before { background-image: url("/static/icons/tick-square.svg"); content: ""; height: 28px; @@ -728,6 +756,7 @@ transform: translate(-50%, -50%); z-index: 2; } + /* article end */ @@ -752,7 +781,8 @@ body.rtl { padding: 0; } -.article-card p.article-title, .section-card p.section-title { +.article-card p.article-title, +.section-card p.section-title { width: 40%; font-weight: bold; margin-right: 5px; @@ -761,12 +791,14 @@ body.rtl { letter-spacing: 0.01em; } -.article-card .article-header, .section-card .section-header { +.article-card .article-header, +.section-card .section-header { padding-left: 10px; padding-right: 10px; } -.article-card .article-header p, .section-card .section-header p { +.article-card .article-header p, +.section-card .section-header p { width: 100%; } @@ -775,11 +807,11 @@ body.rtl { display: -ms-flexbox; display: flex; -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; padding: 6px 0px; border-bottom: 1px solid #EFEFEF; text-decoration: none; @@ -804,13 +836,15 @@ body.rtl { height: auto; } -.article-card .img-holder, .section-card .img-holder { +.article-card .img-holder, +.section-card .img-holder { border-radius: 8px; flex: 0 0 50%; height: auto; } -.article-card .img-holder img, .section-card .img-holder img { +.article-card .img-holder img, +.section-card .img-holder img { border-radius: 8px; width: 100%; height: auto; @@ -830,22 +864,27 @@ body.rtl { .article-card { /* margin: 0px 20px; */ } + .article-card h2 { height: 28px; font-weight: 800; font-size: 24px; line-height: 28px; } - .article-card p.article-title, .section-card p.section-title { + + .article-card p.article-title, + .section-card p.section-title { width: 50%; font-size: 16px; margin-right: 5px; line-height: 24px; } - .article-card .article-header p, .section-card .section-header p { + .article-card .article-header p, + .section-card .section-header p { width: 100%; } + .article-card a { padding: 16px 0px; font-size: 16px; @@ -853,9 +892,11 @@ body.rtl { letter-spacing: 0.01em; color: #303030; } + .article-card__img--xs { display: none; } + .article-card__img--sm { display: block; } @@ -869,14 +910,14 @@ body.rtl { display: flex; -webkit-box-orient: vertical; -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; } .questionnaire-components a { @@ -885,11 +926,11 @@ body.rtl { display: -ms-flexbox; display: flex; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; color: inherit; } @@ -933,18 +974,22 @@ body.rtl { .questionnaire-components { margin: 0px 20px; } + .questionnaire-components p:first-of-type { font-size: 12px; line-height: 16px; } + .questionnaire-components p { padding-right: 20px; margin: 0; } + .questionnaire-components p:nth-of-type(2n) { font-size: 16px; line-height: 22px; } + .questionnaire-components__component { margin: 16px; padding: 8px 12px; @@ -968,37 +1013,40 @@ body.rtl { display: flex; -webkit-box-orient: vertical; -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; padding: 12px; padding-top: 8px; left: 0px; top: 0px; background-color: #E0F2FD; } + .cache-banner p { font-style: normal; font-weight: normal; font-size: 16px; line-height: 24px; } + .cache-banner p:nth-of-type(2n) { font-weight: 700; } + .cache-banner__download { display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; padding: 5px 12px; border: none; height: 26px; @@ -1010,14 +1058,16 @@ body.rtl { line-height: 16px; color: #FFFFFF; } + .cache-banner__close-holder { display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; } + .cache-banner__close-holder__button { padding: 0px 4px; background: #0094F4; @@ -1030,17 +1080,21 @@ body.rtl { border: none; } } + /*# sourceMappingURL=global.css.map */ .other-links, -.content .footer__copyright {display: none;} +.content .footer__copyright { + display: none; +} @media screen and (max-width: 767px) and (min-width: 414px) { .footer { - padding-left: 32px; - padding-right: 32px; + padding-left: 32px; + padding-right: 32px; } + .section-container { max-width: 600px; margin: 0 auto; @@ -1093,7 +1147,8 @@ body.rtl { .footer .footer__copyright { display: none; } - .nav-bar{ + + .nav-bar { position: static; width: 100%; } @@ -1104,7 +1159,7 @@ body.rtl { width: 100%; } - .nav-bar__item a{ + .nav-bar__item a { display: flex; margin-bottom: 8px; padding: 9px 12px; @@ -1152,7 +1207,9 @@ body.rtl { margin-top: 0; } - .content .footer__copyright {display: block;} + .content .footer__copyright { + display: block; + } .footer__copyright { margin: 0; @@ -1161,7 +1218,10 @@ body.rtl { } @media screen and (min-width: 1400px) { - .footer .bottom-level {display: none;} + .footer .bottom-level { + display: none; + } + .footer-main, .other-links { display: block; @@ -1169,6 +1229,7 @@ body.rtl { width: 29%; padding: 0 20px; } + .content { max-width: 600px; width: 40%; @@ -1245,20 +1306,101 @@ body.rtl { } .language__select { - display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ - display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ - display: -ms-flexbox; /* TWEENER - IE 10 */ - display: -webkit-flex; /* NEW - Chrome */ - display: flex; /* NEW, Spec - Opera 12.1, Firefox 20+ */ + display: -webkit-box; + /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; + /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; + /* TWEENER - IE 10 */ + display: -webkit-flex; + /* NEW - Chrome */ + display: flex; + /* NEW, Spec - Opera 12.1, Firefox 20+ */ -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; + -ms-flex-pack: end; + justify-content: flex-end; +} + +.dropdown-menu { + position: absolute; + right: 10px; + top: 40px; + background: white; + border: 1px solid #ccc; + z-index: 1000; + box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.15); + padding: 10px; + border-radius: 6px; +} + +.notification-holder { + display: flex; + align-items: center; + margin-left: 5px; +} + +.notification-bell-wrapper { + position: relative; + display: inline-block; +} + +.notification-bell { + font-size: 1.5rem; + transition: color 0.3s ease; + display: flex; +} + +#notif-bell.highlighted { + fill: #007bff !important; /* blue glow */ +} + +.notif-badge { + position: absolute; + top: -8px; + right: -8px; + background-color: red; + color: white; + border-radius: 50%; + padding: 2px 6px; + font-size: 0.7rem; + font-weight: bold; + display: inline-block; + line-height: 1; + min-width: 16px; + text-align: center; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); +} + +.notification_language select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; +} + +.notification_language { + position: relative; + width: 100%; + max-width: 100%; +} + +.notification_language::after { + content: ""; + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + pointer-events: none; + + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #555; } .language__select select { -webkit-appearance: none; - -moz-appearance: none; - appearance: none; + -moz-appearance: none; + appearance: none; margin-right: 10px; background: #FDD256; border: none; @@ -1273,14 +1415,14 @@ body.rtl { display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; padding: 5px 12px; background-image: url("/static/icons/down.svg"); background-repeat: no-repeat; @@ -1302,17 +1444,17 @@ body.rtl { display: flex; -webkit-box-orient: horizontal; -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + -ms-flex-pack: center; + justify-content: center; -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + -ms-flex-align: center; + align-items: center; } -.language_drop > a { +.language_drop>a { background: #FDD256; display: flex; flex-direction: column; @@ -1332,7 +1474,7 @@ body.rtl { z-index: 999; } -.language_drop > a > span { +.language_drop>a>span { position: absolute; border-left: 4px solid transparent; border-right: 4px solid transparent; @@ -1361,9 +1503,9 @@ body.rtl { z-index: 99; } -.language_drop:hover > a > span, -.language_drop:active > a > span, -.language_drop:focus > a > span { +.language_drop:hover>a>span, +.language_drop:active>a>span, +.language_drop:focus>a>span { border-top: 4px solid transparent; border-bottom: 4px solid #303030; margin: -2px 0 0; @@ -1389,14 +1531,19 @@ body.rtl { position: relative; } -.language_drop .drop li{ +.language_drop .drop li { width: 100%; margin: 0 0 8px; - display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ - display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ - display: -ms-flexbox; /* TWEENER - IE 10 */ - display: -webkit-flex; /* NEW - Chrome */ - display: flex; /* NEW, Spec - Opera 12.1, Firefox 20+ */ + display: -webkit-box; + /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; + /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; + /* TWEENER - IE 10 */ + display: -webkit-flex; + /* NEW - Chrome */ + display: flex; + /* NEW, Spec - Opera 12.1, Firefox 20+ */ } .language_drop .drop .selected a { @@ -1420,11 +1567,16 @@ body.rtl { box-sizing: border-box; border-radius: 0 0 8px 8px; align-items: center; - display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ - display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ - display: -ms-flexbox; /* TWEENER - IE 10 */ - display: -webkit-flex; /* NEW - Chrome */ - display: flex; /* NEW, Spec - Opera 12.1, Firefox 20+ */ + display: -webkit-box; + /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; + /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; + /* TWEENER - IE 10 */ + display: -webkit-flex; + /* NEW - Chrome */ + display: flex; + /* NEW, Spec - Opera 12.1, Firefox 20+ */ } #header:after { @@ -1437,31 +1589,46 @@ body.rtl { align-items: center; padding: 12px 32px; flex: 1; - display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ - display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ - display: -ms-flexbox; /* TWEENER - IE 10 */ - display: -webkit-flex; /* NEW - Chrome */ - display: flex; /* NEW, Spec - Opera 12.1, Firefox 20+ */ + display: -webkit-box; + /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; + /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; + /* TWEENER - IE 10 */ + display: -webkit-flex; + /* NEW - Chrome */ + display: flex; + /* NEW, Spec - Opera 12.1, Firefox 20+ */ max-width: 664px; margin: 0 auto; } .header-content { - display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ - display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ - display: -ms-flexbox; /* TWEENER - IE 10 */ - display: -webkit-flex; /* NEW - Chrome */ - display: flex; /* NEW, Spec - Opera 12.1, Firefox 20+ */ + display: -webkit-box; + /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; + /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; + /* TWEENER - IE 10 */ + display: -webkit-flex; + /* NEW - Chrome */ + display: flex; + /* NEW, Spec - Opera 12.1, Firefox 20+ */ flex: 1; justify-content: space-between; } .logo-holder { - display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ - display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ - display: -ms-flexbox; /* TWEENER - IE 10 */ - display: -webkit-flex; /* NEW - Chrome */ - display: flex; /* NEW, Spec - Opera 12.1, Firefox 20+ */ + display: -webkit-box; + /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; + /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; + /* TWEENER - IE 10 */ + display: -webkit-flex; + /* NEW - Chrome */ + display: flex; + /* NEW, Spec - Opera 12.1, Firefox 20+ */ flex: 1; } @@ -1489,7 +1656,7 @@ body.rtl { margin-right: 12px; } -.form-holder .js-search-btn img{ +.form-holder .js-search-btn img { margin: 0 0 0 12px; border-left: 1px solid #EFEFEF; padding: 5px 0; @@ -1502,6 +1669,7 @@ body.rtl { border-radius: 0; border-width: 0 0 1px 0; } + .footer-head #header, .footer-head .header-holder { /* padding-left: 0; @@ -1531,15 +1699,18 @@ body.rtl { .header-holder { padding: 12px 20px; } + .logo-holder a { max-width: 68px; } + .language__select select { max-width: 74px; font-size: 10px; background-position: 60px; } - .language_drop > a { + + .language_drop>a { min-width: 80px; /* max-width: 80px; */ } @@ -1570,15 +1741,18 @@ body.rtl { padding-top: 20px; } } + @media screen and (max-width: 767px) { .content { padding: 16px 0; } } -.rtl .language_drop > a { + +.rtl .language_drop>a { padding: 5px 12px 5px 20px; } -.rtl .language_drop > a > span { + +.rtl .language_drop>a>span { right: auto; left: 11px; } @@ -1587,22 +1761,30 @@ body.rtl { .footer-head { display: none; } + #header { border-radius: 0; } + .header-holder { padding: 12px 0; max-width: 1000px; } + .logo-holder { flex: initial; } + #header .search__form { display: block; width: 100%; max-width: 600px; } - .form-holder .js-search-btn {display: none;} + + .form-holder .js-search-btn { + display: none; + } + .language__select { margin-left: 12px; } @@ -1616,18 +1798,25 @@ body.rtl { justify-content: flex-start; padding: 0 16px; } + .form-holder { - display: -webkit-box; /* OLD - iOS 6-, Safari 3.1-6 */ - display: -moz-box; /* OLD - Firefox 19- (buggy but mostly works) */ - display: -ms-flexbox; /* TWEENER - IE 10 */ - display: -webkit-flex; /* NEW - Chrome */ - display: flex; /* NEW, Spec - Opera 12.1, Firefox 20+ */ + display: -webkit-box; + /* OLD - iOS 6-, Safari 3.1-6 */ + display: -moz-box; + /* OLD - Firefox 19- (buggy but mostly works) */ + display: -ms-flexbox; + /* TWEENER - IE 10 */ + display: -webkit-flex; + /* NEW - Chrome */ + display: flex; + /* NEW, Spec - Opera 12.1, Firefox 20+ */ max-width: 600px; width: 60%; flex: 1; padding: 0 16px; flex-basis: 24.5%; } + .main-wrapper { width: 100%; display: flex; @@ -1635,6 +1824,7 @@ body.rtl { box-sizing: border-box; } } + @media screen and (min-width: 1400px) { .header-holder { max-width: 1400px; @@ -1642,12 +1832,14 @@ body.rtl { padding-right: 0; justify-content: space-between; } + #header .btn-holder { display: block; width: 29%; max-width: 400px; padding: 0 20px; } + .form-holder { width: 42%; flex-basis: 42%; @@ -1657,6 +1849,7 @@ body.rtl { #header .btn-holder .nav-bar__item__icon { /* display: flex; */ } + #header .btn-holder .nav-bar__item a { margin: 0; } @@ -1666,6 +1859,7 @@ body.rtl { width: 29%; max-width: 400px; } + .link-login { display: none; } @@ -1673,30 +1867,30 @@ body.rtl { .content .article__content__link-btn { - margin-top: 20px; - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-orient: horizontal; - -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - padding: 8px 16px; - border: 1px solid #303030; - border-radius: 8px; - width: 100%; - font-style: normal; - font-weight: 600; - font-size: 10px; - line-height: 16px; - color: #303030; - text-decoration: none; + margin-top: 20px; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + padding: 8px 16px; + border: 1px solid #303030; + border-radius: 8px; + width: 100%; + font-style: normal; + font-weight: 600; + font-size: 10px; + line-height: 16px; + color: #303030; + text-decoration: none; } #app .content-holder .content h1.large { @@ -1713,7 +1907,8 @@ body.rtl { margin: 16px 0; } -#app .content-holder .content h2, .block-heading { +#app .content-holder .content h2, +.block-heading { font-weight: bold; font-size: 16px; line-height: 18px; @@ -1773,11 +1968,11 @@ body.rtl { } } -@media only screen and (min-width: 360px){ +@media only screen and (min-width: 360px) { .content .article__content__link-btn { - font-size: 16px; - line-height: 22px; - padding: 12px 16px; + font-size: 16px; + line-height: 22px; + padding: 12px 16px; } .block-heading { @@ -1802,7 +1997,8 @@ body.rtl { margin: 22px 0; } - #app .content-holder .content h2, .block-heading { + #app .content-holder .content h2, + .block-heading { font-weight: bold; font-size: 22px; line-height: 24px; @@ -1914,9 +2110,9 @@ body.rtl { } #content-wrap .first-content { - flex-direction: column-reverse; - align-items: flex-start; - width: 100%; + flex-direction: column-reverse; + align-items: flex-start; + width: 100%; } #content-wrap .first-content .overlay-holder { @@ -1930,8 +2126,10 @@ body.rtl { width: 100% !important; } -#content-wrap .first-content .img-holder, #content-wrap .first-content .article-header, #content-wrap .first-content .section-header { - width: 100%; +#content-wrap .first-content .img-holder, +#content-wrap .first-content .article-header, +#content-wrap .first-content .section-header { + width: 100%; } /* questionnaires start */ @@ -1943,4 +2141,225 @@ body.rtl { width: 100%; height: auto; } -/* questionnaires end */ \ No newline at end of file + +/* questionnaires end */ + + + +/* Notification Section */ +.notification-overlay { + position: fixed; + display: none; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.4); + z-index: 999; +} + +/* Notification panel */ +.notification-panel { + position: fixed; + top: 0; + right: 0; + width: 460px; + height: 100%; + background: #fff; + box-shadow: -4px 0 12px rgba(0,0,0,0.2); + padding: 24px; + font-family: Arial, sans-serif; + display: none; + flex-direction: column; + z-index: 1000; + overflow-y: auto; +} + +/* Header */ +.notification-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #e0e0e0; + font-size: 16px; + font-weight: 600; + margin-bottom: 10px; +} + +/* Close Button */ +.notification-close-btn { + background: transparent; + border: none; + font-size: 20px; + cursor: pointer; + width: 20%; +} + +.notification-list { + margin: 0; + padding: 0; +} + +.notification-item { + display: block; + width: 100%; + padding: 1rem; + text-decoration: none; + color: inherit; + border-bottom: 1px solid #e6e6e6; + border-radius: 8px; + transition: background 0.2s ease; + box-sizing: border-box; + background-color: #f0f8ff; + margin-bottom: 5px; + box-shadow: 0 1px 4px rgba(0,0,0,0.05); +} + +.notification-item:hover { + background-color: #f1f5fa; +} + +.notification-item.read { + background-color: #fff; + font-weight: normal; +} + +.notification-body { + margin: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.notification-text { + margin: 0; + font-size: 0.8rem; +} + +.notification-title { + font-weight: 600; + font-size: 0.85rem; +} + +.notification-empty { + padding: 24px; + text-align: center; + color: #999; + font-style: italic; +} + +.notification-footer { + text-align: center; + padding: 12px 0; + background: #fafafa; + border-top: 1px solid #e0e0e0; +} + +.notification-footer a { + color: #3367d6; + font-weight: 500; + text-decoration: none; +} + +.notification-footer a:hover { + text-decoration: underline; +} + +.bold { + font-weight: bold; +} + +.unread-dot { + display: inline-block; + width: 8px; + height: 8px; + background-color: #007bff; /* Bootstrap primary blue */ + border-radius: 50%; + margin-left: 8px; +} + +@media (max-width: 768px) { + #notification-overlay { + width: 100%; + } +} + +.highlight-yes { +background-color: #4caf50 !important; +color: white !important; +} +.highlight-no { +background-color: #f44336 !important; +color: white !important; +} +#yes_button, #no_button { + background-color: #e0e0e0; + color: black; + border: none; + cursor: pointer; + width: 92px; + height: 40 px; + height: 40px; + margin-left: 10px; +} +div.notification-confirmation{ + margin-bottom: 10px; + display: flex; + align-items: center; +} + + +.notification-callout-text { + background-color: #eaf7ff; + color: #0c5460; + font-size: 13px; + padding: 4px 8px; + margin-top: 5px; + border-radius: 4px; +} + +.enable-notifications-nudge { + position: fixed; + bottom: 20px; + left: 20px; + background-color: #fff8e1; + color: black; + border-left: 5px solid goldenrod; + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + padding: 15px 10px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 10px; + z-index: 9999; + font-family: "Segoe UI", sans-serif; + animation: slideIn 0.5s ease-out; + margin-right: 15px; + font-size: large; +} + +.notification-toast a { + color: #007bff; + text-decoration: underline; + font-weight: 500; + cursor: pointer; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0%); + opacity: 1; + } +} + +.notification-name { + font-size: 0.75rem; + color: #555; + margin: 0; + margin-bottom: 5px; +} \ No newline at end of file diff --git a/home/templates/home/home_page.html b/home/templates/home/home_page.html index 99c3ffaba..1556fdfaa 100644 --- a/home/templates/home/home_page.html +++ b/home/templates/home/home_page.html @@ -9,6 +9,11 @@ {% block content %}
+ {% if user.is_authenticated and not notification_preference.receive_notifications %} +
+ 🔔 Don’t miss out! Turn on notifications to stay updated with the latest from IOGT." +
+ {% endif %} {% include 'home/tags/banners_list.html' with banners=banners %}
{% if page.home_featured_content %} diff --git a/home/templatetags/clean_html.py b/home/templatetags/clean_html.py new file mode 100644 index 000000000..040347ea8 --- /dev/null +++ b/home/templatetags/clean_html.py @@ -0,0 +1,9 @@ +from django import template +from bs4 import BeautifulSoup + +register = template.Library() + +@register.filter +def strip_html(value): + soup = BeautifulSoup(value, "html.parser") + return soup.get_text(separator=" ", strip=True) diff --git a/home/wagtail_hooks.py b/home/wagtail_hooks.py index c508f2610..b811c4c41 100644 --- a/home/wagtail_hooks.py +++ b/home/wagtail_hooks.py @@ -23,6 +23,9 @@ from translation_manager.models import TranslationEntry from wagtail.core.signals import page_published from django.dispatch import receiver +from iogt.utils import NotifyAndPublishMenuItem, notify_and_publish_view +from .models import Article +from django.urls import path @hooks.register('after_publish_page') @@ -238,3 +241,15 @@ def create_home_page_banner(sender, instance, **kwargs): parent = parent.get_parent().specific if parent: HomePageBanner.objects.get_or_create(source=parent, banner_page=instance) + + +@hooks.register('register_admin_urls') +def register_custom_form_pages_list_view(): + return [ + path("notify-and-publish//", notify_and_publish_view, name="notify_and_publish"), + ] + + +@hooks.register('register_page_action_menu_item') +def register_notify_and_publish_menu_item(): + return NotifyAndPublishMenuItem(order=100, allowed_models=Article) # \ No newline at end of file diff --git a/iogt/__init__.py b/iogt/__init__.py index e69de29bb..fb989c4e6 100644 --- a/iogt/__init__.py +++ b/iogt/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/iogt/celery.py b/iogt/celery.py new file mode 100644 index 000000000..3adc0f663 --- /dev/null +++ b/iogt/celery.py @@ -0,0 +1,10 @@ +import os +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iogt.settings.base') + +app = Celery('iogt') + +app.config_from_object('django.conf:settings', namespace='CELERY') + +app.autodiscover_tasks() diff --git a/iogt/settings/base.py b/iogt/settings/base.py index 500c7ec7d..974b18840 100644 --- a/iogt/settings/base.py +++ b/iogt/settings/base.py @@ -49,6 +49,8 @@ 'matomo', 'messaging', 'modelcluster', + 'admin_notifications', + 'user_notifications', 'notifications', 'questionnaires', 'rest_framework', @@ -129,7 +131,7 @@ 'home.processors.commit_hash', 'home.processors.show_footers', 'messaging.processors.add_vapid_public_key', - 'notifications.processors.push_notification', + 'admin_notifications.processors.push_notification', 'home.processors.jquery', ], }, @@ -226,6 +228,9 @@ SITE_ID = 1 +#Notifications +DJANGO_NOTIFICATIONS_CONFIG = { 'USE_JSONFIELD': True} + # Comments COMMENTS_APP = 'django_comments_xtd' COMMENTS_XTD_MAX_THREAD_LEVEL = 1 @@ -299,6 +304,11 @@ ('uz', _('Uzbek')), ('zu', _('Zulu')), ('xy', _('Testing')), + ('ha', _('Hausa')), + ('yo', _('Yoruba')), + ('ig', _('Igbo')), + ('pcm', _('Pidgin')), + ] EXTRA_LANG_INFO = { @@ -398,6 +408,24 @@ 'name': 'Testing', 'name_local': 'Testing', }, + 'ha': { + 'bidi': False, + 'code': 'ha', + 'name': 'Hausa', + 'name_local': 'Hausa', + }, + 'yo': { + 'bidi': False, + 'code': 'yo', + 'name': 'Yoruba', + 'name_local': 'Yoruba', + }, + 'pcm': { + 'bidi': False, + 'code': 'pcm', + 'name': 'Pidgin', + 'name_local': 'Pidgin', + }, } django.conf.locale.LANG_INFO.update(EXTRA_LANG_INFO) @@ -606,6 +634,11 @@ CSRF_COOKIE_SECURE = True CSRF_COOKIE_HTTPONLY = True +CELERY_BROKER_URL = 'redis://redis:6379/0' +CELERY_RESULT_BACKEND = 'redis://redis:6379/0' +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' + # Enforce HTTPS and HSTS # SECURE_SSL_REDIRECT = True # SECURE_HSTS_SECONDS = 31536000 @@ -613,4 +646,4 @@ # SECURE_HSTS_PRELOAD = True # Ensure Django trusts the proxy (if applicable) -# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") \ No newline at end of file diff --git a/iogt/settings/dev.py b/iogt/settings/dev.py index eb76e60df..5d6e34767 100644 --- a/iogt/settings/dev.py +++ b/iogt/settings/dev.py @@ -1,4 +1,5 @@ from .base import * +from os import getenv WAGTAILADMIN_BASE_URL = 'http://localhost:8000' DEBUG = True @@ -18,7 +19,16 @@ } INSTALLED_APPS += ("django_extensions",) - +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': getenv('DB_NAME', 'postgres'), + 'USER': getenv('DB_USER', 'postgres'), + 'PASSWORD': getenv('DB_PASSWORD', 'iogt'), + 'HOST': getenv('DB_HOST', 'database'), + 'PORT': getenv('DB_PORT', '5432'), + } +} try: from .local import * except ImportError: diff --git a/iogt/settings/production.py b/iogt/settings/production.py index bfb3dbb75..2ee67335e 100644 --- a/iogt/settings/production.py +++ b/iogt/settings/production.py @@ -33,7 +33,7 @@ }, } -SITE_VERSION = '3.0.8' +SITE_VERSION = '3.0.9-rc.15' try: from .local import * diff --git a/iogt/static/css/accounts.css b/iogt/static/css/accounts.css index 21f90ba07..12003b03d 100644 --- a/iogt/static/css/accounts.css +++ b/iogt/static/css/accounts.css @@ -73,6 +73,7 @@ form input[type="checkbox"] { .profile-form__btn { margin-bottom: 8px; + margin-left: 0px; } .profile-form__btn:last-child { @@ -95,3 +96,39 @@ form input[type="checkbox"] { font-weight: 500; line-height: 14px; } + .multiselect-dropdown { + position: relative; + width: 540px; + user-select: none; + } + + .dropdown-btn { + padding: 10px; + border: 1px solid #ccc; + background: #fff; + cursor: pointer; + border-radius: 5px; + } + + .dropdown-content { + display: none; + position: absolute; + background-color: white; + border: 1px solid #ccc; + border-top: none; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + width: 100%; + border-radius: 0 0 5px 5px; + } + + .dropdown-content label { + display: block; + padding: 8px 10px; + cursor: pointer; + } + + .dropdown-content label:hover { + background-color: #f0f0f0; + } \ No newline at end of file diff --git a/iogt/static/css/iogt.css b/iogt/static/css/iogt.css index f1a00457d..b43454acc 100644 --- a/iogt/static/css/iogt.css +++ b/iogt/static/css/iogt.css @@ -2205,8 +2205,9 @@ } .load-more a, button{ + margin:10px; font-size: 14px; - line-height: 20px; + line-height: 10px; font-weight: 600; border-radius: 24px; padding: 12px 16px; @@ -2214,7 +2215,7 @@ color: #303030; display: block; text-align: center; - width: 100%; + width: 20%; cursor: pointer; transition: all 0.2s linear; text-decoration: none; diff --git a/iogt/static/css/user-profile.css b/iogt/static/css/user-profile.css index 98ea54269..552916828 100644 --- a/iogt/static/css/user-profile.css +++ b/iogt/static/css/user-profile.css @@ -9,6 +9,10 @@ clear: both } +p.notification-enable { + font-size: 16px; + color: #EE4B2B; +} .profile__icon { background-color: #0070e2; border-radius: 50%; @@ -47,7 +51,8 @@ } .profile-form__btn { - margin-bottom: 8px + margin-bottom: 8px; + margin-left: 0px; } .profile-form__btn:last-child { diff --git a/iogt/static/icons/bell-solid.svg b/iogt/static/icons/bell-solid.svg new file mode 100644 index 000000000..96f35738e --- /dev/null +++ b/iogt/static/icons/bell-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/iogt/static/js/iogt-no-jquery.js b/iogt/static/js/iogt-no-jquery.js index de4b1c069..89a24d4c5 100644 --- a/iogt/static/js/iogt-no-jquery.js +++ b/iogt/static/js/iogt-no-jquery.js @@ -1,303 +1,358 @@ const ready = (callback) => { - if (document.readyState !== "loading") callback(); - else document.addEventListener("DOMContentLoaded", callback); + if (document.readyState !== "loading") callback(); + else document.addEventListener("DOMContentLoaded", callback); }; - -function showToast(message, type = 'info') { - const toast = document.getElementById('toast-notification'); - - if (!toast) return; - - toast.textContent = message; - - // Set color - switch (type) { - case 'success': - toast.style.backgroundColor = '#4caf50'; - break; - case 'error': - toast.style.backgroundColor = '#f44336'; - break; - case 'warning': - toast.style.backgroundColor = '#ff9800'; - break; - default: - toast.style.backgroundColor = '#333'; - } - - toast.style.display = 'block'; - toast.style.opacity = '1'; - toast.style.transform = 'translateY(0)'; - - // Force reflow for iOS animation - void toast.offsetHeight; - - setTimeout(() => { - toast.style.opacity = '0'; - toast.style.transform = 'translateY(-20px)'; - }, 3000); - - // Hide after transition - setTimeout(() => { - toast.style.display = 'none'; - }, 3500); - } +function showToast(message, type = "info") { + const toast = document.getElementById("toast-notification"); + + if (!toast) return; + + toast.textContent = message; + + // Set color + switch (type) { + case "success": + toast.style.backgroundColor = "#4caf50"; + break; + case "error": + toast.style.backgroundColor = "#f44336"; + break; + case "warning": + toast.style.backgroundColor = "#ff9800"; + break; + default: + toast.style.backgroundColor = "#333"; + } + + toast.style.display = "block"; + toast.style.opacity = "1"; + toast.style.transform = "translateY(0)"; + + // Force reflow for iOS animation + void toast.offsetHeight; + + setTimeout(() => { + toast.style.opacity = "0"; + toast.style.transform = "translateY(-20px)"; + }, 3000); + + // Hide after transition + setTimeout(() => { + toast.style.display = "none"; + }, 3500); +} const init = () => { - const show = (el) => el.style.display = ''; - const hide = (el) => el.style.display = 'none'; - - const externalLinkOverlay = document.querySelector('#external-link-overlay'); - externalLinkOverlay?.addEventListener('click', (event) => hide(event.target)); - - const submitWhenOffline = gettext('You cannot submit when offline'); - - const readContent = document.querySelectorAll('.complete'); - const commentLikeHolders = document.querySelectorAll('.like-holder'); - const replyLinks = document.querySelectorAll('.reply-link'); - const offlineAppBtns = document.querySelectorAll('.offline-app-btn'); - const chatbotBtns = document.querySelectorAll('.chatbot-btn'); - const questionnaireSubmitBtns = document.querySelectorAll('.questionnaire-submit-btn'); - - // Save original button label - questionnaireSubmitBtns.forEach(btn => { - const span = btn.querySelector('span'); - if (span && !span.dataset.originalLabel) { - span.dataset.originalLabel = span.textContent.trim(); - } - }); - - const externalLinks = document.querySelectorAll('a[href*="/external-link/?next="]'); - const elementsToToggle = [ - '.download-app-btn', - '.login-create-account-btn', - '.change-digital-pin', - '.comments__form', - '.logout-btn', - '.progress-holder', - '.report-comment', - '.search-form-holder', - ].flatMap(selector => Array.from(document.querySelectorAll(selector))); - - const blockExternalLinks = (event) => { - event.preventDefault(); - show(externalLinkOverlay); - }; - - const hideFooterMenu = () => { - hide(document.querySelector('.footer-head')); - }; - - if ('serviceWorker' in navigator) { - navigator.serviceWorker.addEventListener('message', event => { - const data = event.data; - if (!data) return; - - switch (data.type) { - case 'sync-success': - showToast(`✅ Synced back offline filled form.`, 'success'); - break; - case 'sync-failed': - showToast(`⚠️ Offline form sync failed for: ${data.url}`, 'error'); - break; - case 'sync-error': - showToast(`❌ Sync error: ${data.error}`, 'error'); - break; - } - }); + const show = (el) => (el.style.display = ""); + const hide = (el) => (el.style.display = "none"); + + const externalLinkOverlay = document.querySelector("#external-link-overlay"); + externalLinkOverlay?.addEventListener("click", (event) => hide(event.target)); + + const submitWhenOffline = gettext("You cannot submit when offline"); + + const readContent = document.querySelectorAll(".complete"); + const commentLikeHolders = document.querySelectorAll(".like-holder"); + const replyLinks = document.querySelectorAll(".reply-link"); + const offlineAppBtns = document.querySelectorAll(".offline-app-btn"); + const chatbotBtns = document.querySelectorAll(".chatbot-btn"); + const questionnaireSubmitBtns = document.querySelectorAll( + ".questionnaire-submit-btn" + ); + + // Save original button label + questionnaireSubmitBtns.forEach((btn) => { + const span = btn.querySelector("span"); + if (span && !span.dataset.originalLabel) { + span.dataset.originalLabel = span.textContent.trim(); } - - const disableForOfflineAccess = () => { - elementsToToggle.forEach(hide); - replyLinks.forEach(hide); - readContent.forEach(el => el.classList.remove('complete')); - commentLikeHolders.forEach(el => el.setAttribute('style', 'display:none !important')); - offlineAppBtns.forEach(show); - chatbotBtns.forEach(btn => { - btn.style.pointerEvents = 'none'; - btn.style.background = '#808080'; - }); - questionnaireSubmitBtns.forEach(btn => { - btn.style.pointerEvents = 'all'; // ensure it's still clickable - const span = btn.querySelector('span'); - if (span) { - span.textContent = span.dataset.originalLabel || span.textContent.trim(); - } - }); - externalLinks.forEach(link => link.addEventListener('click', blockExternalLinks)); - }; - - const enableForOnlineAccess = () => { - elementsToToggle.forEach(show); - readContent.forEach(el => el.classList.add('complete')); - commentLikeHolders.forEach(el => el.setAttribute('style', 'display:inline-block !important')); - replyLinks.forEach(el => el.setAttribute('style', 'display:inline-block')); - offlineAppBtns.forEach(hide); - chatbotBtns.forEach(btn => { - btn.style.pointerEvents = 'all'; - btn.style.background = '#F7F7F9'; - }); - questionnaireSubmitBtns.forEach(btn => { - btn.style.pointerEvents = 'all'; - const span = btn.querySelector('span'); - if (span) { - span.textContent = span.dataset.originalLabel || span.textContent.split('(')[0].trim(); - } - }); - externalLinks.forEach(link => { - show(link); - link.removeEventListener('click', blockExternalLinks); - }); - }; - - window.addEventListener('offline', () => { - console.warn("🔌 Offline detected."); - disableForOfflineAccess(); - if (getItem('offlineReady') === true) { - console.log("📦 Page cached. Reloading offline view..."); - setTimeout(() => location.reload(), 3000); - } + }); + + const externalLinks = document.querySelectorAll( + 'a[href*="/external-link/?next="]' + ); + const elementsToToggle = [ + ".download-app-btn", + ".login-create-account-btn", + ".change-digital-pin", + ".comments__form", + ".logout-btn", + ".notification-pref-btn", + ".progress-holder", + ".report-comment", + ".search-form-holder", + ].flatMap((selector) => Array.from(document.querySelectorAll(selector))); + + const blockExternalLinks = (event) => { + event.preventDefault(); + show(externalLinkOverlay); + }; + + const hideFooterMenu = () => { + hide(document.querySelector(".footer-head")); + }; + + if ("serviceWorker" in navigator) { + navigator.serviceWorker.addEventListener("message", (event) => { + const data = event.data; + if (!data) return; + + switch (data.type) { + case "sync-success": + showToast(`✅ Synced back offline filled form.`, "success"); + break; + case "sync-failed": + showToast(`⚠️ Offline form sync failed for: ${data.url}`, "error"); + break; + case "sync-error": + showToast(`❌ Sync error: ${data.error}`, "error"); + break; + } + }); + } + + const disableForOfflineAccess = () => { + elementsToToggle.forEach(hide); + replyLinks.forEach(hide); + readContent.forEach((el) => el.classList.remove("complete")); + commentLikeHolders.forEach((el) => + el.setAttribute("style", "display:none !important") + ); + offlineAppBtns.forEach(show); + chatbotBtns.forEach((btn) => { + btn.style.pointerEvents = "none"; + btn.style.background = "#808080"; + }); + questionnaireSubmitBtns.forEach((btn) => { + btn.style.pointerEvents = "all"; // ensure it's still clickable + const span = btn.querySelector("span"); + if (span) { + span.textContent = + span.dataset.originalLabel || span.textContent.trim(); + } }); + externalLinks.forEach((link) => + link.addEventListener("click", blockExternalLinks) + ); + }; + + const enableForOnlineAccess = () => { + elementsToToggle.forEach(show); + readContent.forEach((el) => el.classList.add("complete")); + commentLikeHolders.forEach((el) => + el.setAttribute("style", "display:inline-block !important") + ); + replyLinks.forEach((el) => + el.setAttribute("style", "display:inline-block") + ); + offlineAppBtns.forEach(hide); + chatbotBtns.forEach((btn) => { + btn.style.pointerEvents = "all"; + btn.style.background = "#F7F7F9"; + }); + questionnaireSubmitBtns.forEach((btn) => { + btn.style.pointerEvents = "all"; + const span = btn.querySelector("span"); + if (span) { + span.textContent = + span.dataset.originalLabel || span.textContent.split("(")[0].trim(); + } + }); + externalLinks.forEach((link) => { + show(link); + link.removeEventListener("click", blockExternalLinks); + }); + }; + + window.addEventListener("offline", () => { + console.warn("🔌 Offline detected."); + disableForOfflineAccess(); + if (getItem("offlineReady") === true) { + console.log("📦 Page cached. Reloading offline view..."); + setTimeout(() => location.reload(), 3000); + } + }); - window.addEventListener('online', enableForOnlineAccess); + window.addEventListener("online", enableForOnlineAccess); - window.navigator.onLine ? enableForOnlineAccess() : disableForOfflineAccess(); + window.navigator.onLine ? enableForOnlineAccess() : disableForOfflineAccess(); - hideFooterMenu(); + hideFooterMenu(); - // Force re-check of online status - fetch(window.location.href, { method: 'HEAD', cache: 'no-cache' }) + // Force re-check of online status + fetch(window.location.href, { method: "HEAD", cache: "no-cache" }) .then(() => { - console.log("✅ Verified online via HEAD request"); - enableForOnlineAccess(); + console.log("✅ Verified online via HEAD request"); + enableForOnlineAccess(); }) .catch(() => { - console.warn("⚠️ Verified offline via HEAD request"); - disableForOfflineAccess(); + console.warn("⚠️ Verified offline via HEAD request"); + disableForOfflineAccess(); }); }; -const download = pageId => { - showToast("📥 Downloading…"); - console.log("Starting download for page:", pageId); - - fetch(`/page-tree/${pageId}/`) - .then(resp => { - if (!resp.ok) { - throw new Error(`Failed to fetch URLs for caching. Status: ${resp.status}`); - } - return resp.json(); - }) - .then(urls => { - if (!Array.isArray(urls) || urls.length === 0) { - throw new Error("No URLs received for caching."); - } - - return caches.open('iogt').then(cache => { - console.log("URLs to cache:", urls); - return Promise.all(urls.map(url => - fetch(url, { method: 'HEAD' }) - .then(response => { - if (response.ok) { - return cache.add(url).catch(error => { - if (error.name === 'QuotaExceededError') { - alert("⚠️ Your storage limit has been reached! Please free up space."); - throw new Error("Storage full! Cannot cache more content."); - } - throw error; - }); - } else { - console.warn(`Skipping invalid URL: ${url} (Status: ${response.status})`); - } - }) - .catch(err => console.warn(`Skipping ${url} due to error:`, err)) - )); - }); - }) - .then(() => { - setItem('offlineReady', true); // ✅ Mark ready for offline - console.log("✅ Content cached successfully!"); - showToast("✅ Content is now available offline!", "success"); - location.reload(); - }) - .catch(error => { - console.error("❌ Download error:", error); - alert("⚠️ Download failed. Please try again."); - }); +const download = (pageId) => { + showToast("📥 Downloading…"); + console.log("Starting download for page:", pageId); + + fetch(`/page-tree/${pageId}/`) + .then((resp) => { + if (!resp.ok) { + throw new Error( + `Failed to fetch URLs for caching. Status: ${resp.status}` + ); + } + return resp.json(); + }) + .then((urls) => { + if (!Array.isArray(urls) || urls.length === 0) { + throw new Error("No URLs received for caching."); + } + + return caches.open("iogt").then((cache) => { + console.log("URLs to cache:", urls); + return Promise.all( + urls.map((url) => + fetch(url, { method: "HEAD" }) + .then((response) => { + if (response.ok) { + return cache.add(url).catch((error) => { + if (error.name === "QuotaExceededError") { + alert( + "⚠️ Your storage limit has been reached! Please free up space." + ); + throw new Error( + "Storage full! Cannot cache more content." + ); + } + throw error; + }); + } else { + console.warn( + `Skipping invalid URL: ${url} (Status: ${response.status})` + ); + } + }) + .catch((err) => + console.warn(`Skipping ${url} due to error:`, err) + ) + ) + ); + }); + }) + .then(() => { + setItem("offlineReady", true); // ✅ Mark ready for offline + console.log("✅ Content cached successfully!"); + showToast("✅ Content is now available offline!", "success"); + location.reload(); + }) + .catch((error) => { + console.error("❌ Download error:", error); + alert("⚠️ Download failed. Please try again."); + }); }; -function getItem (key, defaultValue = null) { - try { - return JSON.parse(localStorage.getItem(key)) ?? defaultValue; - } catch { - return defaultValue; - } -}; +function getItem(key, defaultValue = null) { + try { + return JSON.parse(localStorage.getItem(key)) ?? defaultValue; + } catch { + return defaultValue; + } +} const setItem = (key, value) => { - localStorage.setItem(key, JSON.stringify(value)); + localStorage.setItem(key, JSON.stringify(value)); }; -const registerPushNotification = registration => { - if (!registration.showNotification) return; - if (Notification.permission === 'denied') return; - if (!'PushManager' in window) return; +const registerPushNotification = (registration) => { + if (!registration.showNotification) return; + if (!"PushManager" in window) return; + if (Notification.permission === "default") { + Notification.requestPermission(function (permission) { + if (permission === "granted") { + subscribe(registration); + } + }); + } else if (Notification.permission === "granted") { subscribe(registration); + } }; -const urlB64ToUint8Array = base64String => { - const padding = '='.repeat((4 - base64String.length % 4) % 4); - const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); - const rawData = window.atob(base64); - return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); +const urlB64ToUint8Array = (base64String) => { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; }; -const subscribe = registration => { - registration.pushManager.getSubscription() - .then(subscription => { - if (subscription) { - sendSubscriptionToServer(subscription, 'subscribe'); - return; - } - const vapidKey = document.querySelector('meta[name="vapid-key"]')?.content; - const options = { userVisibleOnly: true }; - if (vapidKey) options.applicationServerKey = urlB64ToUint8Array(vapidKey); - - registration.pushManager.subscribe(options) - .then(subscription => sendSubscriptionToServer(subscription, 'subscribe')) - .catch(error => console.log("Error during subscribe()", error)); - }) - .catch(error => console.log("Error during getSubscription()", error)); +const subscribe = (registration) => { + registration.pushManager + .getSubscription() + .then((subscription) => { + if (subscription) { + sendSubscriptionToServer(subscription, "subscribe"); + return; + } + const vapidKey = document.querySelector( + 'meta[name="vapid-key"]' + )?.content; + const options = { userVisibleOnly: true }; + if (vapidKey) options.applicationServerKey = urlB64ToUint8Array(vapidKey); + + registration.pushManager + .subscribe(options) + .then((subscription) => + sendSubscriptionToServer(subscription, "subscribe") + ) + .catch((error) => console.log("Error during subscribe()", error)); + }) + .catch((error) => console.log("Error during getSubscription()", error)); }; const sendSubscriptionToServer = (subscription, statusType) => { - const browser = navigator.userAgent.match(/(firefox|msie|chrome|safari|trident)/ig)[0].toLowerCase(); - const data = { - status_type: statusType, - subscription: subscription.toJSON(), - browser: browser, - }; - - fetch('/webpush/subscribe/', { - method: 'POST', - body: JSON.stringify(data), - headers: { 'content-type': 'application/json' }, - credentials: "include" - }).then(() => { - setItem('isPushNotificationRegistered', statusType === 'subscribe'); - }); + const browser = navigator.userAgent + .match(/(firefox|msie|chrome|safari|trident)/gi)[0] + .toLowerCase(); + const data = { + status_type: statusType, + subscription: subscription.toJSON(), + browser: browser, + }; + + fetch("/webpush/subscribe/", { + method: "POST", + body: JSON.stringify(data), + headers: { "content-type": "application/json" }, + credentials: "include", + }).then(() => { + setItem("isPushNotificationRegistered", statusType === "subscribe"); + }); }; const unSubscribePushNotifications = () => { - const isPushNotificationRegistered = getItem('isPushNotificationRegistered', false); - if (isPushNotificationRegistered && isAuthenticated && 'serviceWorker' in navigator) { - navigator.serviceWorker.ready.then(registration => { - registration.pushManager.getSubscription().then(subscription => { - if (subscription) sendSubscriptionToServer(subscription, 'unsubscribe'); - }); - }); - } + const isPushNotificationRegistered = getItem( + "isPushNotificationRegistered", + false + ); + if ( + isPushNotificationRegistered && + isAuthenticated && + "serviceWorker" in navigator + ) { + navigator.serviceWorker.ready.then((registration) => { + registration.pushManager.getSubscription().then((subscription) => { + if (subscription) sendSubscriptionToServer(subscription, "unsubscribe"); + }); + }); + } }; ready(init); diff --git a/iogt/static/js/iogt.js b/iogt/static/js/iogt.js index 6e21fcb0e..d1bd02990 100644 --- a/iogt/static/js/iogt.js +++ b/iogt/static/js/iogt.js @@ -1,314 +1,361 @@ -function showToast(message, type = 'info') { - const toast = document.getElementById('toast-notification'); - - if (!toast) return; - - toast.textContent = message; - - // Set color - switch (type) { - case 'success': - toast.style.backgroundColor = '#4caf50'; - break; - case 'error': - toast.style.backgroundColor = '#f44336'; - break; - case 'warning': - toast.style.backgroundColor = '#ff9800'; - break; - default: - toast.style.backgroundColor = '#333'; - } - - toast.style.display = 'block'; - toast.style.opacity = '1'; - toast.style.transform = 'translateY(0)'; - - // Force reflow for iOS animation - void toast.offsetHeight; - - setTimeout(() => { - toast.style.opacity = '0'; - toast.style.transform = 'translateY(-20px)'; - }, 3000); - - // Hide after transition - setTimeout(() => { - toast.style.display = 'none'; - }, 3500); - } +function showToast(message, type = "info") { + const toast = document.getElementById("toast-notification"); + + if (!toast) return; + + toast.textContent = message; + + // Set color + switch (type) { + case "success": + toast.style.backgroundColor = "#4caf50"; + break; + case "error": + toast.style.backgroundColor = "#f44336"; + break; + case "warning": + toast.style.backgroundColor = "#ff9800"; + break; + default: + toast.style.backgroundColor = "#333"; + } + + toast.style.display = "block"; + toast.style.opacity = "1"; + toast.style.transform = "translateY(0)"; + + // Force reflow for iOS animation + void toast.offsetHeight; + + setTimeout(() => { + toast.style.opacity = "0"; + toast.style.transform = "translateY(-20px)"; + }, 3000); + + // Hide after transition + setTimeout(() => { + toast.style.display = "none"; + }, 3500); +} $(document).ready(() => { - const externalLinkOverlay = $('#external-link-overlay'); - externalLinkOverlay.click(() => externalLinkOverlay.css('display', 'none')); - - const submitWhenOffline = gettext('You cannot submit when offline'); - - const searchFormHolder = $('.search-form-holder'); - const readContent = $('.complete'); - const commentForm = $('.comments__form'); - const commentLikeHolders = $('.like-holder'); - const reportComment = $('.report-comment'); - const commentReplyLinks = $('.reply-link'); - const downloadAppBtns = $('.download-app-btn'); - const offlineAppBtns = $('.offline-app-btn'); - const chatbotBtns = $('.chatbot-btn'); - const questionnaireSubmitBtns = $('.questionnaire-submit-btn'); - const progressHolder = $('.progress-holder'); - const changeDigitalPinBtn = $('.change-digital-pin'); - const loginCreateAccountBtns = $('.login-create-account-btn'); - const logoutBtn = $('.logout-btn'); - const externalLinks = $('a[href*="/external-link/?next="]'); - + const externalLinkOverlay = $("#external-link-overlay"); + externalLinkOverlay.click(() => externalLinkOverlay.css("display", "none")); + + const submitWhenOffline = gettext("You cannot submit when offline"); + + const searchFormHolder = $(".search-form-holder"); + const readContent = $(".complete"); + const commentForm = $(".comments__form"); + const commentLikeHolders = $(".like-holder"); + const reportComment = $(".report-comment"); + const commentReplyLinks = $(".reply-link"); + const downloadAppBtns = $(".download-app-btn"); + const offlineAppBtns = $(".offline-app-btn"); + const chatbotBtns = $(".chatbot-btn"); + const questionnaireSubmitBtns = $(".questionnaire-submit-btn"); + const progressHolder = $(".progress-holder"); + const changeDigitalPinBtn = $(".change-digital-pin"); + const loginCreateAccountBtns = $(".login-create-account-btn"); + const logoutBtn = $(".logout-btn"); + const notificationPreferenceButton = $(".notification-pref-btn"); + const externalLinks = $('a[href*="/external-link/?next="]'); + + questionnaireSubmitBtns.each((index, btn) => { + const $btn = $(btn); + const span = $btn.find("span"); + if (!span.attr("data-original-label")) { + span.attr("data-original-label", span.text().trim()); + } + }); + + const disableForOfflineAccess = () => { + searchFormHolder.hide(); + readContent.removeClass("complete"); + commentForm.hide(); + commentLikeHolders.attr("style", "display: none !important"); + reportComment.hide(); + commentReplyLinks.hide(); + downloadAppBtns.hide(); + offlineAppBtns.show(); + chatbotBtns.each((index, btn) => { + const $btn = $(btn); + $btn.css("pointer-events", "none"); + $btn.css("background", "#808080"); + }); questionnaireSubmitBtns.each((index, btn) => { - const $btn = $(btn); - const span = $btn.find('span'); - if (!span.attr('data-original-label')) { - span.attr('data-original-label', span.text().trim()); - } + const $btn = $(btn); + const span = $btn.find("span"); + span.text(span.attr("data-original-label")); // reset label (no "submit when offline") }); - - const disableForOfflineAccess = () => { - searchFormHolder.hide(); - readContent.removeClass('complete'); - commentForm.hide(); - commentLikeHolders.attr('style', 'display: none !important'); - reportComment.hide(); - commentReplyLinks.hide(); - downloadAppBtns.hide(); - offlineAppBtns.show(); - chatbotBtns.each((index, btn) => { - const $btn = $(btn); - $btn.css('pointer-events', 'none'); - $btn.css('background', '#808080'); - }); - questionnaireSubmitBtns.each((index, btn) => { - const $btn = $(btn); - const span = $btn.find('span'); - span.text(span.attr('data-original-label')); // reset label (no "submit when offline") - }); - progressHolder.hide(); - changeDigitalPinBtn.hide(); - loginCreateAccountBtns.hide(); - logoutBtn.hide(); - externalLinks.each((index, link) => { - const $link = $(link); - if (!$link.data('offline-bound')) { - $link.on('click.offline', e => { - e.preventDefault(); - externalLinkOverlay.css('display', 'block'); - }); - $link.data('offline-bound', true); - } - }); - }; - - const enableForOnlineAccess = () => { - searchFormHolder.show(); - readContent.addClass('complete'); - commentForm.show(); - commentLikeHolders.attr('style', 'display: inline-block !important'); - reportComment.show(); - commentReplyLinks.show(); - downloadAppBtns.show(); - offlineAppBtns.hide(); - chatbotBtns.each((index, btn) => { - const $btn = $(btn); - $btn.css('pointer-events', 'all'); - $btn.css('background', '#F7F7F9'); - }); - questionnaireSubmitBtns.each((index, btn) => { - const $btn = $(btn); - $btn.css('pointer-events', 'all'); - const span = $btn.find('span'); - const original = span.attr('data-original-label') || span.text().trim(); - span.text(original); - }); - progressHolder.show(); - changeDigitalPinBtn.show(); - loginCreateAccountBtns.show(); - logoutBtn.show(); - externalLinks.show(); - externalLinks.each((index, link) => { - $(link).off('click.offline'); + progressHolder.hide(); + changeDigitalPinBtn.hide(); + loginCreateAccountBtns.hide(); + logoutBtn.hide(); + notificationPreferenceButton.hide(); + externalLinks.each((index, link) => { + const $link = $(link); + if (!$link.data("offline-bound")) { + $link.on("click.offline", (e) => { + e.preventDefault(); + externalLinkOverlay.css("display", "block"); }); - }; - - if ('serviceWorker' in navigator) { - navigator.serviceWorker.addEventListener('message', event => { - const data = event.data; - if (!data) return; - - switch (data.type) { - case 'sync-success': - showToast(`✅ Synced back offline filled form.`, "success"); - break; - case 'sync-failed': - showToast(`⚠️ Offline form sync failed for: ${data.url}`, "error"); - break; - case 'sync-error': - showToast(`❌ Sync error: ${data.error}`, "error"); - break; - } - }); - } - - $(window).on('offline', () => { - console.warn("🔌 Offline detected."); - disableForOfflineAccess(); - if (getItem('offlineReady') === true) { - console.log("📦 Page cached. Reloading offline view..."); - setTimeout(() => location.reload(), 500); - } + $link.data("offline-bound", true); + } }); - - $(window).on('online', () => { - enableForOnlineAccess(); + }; + + const enableForOnlineAccess = () => { + searchFormHolder.show(); + readContent.addClass("complete"); + commentForm.show(); + commentLikeHolders.attr("style", "display: inline-block !important"); + reportComment.show(); + commentReplyLinks.show(); + downloadAppBtns.show(); + offlineAppBtns.hide(); + chatbotBtns.each((index, btn) => { + const $btn = $(btn); + $btn.css("pointer-events", "all"); + $btn.css("background", "#F7F7F9"); + }); + questionnaireSubmitBtns.each((index, btn) => { + const $btn = $(btn); + $btn.css("pointer-events", "all"); + const span = $btn.find("span"); + const original = span.attr("data-original-label") || span.text().trim(); + span.text(original); }); + progressHolder.show(); + changeDigitalPinBtn.show(); + loginCreateAccountBtns.show(); + logoutBtn.show(); + notificationPreferenceButton.show(); + externalLinks.show(); + externalLinks.each((index, link) => { + $(link).off("click.offline"); + }); + }; + + if ("serviceWorker" in navigator) { + navigator.serviceWorker.addEventListener("message", (event) => { + const data = event.data; + if (!data) return; + + switch (data.type) { + case "sync-success": + showToast(`✅ Synced back offline filled form.`, "success"); + break; + case "sync-failed": + showToast(`⚠️ Offline form sync failed for: ${data.url}`, "error"); + break; + case "sync-error": + showToast(`❌ Sync error: ${data.error}`, "error"); + break; + } + }); + } + + $(window).on("offline", () => { + console.warn("🔌 Offline detected."); + disableForOfflineAccess(); + if (getItem("offlineReady") === true) { + console.log("📦 Page cached. Reloading offline view..."); + setTimeout(() => location.reload(), 500); + } + }); - window.navigator.onLine ? enableForOnlineAccess() : disableForOfflineAccess(); + $(window).on("online", () => { + enableForOnlineAccess(); + }); - fetch(window.location.href, { method: 'HEAD', cache: 'no-cache' }) + window.navigator.onLine ? enableForOnlineAccess() : disableForOfflineAccess(); + + fetch(window.location.href, { method: "HEAD", cache: "no-cache" }) .then(() => { - console.log("✅ Verified online via HEAD request"); - enableForOnlineAccess(); + console.log("✅ Verified online via HEAD request"); + enableForOnlineAccess(); }) .catch(() => { - console.warn("⚠️ Verified offline via HEAD request"); - disableForOfflineAccess(); + console.warn("⚠️ Verified offline via HEAD request"); + disableForOfflineAccess(); }); - $('.footer-head').hide(); + $(".footer-head").hide(); }); -const download = pageId => { - showToast("📥 Download starting…"); - console.log("Starting download for page:", pageId); - - fetch(`/page-tree/${pageId}/`) - .then(resp => { - if (!resp.ok) { - throw new Error(`Failed to fetch URLs for caching. Status: ${resp.status}`); - } - return resp.json(); - }) - .then(urls => { - if (!Array.isArray(urls) || urls.length === 0) { - throw new Error("No URLs received for caching."); - } - - return caches.open('iogt').then(cache => { - console.log("URLs to cache:", urls); - - return Promise.all(urls.map(url => - fetch(url, { method: 'HEAD' }) - .then(response => { - if (response.ok) { - return cache.add(url).catch(error => { - if (error.name === 'QuotaExceededError') { - alert("⚠️ Your storage limit has been reached! Please free up space."); - throw new Error("Storage full! Cannot cache more content."); - } - throw error; - }); - } else { - console.warn(`Skipping invalid URL: ${url} (Status: ${response.status})`); - } - }) - .catch(err => console.warn(`Skipping ${url} due to error:`, err)) - )); - }); - }) - .then(() => { - setItem('offlineReady', true); // ✅ Set offline-ready flag - console.log("✅ Content cached successfully!"); - showToast("✅ Content is now available offline!", "success"); - location.reload(); // ✅ Reload after caching - }) - .catch(error => { - console.error("❌ Download error:", error); - alert("⚠️ Download failed. Please try again."); - }); +const download = (pageId) => { + showToast("📥 Download starting…"); + console.log("Starting download for page:", pageId); + + fetch(`/page-tree/${pageId}/`) + .then((resp) => { + if (!resp.ok) { + throw new Error( + `Failed to fetch URLs for caching. Status: ${resp.status}` + ); + } + return resp.json(); + }) + .then((urls) => { + if (!Array.isArray(urls) || urls.length === 0) { + throw new Error("No URLs received for caching."); + } + + return caches.open("iogt").then((cache) => { + console.log("URLs to cache:", urls); + + return Promise.all( + urls.map((url) => + fetch(url, { method: "HEAD" }) + .then((response) => { + if (response.ok) { + return cache.add(url).catch((error) => { + if (error.name === "QuotaExceededError") { + alert( + "⚠️ Your storage limit has been reached! Please free up space." + ); + throw new Error( + "Storage full! Cannot cache more content." + ); + } + throw error; + }); + } else { + console.warn( + `Skipping invalid URL: ${url} (Status: ${response.status})` + ); + } + }) + .catch((err) => + console.warn(`Skipping ${url} due to error:`, err) + ) + ) + ); + }); + }) + .then(() => { + setItem("offlineReady", true); // ✅ Set offline-ready flag + console.log("✅ Content cached successfully!"); + showToast("✅ Content is now available offline!", "success"); + location.reload(); // ✅ Reload after caching + }) + .catch((error) => { + console.error("❌ Download error:", error); + alert("⚠️ Download failed. Please try again."); + }); }; -function getItem (key, defaultValue = null) { - try { - return JSON.parse(localStorage.getItem(key)) ?? defaultValue; - } catch { - return defaultValue; - } -}; +function getItem(key, defaultValue = null) { + try { + return JSON.parse(localStorage.getItem(key)) ?? defaultValue; + } catch { + return defaultValue; + } +} const setItem = (key, value) => { - localStorage.setItem(key, JSON.stringify(value)); + localStorage.setItem(key, JSON.stringify(value)); }; -const registerPushNotification = registration => { - if (!registration.showNotification || Notification.permission === 'denied' || !('PushManager' in window)) { - return; - } - subscribe(registration); +const registerPushNotification = (registration) => { + if ( + !registration.showNotification || + !("PushManager" in window) + ) { + return; + } + + // Ask permission first if needed + if (Notification.permission === "default") { + Notification.requestPermission().then(permission => { + if (permission === "granted") { + subscribe(registration); // Now safe to subscribe + } + }); + } else if (Notification.permission === "granted") { + subscribe(registration); // Already granted + } }; -const urlB64ToUint8Array = base64String => { - const padding = '='.repeat((4 - base64String.length % 4) % 4); - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); - const rawData = window.atob(base64); - return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); +const urlB64ToUint8Array = (base64String) => { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; }; -const subscribe = registration => { - registration.pushManager.getSubscription() - .then(subscription => { - if (subscription) { - sendSubscriptionToServer(subscription, 'subscribe'); - return; - } - - const vapidKey = $('meta[name="vapid-key"]').attr('content'); - const options = { - userVisibleOnly: true, - ...(vapidKey && { applicationServerKey: urlB64ToUint8Array(vapidKey) }) - }; - - registration.pushManager.subscribe(options) - .then(subscription => { - sendSubscriptionToServer(subscription, 'subscribe'); - }) - .catch(error => console.log("Error during subscribe()", error)); +const subscribe = (registration) => { + registration.pushManager + .getSubscription() + .then((subscription) => { + if (subscription) { + sendSubscriptionToServer(subscription, "subscribe"); + return; + } + + const vapidKey = $('meta[name="vapid-key"]').attr("content"); + const options = { + userVisibleOnly: true, + ...(vapidKey && { applicationServerKey: urlB64ToUint8Array(vapidKey) }), + }; + + registration.pushManager + .subscribe(options) + .then((subscription) => { + sendSubscriptionToServer(subscription, "subscribe"); }) - .catch(error => console.log("Error during getSubscription()", error)); + .catch((error) => console.log("Error during subscribe()", error)); + }) + .catch((error) => console.log("Error during getSubscription()", error)); }; const sendSubscriptionToServer = (subscription, statusType) => { - const browser = navigator.userAgent.match(/(firefox|msie|chrome|safari|trident)/ig)[0].toLowerCase(); - const data = { - status_type: statusType, - subscription: subscription.toJSON(), - browser: browser, - }; - - fetch('/webpush/subscribe/', { - method: 'POST', - body: JSON.stringify(data), - headers: { - 'content-type': 'application/json' - }, - credentials: "include" - }).then(() => { - setItem('isPushNotificationRegistered', statusType === 'subscribe'); - }); + const browser = navigator.userAgent + .match(/(firefox|msie|chrome|safari|trident)/gi)[0] + .toLowerCase(); + const data = { + status_type: statusType, + subscription: subscription.toJSON(), + browser: browser, + }; + + fetch("/webpush/subscribe/", { + method: "POST", + body: JSON.stringify(data), + headers: { + "content-type": "application/json", + }, + credentials: "include", + }).then(() => { + setItem("isPushNotificationRegistered", statusType === "subscribe"); + console.log(statusType); + }); }; const unSubscribePushNotifications = () => { - const isPushNotificationRegistered = getItem('isPushNotificationRegistered', false); - if (isPushNotificationRegistered && isAuthenticated && 'serviceWorker' in navigator) { - navigator.serviceWorker.ready.then(registration => { - registration.pushManager.getSubscription().then(subscription => { - if (subscription) { - sendSubscriptionToServer(subscription, 'unsubscribe'); - } - }); - }); - } + const isPushNotificationRegistered = getItem( + "isPushNotificationRegistered", + false + ); + if ( + isPushNotificationRegistered && + isAuthenticated && + "serviceWorker" in navigator + ) { + navigator.serviceWorker.ready.then((registration) => { + registration.pushManager.getSubscription().then((subscription) => { + if (subscription) { + sendSubscriptionToServer(subscription, "unsubscribe"); + } + }); + }); + } }; diff --git a/iogt/templates/base.html b/iogt/templates/base.html index bfc887f66..ecfdaa013 100644 --- a/iogt/templates/base.html +++ b/iogt/templates/base.html @@ -5,26 +5,26 @@ - + {% block title %} - {% if page.seo_title %} - {{ page.seo_title }} - {% else %} - {{ page.title }} - {% endif %} + {% if page.seo_title %} + {{ page.seo_title }} + {% else %} + {{ page.title }} + {% endif %} {% endblock %} {% block title_suffix %} - {% with page.get_site.site_name as site_name %} - {% if site_name %}- {{ site_name }}{% endif %} - {% endwith %} + {% with page.get_site.site_name as site_name %} + {% if site_name %}- {{ site_name }}{% endif %} + {% endwith %} {% endblock %} {% image settings.home.SiteSettings.favicon width-60 as favicon_img %} - - - + + + {% social_meta_tags %} @@ -44,15 +44,16 @@ {% block extra_css %} - {# Override this in templates to add extra stylesheets #} + {# Override this in templates to add extra stylesheets #} {% endblock %} {% matomo_tracking_tags %} {% matomo_tag_manager settings.home.SiteSettings.mtm_container_id %} {% get_current_language_bidi as LANGUAGE_BIDI %} + -{% wagtailuserbar %} + {% wagtailuserbar %}
+ {% with page_=page %}
{% include "messages.html" %} @@ -94,59 +95,70 @@
{% include "footer.html" with page=page_ %} - {% endwith %} + {% endwith %} +
+
- - - -{# Global javascript #} - - -{% if jquery %} + {% endif %} + + + {% if jquery %} -{% else %} + {% else %} -{% endif %} - - - -{% block extra_js %}{% endblock %} + {% endif %} + + + + {% block extra_js %}{% endblock %} - + + \ No newline at end of file diff --git a/iogt/templates/header.html b/iogt/templates/header.html index 6b4b5de47..d5956331d 100644 --- a/iogt/templates/header.html +++ b/iogt/templates/header.html @@ -4,7 +4,7 @@ {% get_language_info for LANGUAGE_CODE as lang %} {% get_available_languages as LANGUAGES %} {% get_language_info_list for LANGUAGES as languages %} - +{% load notifications_tags %} + +{% if user.is_authenticated %} + +{% endif %} \ No newline at end of file diff --git a/iogt/templates/sw.js b/iogt/templates/sw.js index 544f68b57..e9eb3cfd4 100644 --- a/iogt/templates/sw.js +++ b/iogt/templates/sw.js @@ -1,5 +1,5 @@ -importScripts('../../static/js/workbox/workbox-v6.1.5/workbox-sw.js'); -importScripts('../../static/js/idb.js'); // Import IndexedDB helper +importScripts("../../static/js/workbox/workbox-v6.1.5/workbox-sw.js"); +importScripts("../../static/js/idb.js"); // Import IndexedDB helper const PRECACHE_ASSETS = [ '/', // your home page '/static/js/iogt.js', @@ -29,33 +29,106 @@ self.addEventListener('install', event => { ); }); +self.addEventListener("push", function (event) { + console.log("📩 Push received", event); + + let data = {}; + + try { + if (event.data && event.data.json) { + // Try to parse JSON + data = event.data.json(); + } else if (event.data && event.data.text) { + // Fallback to text and wrap in object + const text = event.data.text(); + data = { body: text }; + } + } catch (e) { + console.warn("❌ Failed to parse push data", e); + data = { body: "You have a new message." }; + } + + const title = data.title || "New Notification"; + const options = { + body: data.body || "You have a new message.", + icon: + data.icon || "https://cdn-icons-png.flaticon.com/512/3119/3119338.png", + data: { + url: data.url || "/", + notification_id: data.notification_id || null, + }, + requireInteraction: true, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + // ✅ Activate Service Worker -self.addEventListener('activate', event => { - console.log("🚀 Service Worker Activated!"); - event.waitUntil(self.clients.claim()); +self.addEventListener("activate", (event) => { + console.log("🚀 Service Worker Activated!"); + event.waitUntil(self.clients.claim()); }); -// ✅ Handle Fetch Requests -self.addEventListener('fetch', event => { - const { request } = event; +self.addEventListener("notificationclick", function (event) { + console.log("🔔 Notification clicked:", event); - console.log("🔎 Fetch event triggered:", request.url, request.method); + // Optional: close the notification + event.notification.close(); - // ✅ Handle POST Requests (Save to IndexedDB if offline) - if (request.method === 'POST') { - event.respondWith( - fetch(request.clone()).catch(async () => { - console.warn("⚠️ Offline - saving request locally", request.url); + const notificationData = event.notification.data || {}; + const targetUrl = notificationData.url || "/"; - try { - await saveRequest(request); - console.log("💾 Request saved successfully:", request.url); + // Track click via fetch to server + if (notificationData.notification_id) { + fetch(`/notifications/mark-clicked/${notificationData.notification_id}/`, { + method: "POST", + "X-CSRFToken": "{{ csrf_token }}", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }).catch((err) => + console.warn("❌ Failed to log notification click:", err) + ); + } - if ('sync' in self.registration) { - self.registration.sync.register('sync-forms') - .then(() => console.log("🔄 Sync registered successfully!")) - .catch(err => console.error("❌ Sync registration failed:", err)); - } + // Focus tab or open new one + event.waitUntil( + clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((clientList) => { + for (let client of clientList) { + if (client.url === targetUrl && "focus" in client) { + return client.focus(); + } + } + return clients.openWindow(targetUrl); + }) + ); +}); + +// ✅ Handle Fetch Requests +self.addEventListener("fetch", (event) => { + const { request } = event; + + // ✅ Handle POST Requests (Save to IndexedDB if offline) + if (request.method === "POST") { + event.respondWith( + fetch(request.clone()).catch(async () => { + console.warn("⚠️ Offline - saving request locally", request.url); + + try { + await saveRequest(request); + console.log("💾 Request saved successfully:", request.url); + + if ("sync" in self.registration) { + self.registration.sync + .register("sync-forms") + .then(() => console.log("🔄 Sync registered successfully!")) + .catch((err) => + console.error("❌ Sync registration failed:", err) + ); + } // ✅ Dynamically use referrer or fallback to home page const redirectUrl = request.referrer || '/'; @@ -142,20 +215,20 @@ self.addEventListener('fetch', event => { }); // ✅ Background Sync for Form Submissions -self.addEventListener('sync', event => { - if (event.tag === 'sync-forms') { - console.log("🔄 Sync event triggered!"); - event.waitUntil(syncRequests()); - } +self.addEventListener("sync", (event) => { + if (event.tag === "sync-forms") { + console.log("🔄 Sync event triggered!"); + event.waitUntil(syncRequests()); + } }); // ✅ Function to Sync Requests from IndexedDB async function syncRequests() { - console.log("🚀 Syncing stored requests..."); + console.log("🚀 Syncing stored requests..."); - const requests = await getAllRequests(); - for (const req of requests) { - console.log("📤 Syncing request:", req); + const requests = await getAllRequests(); + for (const req of requests) { + console.log("📤 Syncing request:", req); const fetchOptions = { method: req.method, @@ -164,29 +237,39 @@ async function syncRequests() { credentials: 'include' // Important for authentication }; - try { - const response = await fetch(req.url, fetchOptions); - - if (response.ok) { - console.log("✅ Sync successful, deleting request from IndexedDB..."); - await deleteRequest(req.id); - // ✅ Notify client about successful sync - sendMessageToClients({ type: 'sync-success', url: req.url }); - } else { - console.warn("⚠️ Sync failed with status:", response.status); - sendMessageToClients({ type: 'sync-failed', url: req.url, status: response.status }); - } - } catch (err) { - console.error("❌ Sync error:", err); - sendMessageToClients({ type: 'sync-error', url: req.url, error: err.message }); - } + try { + const response = await fetch(req.url, fetchOptions); + + if (response.ok) { + console.log("✅ Sync successful, deleting request from IndexedDB..."); + await deleteRequest(req.id); + // ✅ Notify client about successful sync + sendMessageToClients({ type: "sync-success", url: req.url }); + } else { + console.warn("⚠️ Sync failed with status:", response.status); + sendMessageToClients({ + type: "sync-failed", + url: req.url, + status: response.status, + }); + } + } catch (err) { + console.error("❌ Sync error:", err); + sendMessageToClients({ + type: "sync-error", + url: req.url, + error: err.message, + }); } + } } function sendMessageToClients(message) { - self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then(clients => { - clients.forEach(client => { - client.postMessage(message); - }); + self.clients + .matchAll({ includeUncontrolled: true, type: "window" }) + .then((clients) => { + clients.forEach((client) => { + client.postMessage(message); + }); }); -} \ No newline at end of file +} diff --git a/iogt/urls.py b/iogt/urls.py index 3abc9662e..c21845f49 100644 --- a/iogt/urls.py +++ b/iogt/urls.py @@ -18,8 +18,6 @@ from wagtail_transfer import urls as wagtailtransfer_urls from admin_login import urls as admin_login_urls from admin_login.views import AzureADSignupView - - from iogt.views import ( TransitionPageView, SitemapAPIView, @@ -33,6 +31,7 @@ api_url_patterns = [ path('api/v1/questionnaires/', include('questionnaires.api.v1.urls')), path('api/interactive/', include('interactive.api.urls')), + # path("notifications/save-preference/", save_notification_preference, name="save_notification_preference"), ] @@ -59,8 +58,6 @@ *i18n_patterns(path('users/', include(users_urls), name='users_urls')), *i18n_patterns(path('accounts/', include('allauth.urls'), name='allauth-urls')), *i18n_patterns(path('comments/', include('django_comments_xtd.urls'))), - #*i18n_patterns(path('admin-login/', include('admin_login.urls'))), - path( 'sw.js', pwa_views.ServiceWorkerView.as_view(), @@ -81,6 +78,8 @@ path('page-tree//', PageTreeAPIView.as_view(), name='page_tree'), path('api/docs/', schema_view.with_ui('swagger'), name='swagger'), path('webpush/subscribe/', save_info, name='save_webpush_info'), + path('inbox/notifications/', include('notifications.urls', namespace='notifications')), + path('notifications/', include('user_notifications.urls')), ] if settings.DEBUG: diff --git a/iogt/utils.py b/iogt/utils.py index 7c4fd9132..dc2eed7f4 100644 --- a/iogt/utils.py +++ b/iogt/utils.py @@ -1,5 +1,92 @@ +from django.contrib.admin.views.decorators import staff_member_required from django.conf import settings +from django.shortcuts import get_object_or_404, redirect +from wagtail.admin.action_menu import ActionMenuItem +from django.urls import path, reverse +from django.utils.html import format_html_join, format_html +from questionnaires.models import Survey +from home.models import Article +from wagtail.models import Site, Page +from wagtail.admin import messages +from user_notifications.tasks import send_app_notifications +from wagtail.contrib.modeladmin.helpers import AdminURLHelper def has_md5_hash(name): return bool(settings.HAS_MD5_HASH_REGEX.search(name)) + + +class NotifyAndPublishMenuItem(ActionMenuItem): + label = "Notify & Publish" + name = "notify_and_publish" + + def __init__(self, order=100, allowed_models=None): + super().__init__(order=order) + if allowed_models is None: + self.allowed_models = tuple() + else: + # Handle single class passed directly + self.allowed_models = (allowed_models,) + + def is_shown(self, context): # ✅ Correct for Wagtail 3.2 + page = context.get("page") + return isinstance(getattr(page, "specific", page), self.allowed_models) + + def render_html(self, context): + page = context["page"] + url = reverse("notify_and_publish", args=[page.id]) + return format_html( + '' + ' {}' + '', + url, + self.label, + ) + + +# shared view +@staff_member_required +def notify_and_publish_view(request, page_id): + page = Page.objects.get(id=page_id).specific + latest_revision = page.get_latest_revision_as_page() + + if latest_revision: + # This gives you the latest draft version as a page instance + revision = latest_revision.save_revision(user=request.user) + revision.publish() + else: + revision = page.save_revision(user=request.user) + revision.publish() + if isinstance(page, Survey): + full_url = get_site_for_locale(page) + send_app_notifications.delay(page.id, full_url, 'survey') + messages.success(request, f"Survey '{page.title}' published and notified.") + + elif isinstance(page, Article): + full_url = get_site_for_locale(page) + send_app_notifications.delay(page.id, full_url, 'article') + messages.success(request, f"Article '{page.title}' published and notified.") + + else: + messages.error(request, "Not a valid Survey or Article page.") + + modeladmin_url = AdminURLHelper(type(page)) + return redirect(modeladmin_url.index_url) + + +def get_site_for_locale(instance): + """ + Return the Wagtail Site object matching the given locale. + """ + for site in Site.objects.all(): + if site.root_page.locale.language_code == instance.locale.language_code: + if not site: + print("No matching site for locale:", instance.locale) + return + relative = instance.relative_url(site) + if not relative: + print("Could not get relative URL for instance.") + return + full_url = settings.WAGTAILADMIN_BASE_URL + relative + return full_url + return None \ No newline at end of file diff --git a/iogt_users/forms.py b/iogt_users/forms.py index d17ce539d..472bf4193 100644 --- a/iogt_users/forms.py +++ b/iogt_users/forms.py @@ -6,10 +6,14 @@ from django.utils.translation import gettext_lazy as _ from wagtail.users.forms import UserEditForm as WagtailUserEditForm, \ UserCreationForm as WagtailUserCreationForm +from user_notifications.models import UserNotificationTemplate +from user_notifications.tasks import send_app_notifications from .fields import IogtPasswordField from .models import User +from notifications.signals import notify + class AccountSignupForm(SignupForm): display_name = forms.CharField( @@ -44,6 +48,14 @@ def __init__(self, *args, **kwargs): if hasattr(self, "field_order"): set_form_field_order(self, self.field_order) + def save(self, request): + user = super().save(request) + # 🔁 Run this logic in background + print('user', user) + print("Sending task to Celery...") + send_app_notifications.delay(user.id, notification_type='signup') + return user + def clean_username(self): username = self.cleaned_data.get('username') if User.objects.filter(username__iexact=username): diff --git a/iogt_users/templates/profile.html b/iogt_users/templates/profile.html index 21419b40f..38d42e375 100644 --- a/iogt_users/templates/profile.html +++ b/iogt_users/templates/profile.html @@ -17,12 +17,15 @@

{% endblocktranslate %} {{ user.username | first | upper }}

- {% endif %} + {% endif %} + {# Show notification banner only if preference is not yet set #}
+ {% url 'notification_settings' as notify_url %} + {% primary_button title='Notification Preference' href=notify_url icon_path='icons/bell-solid.svg' extra_classnames='notification-pref-btn' %} {% url 'account_change_password' as change_password_url %} {% primary_button title='Change Digital Pin' href=change_password_url icon_path='icons/lock.svg' extra_classnames='change-digital-pin' %} - {% url 'account_logout' as logout_url %} {% primary_button title='Log out' href=logout_url icon_path='icons/arrow_icon_left.svg' extra_classnames='logout-btn' %}
-{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/iogt_users/templates/user_notification.html b/iogt_users/templates/user_notification.html new file mode 100644 index 000000000..d39dc9fdc --- /dev/null +++ b/iogt_users/templates/user_notification.html @@ -0,0 +1,204 @@ +{% extends 'account/base.html' %} +{% load i18n static image_tags generic_components %} + +{% block title %}{% translate "Notification Preference" %}{% endblock %} + + +{% block content %} +{% if request.user.is_authenticated %} +

{% translate "Notification Preference" %}

+
+ {% translate "Do you want to receive notifications?" %} +
+
+
+
+
+ +
+ +
+ + +
+ + +
+
+ +
+ +{% endif %} + + + +{% endblock %} \ No newline at end of file diff --git a/iogt_users/urls.py b/iogt_users/urls.py index 2b0fe187a..4874d5d03 100644 --- a/iogt_users/urls.py +++ b/iogt_users/urls.py @@ -1,8 +1,12 @@ from django.urls import path -from iogt_users.views import UserDetailEditView, UserDetailView, InviteAdminUserView +from iogt_users.views import UserDetailEditView, UserDetailView, InviteAdminUserView, UserNotificationView +from user_notifications.views import save_notification_preference urlpatterns = [ path('invite-admin-user/', InviteAdminUserView.as_view(), name='invite_admin_user'), path('profile/', UserDetailView.as_view(), name='user_profile'), path('profile/edit', UserDetailEditView.as_view(), name='user_profile_edit'), + path('profile/edit', UserDetailEditView.as_view(), name='user_profile_edit'), + path('notifications/settings/', UserNotificationView.as_view(), name='notification_settings'), + path("notifications/save-preference/", save_notification_preference, name="save_notification_preference"), ] \ No newline at end of file diff --git a/iogt_users/views.py b/iogt_users/views.py index c9b5df5a3..e90cd5f66 100644 --- a/iogt_users/views.py +++ b/iogt_users/views.py @@ -10,10 +10,34 @@ from django.views import View from django.contrib.auth import get_user_model from django.http import JsonResponse +from wagtail.models import Locale from iogt import settings from email_service.mailjet_email_sender import send_email_via_mailjet +from user_notifications.models import NotificationPreference, NotificationTag + + +class UserNotificationView(TemplateView): + template_name = 'user_notification.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.user.is_authenticated: + notification_pref = NotificationPreference.objects.filter(user=self.request.user).first() + context['notification_preference'] = notification_pref + context['selected_tag_ids'] = list( + notification_pref.content_tags.values_list('id', flat=True) + ) if notification_pref else [] + context['selected_language_code'] = ( + notification_pref.preferred_language + if notification_pref and notification_pref.preferred_language else 'en') + else: + context['notification_preference'] = None + context['notification_tags'] = NotificationTag.objects.all() + context['available_languages'] = Locale.objects.all() + context['user'] = self.request.user + return context @method_decorator(login_required, name='dispatch') diff --git a/locale/ha/LC_MESSAGES/django.po b/locale/ha/LC_MESSAGES/django.po new file mode 100644 index 000000000..4f4bead08 --- /dev/null +++ b/locale/ha/LC_MESSAGES/django.po @@ -0,0 +1,376 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-18 10:35+0000\n" +"PO-Revision-Date: 2025-08-18 10:35+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "quizzes" +msgstr "gasa" + +msgid "Log in to participate" +msgstr "Shiga don ka halarta" + +msgid "Log in / Create account" +msgstr "Shiga / Ƙirƙiri asusu" + +msgid "© The Internet of Good Things" +msgstr "© Na'urorinIntanet" + +msgid "Profile" +msgstr "Furofayil" + +msgid "Chat" +msgstr "Hira" + +msgid "More" +msgstr "Ƙari" + +msgid "Log in" +msgstr "Shiga" + +msgid "Back" +msgstr "Koma baya" + +msgid "BACK" +msgstr "BAYA" + +msgid "Previous" +msgstr "Na baya" + +msgid "Next" +msgstr "Na gaba" + +msgid "Comments" +msgstr "Sharhi" + +msgid "Please log in or create your account" +msgstr "shiga ko ka ƙirƙiri asusunka" + +msgid "Report" +msgstr "Kai ƙara" + +msgid "Related Articles" +msgstr "Maƙaloli masu alaƙa" + +msgid "This comment has been reported." +msgstr "An kai ƙarar wannan sharhin." + +msgid "" +"It will be reviewed by the team and may be removed if it breaks our Platform" +" Rules" +msgstr "Ma'aikata za su duba, kuma ana iya cire shi idan ya karya Dokokin Dandalinmu" + +msgid "articles" +msgstr "maƙalu" + +msgid "Search the site..." +msgstr "Bincika shafin…" + +msgid "Search results for:" +msgstr "Sakamakon bincike:" + +msgid "No search results were found for" +msgstr "Ba a sami wani sakamakon bincike na...." + +msgid "Username" +msgstr "Sunan mai amfani" + +msgid "Create your account" +msgstr "Ƙirƙiri asusunka" + +msgid "Forgot PIN" +msgstr "Ka manta PIN" + +msgid "Cancel" +msgstr "Fasa" + +msgid "Hey, %(username)s" +msgstr "kai, kaso cikin ɗari na (masu amfani)" + +msgid "Change Digital Pin" +msgstr "Sauya lambobin PIN" + +msgid "Personal Details" +msgstr "Bayanai na ƙashin kai" + +msgid "Save changes" +msgstr "Adana Sauye-sauye" + +msgid "Reset PIN" +msgstr "Sake saita PIN" + +msgid "Your PIN has been changed successfully." +msgstr "An yi nasarar sauya lambar PIN ɗinka." + +msgid "I accept the Terms and Conditions." +msgstr "Na yarda da Sharuɗɗa da Ka’idoji." + +msgid "You have read %(read)s out of %(total)s" +msgstr "Ka karanta kaso (karatu)s daga cikin kaso (jimilla)s" + +msgid "%(counter)s of %(total)s questions" +msgstr "kaso (adadi)s daga cikin kaso (jimilla)s cikin ɗari na tambayoyi" + +msgid "Select one" +msgstr "Zaɓi ɗaya" + +msgid "Optional" +msgstr "Na zaɓi" + +msgid "No limit" +msgstr "Babu iyaka" + +msgid "Submit" +msgstr "Miƙa" + +msgid "Go to homepage" +msgstr "Je zuwa shafin farko" + +msgid "You have already completed this survey." +msgstr "Ka riga ka kammala wannan binciken." + +msgid "Check all that apply" +msgstr "Duba duk waɗanda suka dace" + +msgid "Your results!" +msgstr "Sakamakonka!" + +msgid "Replay Quiz" +msgstr "A sake maimaita gasa" + +msgid "Incorrect" +msgstr "Ba daidai ba" + +msgid "Correct" +msgstr "Daidai" + +msgid "Your answer" +msgstr "Amsarka" + +msgid "Leave comment" +msgstr "Bar sharhi" + +msgid "Reply" +msgstr "Amsa" + +msgid "Page not found" +msgstr "Ba a sami shafin ba" + +msgid "Sorry, this page could not be found." +msgstr "Yi hakuri, ba a iya samun wannan shafi ba." + +msgid "Internal server error" +msgstr "Kuskuren rumbun intanet" + +msgid "1 result" +msgid_plural "%(counter)s results" +msgstr[0] "Sakamako 1" +msgstr[1] "kaso(adadi)s cikin ɗari na sakamako" + +msgid "poll" +msgstr "zaɓe" + +msgid "survey" +msgstr "bincike" + +msgid "quiz" +msgstr "Gasa" + +msgid "This field is required." +msgstr "Ana buƙatar cike wannan wurin." + +msgid "Send" +msgstr "Aika" + +msgid "Remove this comment?" +msgstr "Cire wannan sharhi?" + +msgid "Remove" +msgstr "Cire" + +msgid "This comment has been removed." +msgstr "An cire wannan sharhi." + +msgid "moderator" +msgstr "mai kula" + +msgid "sections" +msgstr "sassa" + +msgid "Check if applies" +msgstr "A zaɓa idan ya dace" + +msgid "New comments have been disabled for this page." +msgstr "An kashe yin sabon sharhi a wannan shafin." + +msgid "Correct answer" +msgstr "Amsa daidai" + +msgid "Download" +msgstr "Sauke" + +msgid "" +"You are leaving the Internet of Good Things to visit an external " +"website and standard data charges by your network provider might apply" +msgstr "Kana fita daga na'urorin intanet za ka kai ziyara shafin waje kuma ta yiwu a sanya cajin data na mai samar maka da sabis" + +msgid "Continue to external site" +msgstr "Ci gaba zuwa shafi na waje" + +msgid "4-digit PIN" +msgstr "Lambar sirri ta PIN mai haruffa 4" + +msgid "Old 4-digit PIN" +msgstr "Tsohuwar lambar sirri ta PIN mai haruffa 4" + +msgid "New 4-digit PIN" +msgstr "Sabuwar lambar sirri ta PIN mai haruffa 4" + +msgid "Confirm new 4-digit PIN" +msgstr "Tabbatar da sabuwar lambar sirri ta PIN mai haruffa 4" + +msgid "Log out" +msgstr "Fita" + +msgid "section" +msgstr "sashe" + +msgid "article" +msgstr "maƙala" + +msgid "Remember Me" +msgstr "A tuna ni" + +msgid "You have signed out." +msgstr "Ka fita." + +msgid "" +"Enter a valid username. This value may contain only letters, numbers, and " +"@/./+/-/_ characters." +msgstr "Sanya ingantaccen sunan mai amfani. Wannan zai iya ƙunsar haruffa kawai da lambobi da alamomin @/./+/-_." + +msgid "You must type the same password each time." +msgstr "Dole a koyaushe ka rubuta lambar sirri iri ɗaya" + +msgid "This password is too short. It must contain at least 4 characters." +msgstr "Wannan lambar sirrin ta yi ƙanƙanta. Dole ta kai a ƙalla rubutu guda 4." + +msgid "Please type your current password." +msgstr "rubuta lambar sirrinka na yanzu." + +msgid "Password successfully changed." +msgstr "Anyi nasarar canza lambar sirri." + +msgid "Enter a valid date/time." +msgstr "Shigar da sahihin kwanan wata/lokaci." + +msgid "Enter a valid date." +msgstr "Shigar da sahihin kwanan wata." + +msgid "Enter a valid URL." +msgstr "Shigar da sahihin adireshin yanar gizo." + +msgid "Enter a valid email address." +msgstr "Shigar da sahihin adireshin imel." + +msgid "Enter a valid integer." +msgstr "Shigar da lamba mai inganci." + +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "A tabbata wannan adadi ya haura ko ya yi daidai da kaso (iyakar adadi) cikin ɗari" + +msgid "Your comment" +msgstr "Sharhinka" + +msgid "Are you sure you want to log out?" +msgstr "Ka tabbata kana son fita?" + +msgid "Your browser does not support video playback." +msgstr "Burauzarka ba ta goyon bayan kunna hoton bidiyo." + +msgid "Your browser does not support audio playback." +msgstr "Burauzarka ba ta goyon bayan kunna sauti." + +msgid "Don't display my username next to my comment" +msgstr "Kada a nuna sunan da nake amfani da shi kusa da sharhina" + +msgid "Anonymous" +msgstr "A sakaya suna" + +msgid "Report this comment?" +msgstr "A kai ƙarar wannan sharhi?" + +msgid "Display name" +msgstr "Nuna suna" + +msgid "Reported by %(comment_report_count)s user" +msgid_plural "Reported by %(comment_report_count)s users" +msgstr[0] "Ƙara daga mai amfani kaso (adadin_ƙarar jawabi) cikin ɗari" +msgstr[1] "Ƙara daga masu amfani kaso (adadin_ƙarar jawabi) cikin ɗari" + +msgid "" +"Sorry, %(page_title)s is not available in %(language)s. If you want to keep " +"browsing in %(language)s you can click below to return to the homepage." +msgstr "A yi haƙuri babu (sunan shafi) a (harshe). Idan kana son ka ci gaba da shiga intanet da (harshe) za ka iya danna ƙasa don ka koma shafin farko." + +msgid "Username not available." +msgstr "Babu sunan mai amfani." + +msgid "Display name not available." +msgstr "Babu nuna suna." + +msgid "You have already completed this quiz." +msgstr "Ka riga ka kammala wannan gasar." + +msgid "You have already completed this poll." +msgstr "Ka riga ka kammala wannan zaɓe." + +msgid "" +"If you cannot view the above video, you can instead %(start_link)sdownload " +"it%(end_link)s." +msgstr "Idan ba za ka iya kallon bidiyon da ke sama ba, a maimakon haka za ka iya sauke kaso (Mahaɗin_Farko) kaso (mahaɗin_ƙarshe)." + +msgid "" +"If you cannot listen to the above audio, you can instead " +"%(start_link)sdownload it%(end_link)s." +msgstr "Idan ba za ka iya sauraron sautin da ke sama ba, a maimakon haka za ka iya sauke kaso (Mahaɗin_Farko) kaso (mahaɗin_ƙarshe)." + +msgid "Please complete the questions marked as required to continue" +msgstr "Cike amsoshin da aka yi wa alamar wajibi don ci gaba" + +msgid "Need internet access to visit external site." +msgstr "Kana buƙatar intanet kafin ka ziyarci shafin waje" + +msgid "Comment Moderation" +msgstr "Kula da Sharhi" + +msgid "Filters" +msgstr "Matatai" + +msgid "Apply" +msgstr "Shigar" + +msgid "Actions" +msgstr "Ayyuka" + +msgid "Unsure" +msgstr "Ba tabbas ba" + +msgid "No comment found." +msgstr "Ba a sami sharhi ba." + +msgid "" +"The content you are trying to reach is not available. To see offline content " +"click here" +msgstr "" +"Babu bayanin da kake ƙoƙarin samu. Idan kana son ganin bayanin da ba ya kan intanet " +"danna here" diff --git a/locale/ha/LC_MESSAGES/djangojs.po b/locale/ha/LC_MESSAGES/djangojs.po new file mode 100644 index 000000000..6d3dfa400 --- /dev/null +++ b/locale/ha/LC_MESSAGES/djangojs.po @@ -0,0 +1,27 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-18 10:35+0000\n" +"PO-Revision-Date: 2025-08-18 10:35+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "Sorry, there seems to be an error. Please try again soon." +msgstr "A yi haƙuri, da alama an sami matsala. A sake gwadawa nan ba da daɗewa ba." +"Désolé, il semble qu'il y ait une erreur. Veuillez réessayer sous peu." + +msgid "You cannot submit when offline" +msgstr "Ba za ka iya turawa ba idan ba a kan Intanet ba" + +msgid "Date must be in this (YYYY-MM-DD) format" +msgstr "Dole kwanan wata ya zama cikin wannan tsarin (SSSS-WW-RR)" + +msgid "Datetime must be in this YYYY-MM-DDTHH:SS format" +msgstr "Dole kwanan wata da lokaci su zama cikin wannan tsarin SSSS-WW-RRLAA-SS" diff --git a/locale/ig/LC_MESSAGES/django.po b/locale/ig/LC_MESSAGES/django.po new file mode 100644 index 000000000..f8508242b --- /dev/null +++ b/locale/ig/LC_MESSAGES/django.po @@ -0,0 +1,374 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-18 10:35+0000\n" +"PO-Revision-Date: 2025-08-18 10:35+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "quizzes" +msgstr "ajụjụ ọnụ nta" + +msgid "Log in to participate" +msgstr "Banye ka ị sonyere" + +msgid "Log in / Create account" +msgstr "Banye / Mepụta akaụntụ" + +msgid "© The Internet of Good Things" +msgstr "© Ịntanet nke Ihe Ọma" + +msgid "Profile" +msgstr "Profaịlụ" + +msgid "Chat" +msgstr "Mkparịta ụka" + +msgid "More" +msgstr "Ọzọ" + +msgid "Log in" +msgstr "Banye" + +msgid "Back" +msgstr "Laghachi" + +msgid "BACK" +msgstr "LAGHACHI" + +msgid "Previous" +msgstr "Gara aga" + +msgid "Next" +msgstr "Na-abịa" + +msgid "Comments" +msgstr "Okwu" + +msgid "Please log in or create your account" +msgstr "Biko banye ma ọ bụ mepụta akaụntụ gị" + +msgid "Report" +msgstr "Kọ akụkọ" + +msgid "Related Articles" +msgstr "Isiokwu metụtara ya" + +msgid "This comment has been reported." +msgstr "Egoziwo akụkọ gbasara okwu a." + +msgid "" +"It will be reviewed by the team and may be removed if it breaks our Platform" +" Rules" +msgstr "Otu ga-enyocha ya ma wepu ya ma ọ bụrụ na ọ mebiri Iwu Ụlọọrụ anyị" + +msgid "articles" +msgstr "isiokwu" + +msgid "Search the site..." +msgstr "Chọọ na saịtị…" + +msgid "Search results for:" +msgstr "Nsonaazụ ọchụchọ maka:" + +msgid "No search results were found for" +msgstr "Enweghị nsonaazụ ọchụchọ achọtara maka" + +msgid "Username" +msgstr "Aha njirimara" + +msgid "Create your account" +msgstr "Mepụta akaụntụ gị" + +msgid "Forgot PIN" +msgstr "Chefuru PIN" + +msgid "Cancel" +msgstr "Kagbuo" + +msgid "Hey, %(username)s" +msgstr "Ndewo, %(username)s" + +msgid "Change Digital Pin" +msgstr "Gbanwee PIN dijitalụ" + +msgid "Personal Details" +msgstr "Nkọwa onwe" + +msgid "Save changes" +msgstr "Chekwaa mgbanwe" + +msgid "Reset PIN" +msgstr "Tọgharịa PIN" + +msgid "Your PIN has been changed successfully." +msgstr "Agbanweela PIN gị nke ọma." + +msgid "I accept the Terms and Conditions." +msgstr "Anaghị m ekweta na Okwu na Ụkpụrụ." + +msgid "You have read %(read)s out of %(total)s" +msgstr "Ị gụrụ %(read)s n’ime %(total)s" + +msgid "%(counter)s of %(total)s questions" +msgstr "Ajụjụ %(counter)s n’ime %(total)s" + +msgid "Select one" +msgstr "Họrọ otu" + +msgid "Optional" +msgstr "Nhọrọ" + +msgid "No limit" +msgstr "Enweghị oke" + +msgid "Submit" +msgstr "Zipụ" + +msgid "Go to homepage" +msgstr "Gaa na ibe isi" + +msgid "You have already completed this survey." +msgstr "Ị emelarị nchọpụta a." + +msgid "Check all that apply" +msgstr "Lelee ihe niile kwekọrọ" + +msgid "Your results!" +msgstr "Nsonaazụ gị!" + +msgid "Replay Quiz" +msgstr "Mee ajụjụ ọzọ" + +msgid "Incorrect" +msgstr "Ezighi ezi" + +msgid "Correct" +msgstr "Ziri ezi" + +msgid "Your answer" +msgstr "Azịza gị" + +msgid "Leave comment" +msgstr "Hapụ okwu" + +msgid "Reply" +msgstr "Zaghachi" + +msgid "Page not found" +msgstr "Echefuru ibe ahụ" + +msgid "Sorry, this page could not be found." +msgstr "Ndo, enweghi ike ịchọta ibe a." + +msgid "Internal server error" +msgstr "Njehie sava ime" + +msgid "1 result" +msgid_plural "%(counter)s results" +msgstr[0] "Nsonaazụ 1" +msgstr[1] "Nsonaazụ %(counter)s" + +msgid "poll" +msgstr "votu" + +msgid "survey" +msgstr "nchọpụta" + +msgid "quiz" +msgstr "ajụjụ" + +msgid "This field is required." +msgstr "A chọrọ ubi a." + +msgid "Send" +msgstr "Zipụ" + +msgid "Remove this comment?" +msgstr "Wepu okwu a?" + +msgid "Remove" +msgstr "Wepu" + +msgid "This comment has been removed." +msgstr "E wepụrụ okwu a." + +msgid "moderator" +msgstr "onye nlekọta" + +msgid "sections" +msgstr "ngalaba" + +msgid "Check if applies" +msgstr "Lelee ma ọ dị mkpa" + +msgid "New comments have been disabled for this page." +msgstr "E kwụsịrị ikwu okwu ọhụrụ n’ibe a." + +msgid "Correct answer" +msgstr "Azịza ziri ezi" + +msgid "Download" +msgstr "Budata" + +msgid "" +"You are leaving the Internet of Good Things to visit an external " +"website and standard data charges by your network provider might apply" +msgstr "Ị na-apụ na Internet of Good Things iji gaa na weebụsaịtị ọzọ, ụgwọ data nwere ike ịbanye" + +msgid "Continue to external site" +msgstr "Gaa n'ihu na weebụsaịtị ọzọ" + +msgid "4-digit PIN" +msgstr "PIN mkpụrụ akwụkwọ 4" + +msgid "Old 4-digit PIN" +msgstr "PIN mkpụrụ akwụkwọ 4 ochie" + +msgid "New 4-digit PIN" +msgstr "PIN mkpụrụ akwụkwọ 4 ọhụrụ" + +msgid "Confirm new 4-digit PIN" +msgstr "Kwenye PIN mkpụrụ akwụkwọ 4 ọhụrụ" + +msgid "Log out" +msgstr "Pụọ" + +msgid "section" +msgstr "ngalaba" + +msgid "article" +msgstr "edemede" + +msgid "Remember Me" +msgstr "Cheta m" + +msgid "You have signed out." +msgstr "Ị pụọla." + +msgid "" +"Enter a valid username. This value may contain only letters, numbers, and " +"@/./+/-/_ characters." +msgstr "Tinye aha onye ọrụ ziri ezi. Nke a nwere ike inwe mkpụrụedemede, nọmba, na akara @/./+/-/_ naanị." + +msgid "You must type the same password each time." +msgstr "Ị ga-ede otu paswọọdụ ahụ mgbe niile." + +msgid "This password is too short. It must contain at least 4 characters." +msgstr "Paswọọdụ a dị mkpụmkpụ. Ọ ga-enwekarị mkpụrụedemede 4 ma ọ dịkarịa ala." + +msgid "Please type your current password." +msgstr "Biko dee paswọọdụ gị ugbu a." + +msgid "Password successfully changed." +msgstr "E gbanwere paswọọdụ nke ọma." + +msgid "Enter a valid date/time." +msgstr "Tinye ụbọchị/oge ziri ezi." + +msgid "Enter a valid date." +msgstr "Tinye ụbọchị ziri ezi." + +msgid "Enter a valid URL." +msgstr "Tinye URL ziri ezi." + +msgid "Enter a valid email address." +msgstr "Tinye adreesị email ziri ezi." + +msgid "Enter a valid integer." +msgstr "Tinye nọmba zuru oke ziri ezi." + +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Jide n'aka na uru a ka ukwuu ma ọ bụ hà na %(limit_value)s." + +msgid "Your comment" +msgstr "Okwu gị" + +msgid "Are you sure you want to log out?" +msgstr "Ị kwetara na ị chọrọ ịpụ?" + +msgid "Your browser does not support video playback." +msgstr "Browser gị anaghị akwado igwu vidio." + +msgid "Your browser does not support audio playback." +msgstr "Browser gị anaghị akwado igwu ụda." + +msgid "Don't display my username next to my comment" +msgstr "Egbula aha m n'akụkụ okwu m" + +msgid "Anonymous" +msgstr "Onye amaghị aha" + +msgid "Report this comment?" +msgstr "Kpọọ akụkọ okwu a?" + +msgid "Display name" +msgstr "Gosipụtara aha" + +msgid "Reported by %(comment_report_count)s user" +msgid_plural "Reported by %(comment_report_count)s users" +msgstr[0] "E kọrọ site n'aka onye ọrụ %(comment_report_count)s" +msgstr[1] "E kọrọ site n'aka ndị ọrụ %(comment_report_count)s" + +msgid "" +"Sorry, %(page_title)s is not available in %(language)s. If you want to keep " +"browsing in %(language)s you can click below to return to the homepage." +msgstr "Ndo, %(page_title)s adịghị na %(language)s. Ọ bụrụ na ịchọrọ ịga n'ihu na nchọgharị na %(language)s, pịa n'okpuru ka laghachi n'ibe isi." + +msgid "Username not available." +msgstr "Aha onye ọrụ adịghị." + +msgid "Display name not available." +msgstr "Aha ngosi adịghị." + +msgid "You have already completed this quiz." +msgstr "Ị emelarị ajụjụ a." + +msgid "You have already completed this poll." +msgstr "Ị emelarị votu a." + +msgid "" +"If you cannot view the above video, you can instead %(start_link)sdownload " +"it%(end_link)s." +msgstr "Ọ bụrụ na ị gaghị ele vidio dị n’elu anya, ị nwere ike ibudata ya kama %(start_link)sbudata ya%(end_link)s." + +msgid "" +"If you cannot listen to the above audio, you can instead " +"%(start_link)sdownload it%(end_link)s." +msgstr "Ọ bụrụ na ị gaghị ege ụda dị n’elu, ị nwere ike ibudata ya kama %(start_link)sbudata ya%(end_link)s." + +msgid "Please complete the questions marked as required to continue" +msgstr "Biko mezue ajụjụ ndị e depụtara dị ka achọrọ ka ịga n’ihu" + +msgid "Need internet access to visit external site." +msgstr "Ọ dị mkpa inwe njikọ ịntanet iji gaa na weebụsaịtị ọzọ." + +msgid "Comment Moderation" +msgstr "Nlekọta Okwu" + +msgid "Filters" +msgstr "Filtas" + +msgid "Apply" +msgstr "Tinye" + +msgid "Actions" +msgstr "Omume" + +msgid "Unsure" +msgstr "Amaghị" + +msgid "No comment found." +msgstr "Enweghị okwu e chọtara." + +msgid "" +"The content you are trying to reach is not available. To see offline content" +" click here" +msgstr "Ihe ị na-agbalị ịhụ adịghị. Iji hụ ọdịnaya na-adịghị n'ịntanetị, pịa ebe a" diff --git a/locale/ig/LC_MESSAGES/djangojs.po b/locale/ig/LC_MESSAGES/djangojs.po new file mode 100644 index 000000000..44dc8ed95 --- /dev/null +++ b/locale/ig/LC_MESSAGES/djangojs.po @@ -0,0 +1,27 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-18 10:35+0000\n" +"PO-Revision-Date: 2025-08-18 10:35+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "Sorry, there seems to be an error. Please try again soon." +msgstr "Ndo, e nwere njehie. Biko nwaa ọzọ n’oge na-adịghị anya." + + +msgid "You cannot submit when offline" +msgstr "Ị gaghị ezipu mgbe ị nọghị n’ịntanetị" + +msgid "Date must be in this (YYYY-MM-DD) format" +msgstr "Ụbọchị ga-adị na usoro a (YYYY-MM-DD)" + +msgid "Datetime must be in this YYYY-MM-DDTHH:SS format" +msgstr "Ụbọchị na oge ga-adị na usoro YYYY-MM-DDTHH:SS" diff --git a/locale/pcm/LC_MESSAGES/django.po b/locale/pcm/LC_MESSAGES/django.po new file mode 100644 index 000000000..c00cbc130 --- /dev/null +++ b/locale/pcm/LC_MESSAGES/django.po @@ -0,0 +1,374 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-18 10:35+0000\n" +"PO-Revision-Date: 2025-08-18 10:35+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "quizzes" +msgstr "small-small test" + +msgid "Log in to participate" +msgstr "Login make you fit join" + +msgid "Log in / Create account" +msgstr "Login / Create account" + +msgid "© The Internet of Good Things" +msgstr "© Di Internet wey get Beta Things" + +msgid "Profile" +msgstr "Your profile" + +msgid "Chat" +msgstr "Talk" + +msgid "More" +msgstr "More" + +msgid "Log in" +msgstr "Login" + +msgid "Back" +msgstr "Go back" + +msgid "BACK" +msgstr "GO BACK" + +msgid "Previous" +msgstr "Before" + +msgid "Next" +msgstr "Next" + +msgid "Comments" +msgstr "Comment dem" + +msgid "Please log in or create your account" +msgstr "Abeg login or create your account" + +msgid "Report" +msgstr "Report am" + +msgid "Related Articles" +msgstr "Articles wey relate" + +msgid "This comment has been reported." +msgstr "Person don report this comment." + +msgid "" +"It will be reviewed by the team and may be removed if it breaks our Platform" +" Rules" +msgstr "Di team go check am and fit remove am if e break our Platform Rules" + +msgid "articles" +msgstr "articles" + +msgid "Search the site..." +msgstr "Search for site…" + +msgid "Search results for:" +msgstr "Search result for:" + +msgid "No search results were found for" +msgstr "No search result for" + +msgid "Username" +msgstr "Username" + +msgid "Create your account" +msgstr "Create your account" + +msgid "Forgot PIN" +msgstr "Forget your PIN" + +msgid "Cancel" +msgstr "Cancel" + +msgid "Hey, %(username)s" +msgstr "Hey, %(username)s" + +msgid "Change Digital Pin" +msgstr "make you Change your digital PIN" + +msgid "Personal Details" +msgstr "Your personal details" + +msgid "Save changes" +msgstr "Save changes" + +msgid "Reset PIN" +msgstr "Reset PIN" + +msgid "Your PIN has been changed successfully." +msgstr "You don change your PIN well well." + +msgid "I accept the Terms and Conditions." +msgstr "I gree to di Terms and Conditions." + +msgid "You have read %(read)s out of %(total)s" +msgstr "You don read %(read)s from %(total)s" + +msgid "%(counter)s of %(total)s questions" +msgstr "%(counter)s out of %(total)s questions" + +msgid "Select one" +msgstr "Choose one" + +msgid "Optional" +msgstr "If you wan do" + +msgid "No limit" +msgstr "No limit" + +msgid "Submit" +msgstr "Submit" + +msgid "Go to homepage" +msgstr "Oya Go homepage" + +msgid "You have already completed this survey." +msgstr "You don already finish dis survey." + +msgid "Check all that apply" +msgstr "Tick all wey apply" + +msgid "Your results!" +msgstr "Na your result be this!" + +msgid "Replay Quiz" +msgstr "Play di quiz again" + +msgid "Incorrect" +msgstr "No correct" + +msgid "Correct" +msgstr "Correct" + +msgid "Your answer" +msgstr "Your answer" + +msgid "Leave comment" +msgstr "dey Leave comment" + +msgid "Reply" +msgstr "Reply" + +msgid "Page not found" +msgstr "Page no dey" + +msgid "Sorry, this page could not be found." +msgstr "Sorry, we no fit find dis page." + +msgid "Internal server error" +msgstr "Internal server error" + +msgid "1 result" +msgid_plural "%(counter)s results" +msgstr[0] "1 result" +msgstr[1] "%(counter)s results" + +msgid "poll" +msgstr "poll" + +msgid "survey" +msgstr "survey" + +msgid "quiz" +msgstr "quiz" + +msgid "This field is required." +msgstr "You must fill dis space." + +msgid "Send" +msgstr "Send" + +msgid "Remove this comment?" +msgstr "Remove dis comment?" + +msgid "Remove" +msgstr "Remove" + +msgid "This comment has been removed." +msgstr "Dem don remove dis comment." + +msgid "moderator" +msgstr "moderator" + +msgid "sections" +msgstr "sections" + +msgid "Check if applies" +msgstr "Check if e apply" + +msgid "New comments have been disabled for this page." +msgstr "New comment no dey again for dis page." + +msgid "Correct answer" +msgstr "Correct answer" + +msgid "Download" +msgstr "Download" + +msgid "" +"You are leaving the Internet of Good Things to visit an external " +"website and standard data charges by your network provider might apply" +msgstr "You dey comot from Internet of Good Things go another website, your network fit charge you data fee" + +msgid "Continue to external site" +msgstr "Continue go di external site" + +msgid "4-digit PIN" +msgstr "4-digit PIN" + +msgid "Old 4-digit PIN" +msgstr "Old 4-digit PIN" + +msgid "New 4-digit PIN" +msgstr "New 4-digit PIN" + +msgid "Confirm new 4-digit PIN" +msgstr "Confirm new 4-digit PIN" + +msgid "Log out" +msgstr "Log out" + +msgid "section" +msgstr "section" + +msgid "article" +msgstr "article" + +msgid "Remember Me" +msgstr "Remember Me" + +msgid "You have signed out." +msgstr "You don sign out." + +msgid "" +"Enter a valid username. This value may contain only letters, numbers, and " +"@/./+/-/_ characters." +msgstr "Put correct username wey fit get only letter, number, and @/./+/-/_" + +msgid "You must type the same password each time." +msgstr "You must type di same password every time." + +msgid "This password is too short. It must contain at least 4 characters." +msgstr "This password too short, e must get at least 4 character." + +msgid "Please type your current password." +msgstr "Abeg type your current password." + +msgid "Password successfully changed." +msgstr "Password don change well well." + +msgid "Enter a valid date/time." +msgstr "Put correct date/time." + +msgid "Enter a valid date." +msgstr "Put correct date." + +msgid "Enter a valid URL." +msgstr "Put correct website link." + +msgid "Enter a valid email address." +msgstr "Put correct email." + +msgid "Enter a valid integer." +msgstr "Put correct whole number." + +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Make sure say dis value big pass or equal %(limit_value)s." + +msgid "Your comment" +msgstr "Your comment" + +msgid "Are you sure you want to log out?" +msgstr "You sure say you wan log out?" + +msgid "Your browser does not support video playback." +msgstr "Your browser no fit play video." + +msgid "Your browser does not support audio playback." +msgstr "Your browser no fit play audio." + +msgid "Don't display my username next to my comment" +msgstr "No show my username near my comment" + +msgid "Anonymous" +msgstr "Anonymous" + +msgid "Report this comment?" +msgstr "Report dis comment?" + +msgid "Display name" +msgstr "Display name" + +msgid "Reported by %(comment_report_count)s user" +msgid_plural "Reported by %(comment_report_count)s users" +msgstr[0] "Reported by %(comment_report_count)s user" +msgstr[1] "Reported by %(comment_report_count)s users" + +msgid "" +"Sorry, %(page_title)s is not available in %(language)s. If you want to keep " +"browsing in %(language)s you can click below to return to the homepage." +msgstr "Sorry, %(page_title)s no dey for %(language)s. If you wan continue browse for %(language)s, click down go homepage." + +msgid "Username not available." +msgstr "Username no dey." + +msgid "Display name not available." +msgstr "Display name no dey." + +msgid "You have already completed this quiz." +msgstr "You don already finish dis quiz." + +msgid "You have already completed this poll." +msgstr "You don already finish dis poll." + +msgid "" +"If you cannot view the above video, you can instead %(start_link)sdownload " +"it%(end_link)s." +msgstr "If you no fit watch di video wey dey up, you fit instead %(start_link)sdownload am%(end_link)s." + +msgid "" +"If you cannot listen to the above audio, you can instead " +"%(start_link)sdownload it%(end_link)s." +msgstr "If you no fit listen di audio wey dey up, you fit instead %(start_link)sdownload am%(end_link)s." + +msgid "Please complete the questions marked as required to continue" +msgstr "Abeg finish di question dem mark as required before you continue" + +msgid "Need internet access to visit external site." +msgstr "You need internet to visit external site." + +msgid "Comment Moderation" +msgstr "Comment Moderation" + +msgid "Filters" +msgstr "Filters" + +msgid "Apply" +msgstr "Apply" + +msgid "Actions" +msgstr "Actions" + +msgid "Unsure" +msgstr "No sure" + +msgid "No comment found." +msgstr "No comment we see." + +msgid "" +"The content you are trying to reach is not available. To see offline content" +" click here" +msgstr "The content wey you dey try reach no dey. To see offline content click here" diff --git a/locale/pcm/LC_MESSAGES/djangojs.po b/locale/pcm/LC_MESSAGES/djangojs.po new file mode 100644 index 000000000..2d31cc640 --- /dev/null +++ b/locale/pcm/LC_MESSAGES/djangojs.po @@ -0,0 +1,26 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-18 10:35+0000\n" +"PO-Revision-Date: 2025-08-18 10:35+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "Sorry, there seems to be an error. Please try again soon." +msgstr "Sorry, e be like say error dey. Try again soon." + +msgid "You cannot submit when offline" +msgstr "You no fit submit when you offline" + +msgid "Date must be in this (YYYY-MM-DD) format" +msgstr "Date must follow dis (YYYY-MM-DD) format" + +msgid "Datetime must be in this YYYY-MM-DDTHH:SS format" +msgstr "Datetime must follow dis YYYY-MM-DDTHH:SS formatgi" diff --git a/locale/yo/LC_MESSAGES/django.po b/locale/yo/LC_MESSAGES/django.po new file mode 100644 index 000000000..474c16c94 --- /dev/null +++ b/locale/yo/LC_MESSAGES/django.po @@ -0,0 +1,374 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-18 10:35+0000\n" +"PO-Revision-Date: 2025-08-18 10:35+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "quizzes" +msgstr "ìdánwò kéékèèké" + +msgid "Log in to participate" +msgstr "Wọlé láti kópa" + +msgid "Log in / Create account" +msgstr "Wọlé / Ṣẹ́dá àkọọlẹ́" + +msgid "© The Internet of Good Things" +msgstr "© Ayélujára àwọn Nǹkan Tó Dáa" + +msgid "Profile" +msgstr "Àkọsílẹ̀ ẹni" + +msgid "Chat" +msgstr "Ìjíròrò" + +msgid "More" +msgstr "Síi" + +msgid "Log in" +msgstr "Wọlé" + +msgid "Back" +msgstr "Padà" + +msgid "BACK" +msgstr "PADÀ" + +msgid "Previous" +msgstr "Tó kọjá" + +msgid "Next" +msgstr "Tó ń bọ̀" + +msgid "Comments" +msgstr "Àlàyé" + +msgid "Please log in or create your account" +msgstr "Jọ̀wọ́ wọlé tàbí ṣẹ́dá àkọọlẹ́ rẹ" + +msgid "Report" +msgstr "Ròyìn" + +msgid "Related Articles" +msgstr "Àwọn àpilẹ̀kọ tó ní í ṣe pẹ̀lú" + +msgid "This comment has been reported." +msgstr "Àlàyé yìí ti jẹ́ kó ròyìn." + +msgid "" +"It will be reviewed by the team and may be removed if it breaks our Platform" +" Rules" +msgstr "Ẹgbẹ́ yóò ṣàyẹ̀wò ó, wọ́n lè yọ ọ́ kúrò bí ó bá bàjẹ́ Ilànà Pẹpẹ wa" + + +msgid "articles" +msgstr "àpilẹ̀kọ" + +msgid "Search the site..." +msgstr "Wá nínú ààyè yìí…" + +msgid "Search results for:" +msgstr "Àbájáde ìwádìí fún:" + +msgid "No search results were found for" +msgstr "Kò sí àbájáde ìwádìí tó wà fún" + +msgid "Username" +msgstr "Orúkọ olùmúlò" + +msgid "Create your account" +msgstr "Ṣẹ́dá àkọọlẹ́ rẹ" + +msgid "Forgot PIN" +msgstr "Gbàgbé Nọ́mbà PIN" + +msgid "Cancel" +msgstr "Fagilé" + +msgid "Hey, %(username)s" +msgstr "Bàwo, %(username)s" + +msgid "Change Digital Pin" +msgstr "Ṣàtúnṣe Nọ́mbà PIN oní-nọ́mbà" + +msgid "Personal Details" +msgstr "Àlàyé ẹni" + +msgid "Save changes" +msgstr "Fipamọ́ àwọn àtúnṣe" + +msgid "Reset PIN" +msgstr "Tún PIN ṣe" + +msgid "Your PIN has been changed successfully." +msgstr "A ti yí Nọ́mbà PIN rẹ padà ní àṣeyọrí." + +msgid "I accept the Terms and Conditions." +msgstr "Mo gba àwọn Ìpinnu àti Àwọn Ilànà." + +msgid "You have read %(read)s out of %(total)s" +msgstr "O ti ka %(read)s lára %(total)s" + +msgid "%(counter)s of %(total)s questions" +msgstr "Ìbéèrè %(counter)s lára %(total)s" + +msgid "Select one" +msgstr "Yàn ọ̀kan" + +msgid "Optional" +msgstr "Aṣayan" + +msgid "No limit" +msgstr "Kò sí ìdíyelé" + +msgid "Submit" +msgstr "Fọwọ́ sí" + +msgid "Go to homepage" +msgstr "Lọ sí ojú-ìwé àkọ́kọ́" + +msgid "You have already completed this survey." +msgstr "O ti parí ìdánilẹ́kọ yìí." + +msgid "Check all that apply" +msgstr "Ṣàyẹ̀wò gbogbo tó yẹ" + +msgid "Your results!" +msgstr "Àbájáde rẹ!" + +msgid "Replay Quiz" +msgstr "Ṣe ìdánwò lẹ́ẹ̀kansi" + +msgid "Incorrect" +msgstr "Kò tọ́" + +msgid "Correct" +msgstr "Tọ́" + +msgid "Your answer" +msgstr "Ìdáhùn rẹ" + +msgid "Leave comment" +msgstr "Fi ọrọ̀ sílẹ̀" + +msgid "Reply" +msgstr "Dáhùn" + +msgid "Page not found" +msgstr "A kò rí ojú-ìwé náà" + +msgid "Sorry, this page could not be found." +msgstr "Bínú, a kò rí ojú-ìwé yìí." + +msgid "Internal server error" +msgstr "Àṣìṣe olupin inú" + +msgid "1 result" +msgid_plural "%(counter)s results" +msgstr[0] "Àbájáde 1" +msgstr[1] "Àbájáde %(counter)s" + +msgid "poll" +msgstr "ìdìbọ" + +msgid "survey" +msgstr "ìdánilẹ́kọ" + +msgid "quiz" +msgstr "ìdánwò" + +msgid "This field is required." +msgstr "Pápá yìí ṣe pàtàkì." + +msgid "Send" +msgstr "Ránṣẹ́" + +msgid "Remove this comment?" +msgstr "Yọ àsọyé yìí kúrò?" + +msgid "Remove" +msgstr "Yọ" + +msgid "This comment has been removed." +msgstr "A ti yọ àsọyé yìí kúrò." + +msgid "moderator" +msgstr "amòfin" + +msgid "sections" +msgstr "apá" + +msgid "Check if applies" +msgstr "Ṣàyẹ̀wò bó bá yẹ" + +msgid "New comments have been disabled for this page." +msgstr "Àwọn àsọyé tuntun ti di mọ́ fún ojú-ìwé yìí." + +msgid "Correct answer" +msgstr "Ìdáhùn tó tọ́" + +msgid "Download" +msgstr "Ṣe igbasilẹ" + +msgid "" +"You are leaving the Internet of Good Things to visit an external " +"website and standard data charges by your network provider might apply" +msgstr "O ń fi Ayelujara ti Ohun Rere sílẹ̀ láti ṣàbẹ̀wò sí ojú-ìwé ita, àti pé ó ṣeé ṣe kí wọ́n gba owó ìfọwọ́sowọ́pọ̀ data" + +msgid "Continue to external site" +msgstr "Tẹ̀síwájú sí ojú-ìwé ita" + +msgid "4-digit PIN" +msgstr "PIN díjítì mẹ́rin" + +msgid "Old 4-digit PIN" +msgstr "PIN díjítì mẹ́rin atijọ́" + +msgid "New 4-digit PIN" +msgstr "PIN díjítì mẹ́rin tuntun" + +msgid "Confirm new 4-digit PIN" +msgstr "Jẹ́rìí PIN díjítì mẹ́rin tuntun" + +msgid "Log out" +msgstr "Jáde" + +msgid "section" +msgstr "apá" + +msgid "article" +msgstr "àpilẹ̀kọ" + +msgid "Remember Me" +msgstr "Rántí mi" + +msgid "You have signed out." +msgstr "O ti jáde." + +msgid "" +"Enter a valid username. This value may contain only letters, numbers, and " +"@/./+/-/_ characters." +msgstr "Tẹ orúkọ olumulo tó bófin mu. Èyí lè ní lẹ́tà, nọ́mbà, àti àmi @/./+/-/_ nìkan." + +msgid "You must type the same password each time." +msgstr "O gbọ́dọ̀ kọ ọrọ aṣínà kan naa ní gbogbo ìgbà." + +msgid "This password is too short. It must contain at least 4 characters." +msgstr "Ọrọ aṣínà yìí kéré jù. Ó gbọ́dọ̀ ní o kere jù lọ àmì mẹ́rin." + +msgid "Please type your current password." +msgstr "Jọwọ kọ ọrọ aṣínà lọwọlọwọ rẹ." + +msgid "Password successfully changed." +msgstr "Ọrọ aṣínà ti yípadà ní àṣeyọrí." + +msgid "Enter a valid date/time." +msgstr "Tẹ ọjọ́/àkókò tó bófin mu." + +msgid "Enter a valid date." +msgstr "Tẹ ọjọ́ tó bófin mu." + +msgid "Enter a valid URL." +msgstr "Tẹ URL tó bófin mu." + +msgid "Enter a valid email address." +msgstr "Tẹ ìmèl tó bófin mu." + +msgid "Enter a valid integer." +msgstr "Tẹ nọ́mbà gígùn tó bófin mu." + +msgid "Ensure this value is greater than or equal to %(limit_value)s." +msgstr "Rí i dájú pé iye yìí pọ̀ jù tàbí tó dogba pẹ̀lú %(limit_value)s." + +msgid "Your comment" +msgstr "Àsọyé rẹ" + +msgid "Are you sure you want to log out?" +msgstr "Ṣé o dájú pé o fẹ́ jáde?" + +msgid "Your browser does not support video playback." +msgstr "Àwọ̀n aṣàwákiri rẹ kò ṣe àtìlẹ́yìn fíìmù." + +msgid "Your browser does not support audio playback." +msgstr "Àwọ̀n aṣàwákiri rẹ kò ṣe àtìlẹ́yìn ohun." + +msgid "Don't display my username next to my comment" +msgstr "Má ṣe fi orúkọ olumulo mi lẹ́gbẹ̀ẹ́ àsọyé mi" + +msgid "Anonymous" +msgstr "Aláìmọ̀ orúkọ" + +msgid "Report this comment?" +msgstr "Ṣe ìròyìn àsọyé yìí?" + +msgid "Display name" +msgstr "Orúkọ tó hàn" + +msgid "Reported by %(comment_report_count)s user" +msgid_plural "Reported by %(comment_report_count)s users" +msgstr[0] "A ti jabo rẹ̀ láti ọdọ olumulo %(comment_report_count)s" +msgstr[1] "A ti jabo rẹ̀ láti ọdọ àwọn olumulo %(comment_report_count)s" + +msgid "" +"Sorry, %(page_title)s is not available in %(language)s. If you want to keep " +"browsing in %(language)s you can click below to return to the homepage." +msgstr "Bínú, %(page_title)s kò sí ní %(language)s. Tí o bá fẹ́ máa ṣàwárí lọ́wọ́lọ́wọ́ ní %(language)s o lè tẹ isalẹ láti padà sí ojú-ìwé àkọ́kọ́." + +msgid "Username not available." +msgstr "Orúkọ olumulo kò sí." + +msgid "Display name not available." +msgstr "Orúkọ tó hàn kò sí." + +msgid "You have already completed this quiz." +msgstr "O ti parí ìdánwò yìí." + +msgid "You have already completed this poll." +msgstr "O ti parí ìdìbọ yìí." + +msgid "" +"If you cannot view the above video, you can instead %(start_link)sdownload " +"it%(end_link)s." +msgstr "Bí o kò bá lè wo fíìmù tó wà lókè, o lè dínkù rẹ̀ dípò náà %(start_link)sṣe igbasilẹ rẹ̀%(end_link)s." + +msgid "" +"If you cannot listen to the above audio, you can instead " +"%(start_link)sdownload it%(end_link)s." +msgstr "Bí o kò bá lè gbọ́ ohun tó wà lókè, o lè dínkù rẹ̀ dípò náà %(start_link)sṣe igbasilẹ rẹ̀%(end_link)s." + +msgid "Please complete the questions marked as required to continue" +msgstr "Jọwọ parí àwọn ìbéèrè tó fi hàn pé wọ́n ṣe pàtàkì kí o lè tẹ̀síwájú" + +msgid "Need internet access to visit external site." +msgstr "O nílò ìbáṣepọ̀ Ayelujara láti ṣàbẹ̀wò sí ojú-ìwé ita." + +msgid "Comment Moderation" +msgstr "Ìṣàkóso Àsọyé" + +msgid "Filters" +msgstr "Fíltà" + +msgid "Apply" +msgstr "Lo" + +msgid "Actions" +msgstr "Ìṣe" + +msgid "Unsure" +msgstr "A kò dájú" + +msgid "No comment found." +msgstr "Kò sí àsọyé kankan." + +msgid "The content you are trying to reach is not available. To see offline content" +" click here" +msgstr "Àkọsílẹ̀ tí o ń gbìyànjú láti rí kò sí. Láti rí àkọsílẹ̀ àìmọ̀ọ́fàìnì tẹ níbí" diff --git a/locale/yo/LC_MESSAGES/djangojs.po b/locale/yo/LC_MESSAGES/djangojs.po new file mode 100644 index 000000000..0696c41cb --- /dev/null +++ b/locale/yo/LC_MESSAGES/djangojs.po @@ -0,0 +1,26 @@ +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-08-18 10:35+0000\n" +"PO-Revision-Date: 2025-08-18 10:35+0000\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: French\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +msgid "Sorry, there seems to be an error. Please try again soon." +msgstr "Bínú, àṣìṣe kan wà. Jọwọ gbìyànjú lẹ́yìn ìgbà díẹ̀." + +msgid "You cannot submit when offline" +msgstr "O kò lè fi ránṣẹ́ nígbà tí o kò sí lórí Ayelujara" + +msgid "Date must be in this (YYYY-MM-DD) format" +msgstr "Ọjọ́ gbọ́dọ̀ wà ní irú fọ́ọ́màtì yìí (YYYY-MM-DD)" + +msgid "Datetime must be in this YYYY-MM-DDTHH:SS format" +msgstr "Ọjọ́ àti àkókò gbọ́dọ̀ wà ní fọ́ọ́màtì YYYY-MM-DDTHH:SS" diff --git a/notifications/apps.py b/notifications/apps.py deleted file mode 100644 index 9c260e0b1..000000000 --- a/notifications/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class NotificationsConfig(AppConfig): - name = 'notifications' diff --git a/notifications/views.py b/notifications/views.py deleted file mode 100644 index afccb959d..000000000 --- a/notifications/views.py +++ /dev/null @@ -1,12 +0,0 @@ -from wagtail.contrib.modeladmin.views import CreateView -from webpush import send_user_notification - - -class CreateNotificationView(CreateView): - def form_valid(self, form): - payload = form.cleaned_data.copy() - groups = payload.pop('groups') - for group in groups: - for user in group.user_set.all(): - send_user_notification(user=user, payload=payload, ttl=1000) - return super().form_valid(form) diff --git a/questionnaires/migrations/0033_survey_notification_tags.py b/questionnaires/migrations/0033_survey_notification_tags.py new file mode 100644 index 000000000..28312e9f9 --- /dev/null +++ b/questionnaires/migrations/0033_survey_notification_tags.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.25 on 2025-07-28 12:22 + +from django.db import migrations +import modelcluster.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('user_notifications', '0003_auto_20250728_1222'), + ('questionnaires', '0032_auto_20250526_0135'), + ] + + operations = [ + migrations.AddField( + model_name='survey', + name='notification_tags', + field=modelcluster.fields.ParentalManyToManyField(blank=True, to='user_notifications.NotificationTag'), + ), + ] diff --git a/questionnaires/models.py b/questionnaires/models.py index 682df84ea..d29013497 100644 --- a/questionnaires/models.py +++ b/questionnaires/models.py @@ -12,6 +12,7 @@ from wagtailmarkdown.blocks import MarkdownBlock from wagtailsvg.edit_handlers import SvgChooserPanel from wagtailsvg.models import Svg +from user_notifications.models import NotificationTag from home.blocks import ( MediaBlock, @@ -25,7 +26,7 @@ collect_urls_from_streamfield, get_all_renditions_urls, ) -from modelcluster.fields import ParentalKey +from modelcluster.fields import ParentalKey, ParentalManyToManyField from wagtail.admin.panels import (FieldPanel, InlinePanel, MultiFieldPanel) from wagtail.contrib.forms.models import (AbstractForm, AbstractFormField, AbstractFormSubmission) @@ -384,6 +385,7 @@ def next_page(self, choice): class Survey(QuestionnairePage, AbstractForm): base_form_class = SurveyForm form_builder = CustomFormBuilder + notification_tags = ParentalManyToManyField(NotificationTag, blank=True) parent_page_types = [ "home.HomePage", "home.Section", "home.Article", "questionnaires.SurveyIndexPage", 'home.FooterIndexPage', @@ -443,7 +445,9 @@ class Survey(QuestionnairePage, AbstractForm): TranslatableField('survey_form_fields'), TranslatableField('thank_you_text') ] - + promote_panels = Page.promote_panels + [ + MultiFieldPanel([FieldPanel("notification_tags"), ], heading='Metadata'), + ] @cached_property def has_page_breaks(self): return any( diff --git a/questionnaires/wagtail_hooks.py b/questionnaires/wagtail_hooks.py index 383e5d17c..130c3389f 100644 --- a/questionnaires/wagtail_hooks.py +++ b/questionnaires/wagtail_hooks.py @@ -1,12 +1,14 @@ -from django.urls import path -from django.utils.html import format_html_join + +from django.urls import path, reverse +from django.utils.html import format_html_join, format_html from django.templatetags.static import static from wagtail import hooks -from wagtail.models import Locale - +from wagtail.models import Locale, Page from questionnaires.views import FormPagesListView, FormDataPerUserView, generate_dashboard +from questionnaires.models import Survey +from iogt.utils import NotifyAndPublishMenuItem, notify_and_publish_view @hooks.register('insert_editor_js', order=0) def editor_js(): @@ -27,4 +29,10 @@ def register_custom_form_pages_list_view(): path('forms/', FormPagesListView.as_view(), name='index'), path('form-data/', FormDataPerUserView.as_view(), name='form_data_per_user'), path('generate-dashboard//', generate_dashboard, name='generate_dashboard'), + path("notify-and-publish//", notify_and_publish_view, name="notify_and_publish"), ] + + +@hooks.register('register_page_action_menu_item') +def register_notify_and_publish_menu_item(): + return NotifyAndPublishMenuItem(order=100, allowed_models=Survey) # diff --git a/requirements.dev.in b/requirements.dev.in index e804e9a28..29ca2f43f 100644 --- a/requirements.dev.in +++ b/requirements.dev.in @@ -8,3 +8,5 @@ pytest pytest-django selenium==4.9.1 wagtail-factories +django-notifications-hq==1.8.3 +celery==5.3.6 diff --git a/requirements.dev.txt b/requirements.dev.txt index e6f43e065..3b595d1ab 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -4,6 +4,10 @@ # # pip-compile --generate-hashes requirements.dev.in # +amqp==5.3.1 \ + --hash=sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2 \ + --hash=sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432 + # via kombu anyascii==0.3.2 \ --hash=sha256:3b3beef6fc43d9036d3b0529050b0c48bfad8bc960e9e562d7223cfb94fe45d4 \ --hash=sha256:9d5d32ef844fe225b8bc7cba7f950534fae4da27a9bf3a6bea2cb0ea46ce4730 @@ -29,6 +33,10 @@ beautifulsoup4==4.9.3 \ # via # -r requirements.txt # wagtail +billiard==4.2.1 \ + --hash=sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f \ + --hash=sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb + # via celery bleach==4.1.0 \ --hash=sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da \ --hash=sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994 @@ -45,6 +53,10 @@ cairosvg==2.7.1 \ --hash=sha256:432531d72347291b9a9ebfb6777026b607563fd8719c46ee742db0aef7271ba0 \ --hash=sha256:8a5222d4e6c3f86f1f7046b63246877a63b49923a1cd202184c3a634ef546b3b # via -r requirements.txt +celery==5.3.6 \ + --hash=sha256:870cc71d737c0200c397290d730344cc991d13a057534353d124c9380267aab9 \ + --hash=sha256:9da4ea0118d232ce97dff5ed4974587fb1c0ff5c10042eb15278487cdd27d1af + # via -r requirements.dev.in certifi==2024.7.4 \ --hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \ --hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90 @@ -209,7 +221,27 @@ click==8.1.7 \ --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de # via # -r requirements.txt + # celery + # click-didyoumean + # click-plugins + # click-repl # rq +click-didyoumean==0.3.1 \ + --hash=sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463 \ + --hash=sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c + # via celery +click-plugins==1.1.1.2 \ + --hash=sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6 \ + --hash=sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261 + # via celery +click-repl==0.3.0 \ + --hash=sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9 \ + --hash=sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812 + # via celery +colorama==0.4.6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via -r requirements.txt coverage==7.6.0 \ --hash=sha256:0086cd4fc71b7d485ac93ca4239c8f75732c2ae3ba83f6be1c9be59d9e2c6382 \ --hash=sha256:01c322ef2bbe15057bc4bf132b525b7e3f7206f071799eb8aa6ad1940bcf5fb1 \ @@ -329,7 +361,9 @@ django==3.2.25 \ # django-extensions # django-filter # django-health-check + # django-model-utils # django-modelcluster + # django-notifications-hq # django-permissionedforms # django-redis # django-rq @@ -339,6 +373,7 @@ django==3.2.25 \ # djangorestframework # djangorestframework-simplejwt # drf-yasg + # jsonfield # wagtail # wagtail-localize # wagtailmedia @@ -389,12 +424,23 @@ django-health-check==3.16.5 \ --hash=sha256:1edfd49293ccebbce29f9da609c407f307aee240ab799ab4201031341ae78c0f \ --hash=sha256:8d66781a0ea82b1a8b44878187b38a27370e94f18287312e39be0593e72d8983 # via -r requirements.txt +django-model-utils==5.0.0 \ + --hash=sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb \ + --hash=sha256:fec78e6c323d565a221f7c4edc703f4567d7bb1caeafe1acd16a80c5ff82056b + # via + # -r requirements.txt + # django-notifications-hq django-modelcluster==6.3 \ --hash=sha256:0caed8a0e889f3abb92f144670878a466ef954ffa6c4c7b9c80e6426b720a49d \ --hash=sha256:a8783d6565a0663f41cd6003ea361c3a5711e8a2a326160f1ec1eceb3e973d4f # via # -r requirements.txt # wagtail +django-notifications-hq==1.8.3 \ + --hash=sha256:0f4b216bb382b7c7c4eef273eb211e59c1c6a0ea38cba6077415ac031d330725 + # via + # -r requirements.dev.in + # -r requirements.txt django-permissionedforms==0.1 \ --hash=sha256:4340bb20c4477fffb13b4cc5cccf9f1b1010b64f79956c291c72d2ad2ed243f8 \ --hash=sha256:d341a961a27cc77fde8cc42141c6ab55cc1f0cb886963cc2d6967b9674fa47d6 @@ -454,9 +500,9 @@ djangorestframework-simplejwt==5.3.1 \ --hash=sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220 \ --hash=sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae # via -r requirements.txt -docutils==0.21.2 \ - --hash=sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f \ - --hash=sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2 +docutils==0.20.1 \ + --hash=sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6 \ + --hash=sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b # via # -r requirements.txt # django-comments-xtd @@ -514,6 +560,10 @@ idna==3.7 \ # -r requirements.txt # requests # trio +importlib-metadata==8.5.0 \ + --hash=sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b \ + --hash=sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7 + # via -r requirements.txt inflection==0.5.1 \ --hash=sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417 \ --hash=sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2 @@ -524,6 +574,16 @@ iniconfig==2.0.0 \ --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 # via pytest +jsonfield==3.1.0 \ + --hash=sha256:7e4e84597de21eeaeeaaa7cc5da08c61c48a9b64d0c446b2d71255d01812887a \ + --hash=sha256:df857811587f252b97bafba42e02805e70a398a7a47870bc6358a0308dd689ed + # via + # -r requirements.txt + # django-notifications-hq +kombu==5.5.4 \ + --hash=sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363 \ + --hash=sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8 + # via celery l18n==2021.3 \ --hash=sha256:1956e890d673d17135cc20913253c154f6bc1c00266c22b7d503cc1a5a42d848 \ --hash=sha256:78495d1df95b6f7dcc694d1ba8994df709c463a1cbac1bf016e1b9a5ce7280b9 @@ -666,6 +726,7 @@ packaging==24.1 \ # -r requirements.txt # bleach # drf-yasg + # kombu # pytest pillow==9.5.0 \ --hash=sha256:07999f5834bdc404c442146942a2ecadd1cb6292f5229f4ed3b31e0a108746b1 \ @@ -749,6 +810,10 @@ polib==1.2.0 \ # -r requirements.txt # django-translation-manager # wagtail-localize +prompt-toolkit==3.0.51 \ + --hash=sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07 \ + --hash=sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed + # via click-repl psycopg2==2.9.9 \ --hash=sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981 \ --hash=sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516 \ @@ -782,6 +847,7 @@ pyjwt[crypto]==2.8.0 \ # -r requirements.txt # django-allauth # djangorestframework-simplejwt + # pyjwt pysocks==1.7.1 \ --hash=sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299 \ --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ @@ -800,7 +866,9 @@ pytest-django==4.8.0 \ python-dateutil==2.9.0.post0 \ --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 - # via faker + # via + # celery + # faker python3-openid==3.2.0 \ --hash=sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf \ --hash=sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b @@ -815,6 +883,7 @@ pytz==2024.1 \ # django # django-comments-xtd # django-modelcluster + # django-notifications-hq # djangorestframework # drf-yasg # l18n @@ -968,11 +1037,18 @@ sqlparse==0.5.0 \ # -r requirements.txt # django # django-debug-toolbar +swapper==1.4.0 \ + --hash=sha256:57b8378aad234242542fe32dc6e8cff0ed24b63493d20b3c88ee01f894b9345e \ + --hash=sha256:9e083af114ee0593241a7b877e3e0e7d3a580454f5d59016c667a5563306f8fe + # via + # -r requirements.txt + # django-notifications-hq tablib[xls,xlsx]==3.6.1 \ --hash=sha256:040685fde11e9237675f43e985edb94b63250a5e9236f89d561ce6fb1465b839 \ --hash=sha256:c771d38ed1d74350a69873db43e0afb7f1cca0ed2915a7243094463eb6789207 # via # -r requirements.txt + # tablib # wagtail telepath==0.3.1 \ --hash=sha256:925c0609e0a8a6488ec4a55b19d485882cf72223b2b19fe2359a50fddd813c9c \ @@ -1007,6 +1083,12 @@ typing-extensions==4.12.2 \ # via # -r requirements.txt # wagtail-localize +tzdata==2025.2 \ + --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \ + --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9 + # via + # celery + # kombu uritemplate==4.1.1 \ --hash=sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0 \ --hash=sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e @@ -1021,6 +1103,13 @@ urllib3[socks]==1.26.19 \ # elasticsearch # requests # selenium +vine==5.1.0 \ + --hash=sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc \ + --hash=sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0 + # via + # amqp + # celery + # kombu wagtail==3.0.3 \ --hash=sha256:111ed9a0a6ff26d5d881d52deb4bf52b627d79a53c43829611752dbb68a9192f \ --hash=sha256:23b3e541401355ea183372582050ea52b049c956dd5b506197f957bb68423ab3 @@ -1067,6 +1156,10 @@ wagtailsvg==0.0.37 \ --hash=sha256:4b30433809614fbac09f9088b4aed42a1bfe56f9fb581b858606187cb177ca6b \ --hash=sha256:dc4d698748bde32034b1eac2275cf159597ca8e399d3830e96391f761aa0cacf # via -r requirements.txt +wcwidth==0.2.13 \ + --hash=sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 \ + --hash=sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5 + # via prompt-toolkit webencodings==0.5.1 \ --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923 @@ -1108,28 +1201,9 @@ xlwt==1.3.0 \ # via # -r requirements.txt # tablib -msal==1.31.0 \ - --hash=sha256:96bc37cff82ebe4b160d5fc0f1196f6ca8b50e274ecd0ec5bf69c438514086e7 - # via - # -r requirements.txt - # msal -django-anymail==10.3 \ - --hash=sha256:35d8ec20b06000af3c1638492e32f416fd1a584f0a572cded03f1bc570169ed0 - # via - # -r requirements.txt - # django-anymail -pandas==2.2.3 \ - --hash=sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc - # via - # -r requirements.txt - # pandas -numpy==1.23.2 \ - --hash=sha256:ac987b35df8c2a2eab495ee206658117e9ce867acf3ccb376a19e83070e69418 - # via - # -r requirements.txt - # numpy -tzdata==2025.1 \ - --hash=sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639 - # via - # -r requirements.txt - # numpy +zipp==3.20.2 \ + --hash=sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350 \ + --hash=sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29 + # via + # -r requirements.txt + # importlib-metadata diff --git a/requirements.in b/requirements.in index 92c7a8628..2ddff454f 100644 --- a/requirements.in +++ b/requirements.in @@ -31,3 +31,5 @@ wagtailsvg==0.0.37 # branch: iogt, tag: v0.8.5-iogt.1 wagtail-transfer @ https://github.com/IDEMSInternational/wagtail-transfer/archive/52b57b364ebc9acddd412ee708f829b45763c9ab.zip whitenoise==5.2.* +django-notifications-hq==1.8.3 +celery==5.3.6 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d48e89719..8905fd7e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,13 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile --generate-hashes requirements.in # +amqp==5.3.1 \ + --hash=sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2 \ + --hash=sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432 + # via kombu anyascii==0.3.2 \ --hash=sha256:3b3beef6fc43d9036d3b0529050b0c48bfad8bc960e9e562d7223cfb94fe45d4 \ --hash=sha256:9d5d32ef844fe225b8bc7cba7f950534fae4da27a9bf3a6bea2cb0ea46ce4730 @@ -19,6 +23,10 @@ beautifulsoup4==4.9.3 \ # via # -r requirements.in # wagtail +billiard==4.2.1 \ + --hash=sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f \ + --hash=sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb + # via celery bleach==4.1.0 \ --hash=sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da \ --hash=sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994 @@ -31,6 +39,10 @@ cairosvg==2.7.1 \ --hash=sha256:432531d72347291b9a9ebfb6777026b607563fd8719c46ee742db0aef7271ba0 \ --hash=sha256:8a5222d4e6c3f86f1f7046b63246877a63b49923a1cd202184c3a634ef546b3b # via -r requirements.in +celery==5.3.6 \ + --hash=sha256:870cc71d737c0200c397290d730344cc991d13a057534353d124c9380267aab9 \ + --hash=sha256:9da4ea0118d232ce97dff5ed4974587fb1c0ff5c10042eb15278487cdd27d1af + # via -r requirements.in certifi==2024.7.4 \ --hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \ --hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90 @@ -188,7 +200,30 @@ charset-normalizer==3.3.2 \ click==8.1.7 \ --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de - # via rq + # via + # celery + # click-didyoumean + # click-plugins + # click-repl + # rq +click-didyoumean==0.3.1 \ + --hash=sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463 \ + --hash=sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c + # via celery +click-plugins==1.1.1.2 \ + --hash=sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6 \ + --hash=sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261 + # via celery +click-repl==0.3.0 \ + --hash=sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9 \ + --hash=sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812 + # via celery +colorama==0.4.6 \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # click + # tqdm cryptography==42.0.8 \ --hash=sha256:013629ae70b40af70c9a7a5db40abe5d9054e6f4380e50ce769947b73bf3caad \ --hash=sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583 \ @@ -249,7 +284,9 @@ django==3.2.25 \ # django-extensions # django-filter # django-health-check + # django-model-utils # django-modelcluster + # django-notifications-hq # django-permissionedforms # django-redis # django-rq @@ -259,6 +296,7 @@ django==3.2.25 \ # djangorestframework # djangorestframework-simplejwt # drf-yasg + # jsonfield # wagtail # wagtail-localize # wagtailmedia @@ -290,7 +328,7 @@ django-extensions==3.1.5 \ --hash=sha256:9238b9e016bb0009d621e05cf56ea8ce5cce9b32e91ad2026996a7377ca28069 # via -r requirements.in django-filter==2.4.0 \ - --hash=sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06 \ + --hash=sha256:84e9d5cbb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06 \ --hash=sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1 # via # -r requirements.in @@ -299,10 +337,17 @@ django-health-check==3.16.5 \ --hash=sha256:1edfd49293ccebbce29f9da609c407f307aee240ab799ab4201031341ae78c0f \ --hash=sha256:8d66781a0ea82b1a8b44878187b38a27370e94f18287312e39be0593e72d8983 # via -r requirements.in +django-model-utils==5.0.0 \ + --hash=sha256:041cdd6230d2fbf6cd943e1969318bce762272077f4ecd333ab2263924b4e5eb \ + --hash=sha256:fec78e6c323d565a221f7c4edc703f4567d7bb1caeafe1acd16a80c5ff82056b + # via django-notifications-hq django-modelcluster==6.3 \ --hash=sha256:0caed8a0e889f3abb92f144670878a466ef954ffa6c4c7b9c80e6426b720a49d \ --hash=sha256:a8783d6565a0663f41cd6003ea361c3a5711e8a2a326160f1ec1eceb3e973d4f # via wagtail +django-notifications-hq==1.8.3 \ + --hash=sha256:0f4b216bb382b7c7c4eef273eb211e59c1c6a0ea38cba6077415ac031d330725 + # via -r requirements.in django-permissionedforms==0.1 \ --hash=sha256:4340bb20c4477fffb13b4cc5cccf9f1b1010b64f79956c291c72d2ad2ed243f8 \ --hash=sha256:d341a961a27cc77fde8cc42141c6ab55cc1f0cb886963cc2d6967b9674fa47d6 @@ -350,9 +395,9 @@ djangorestframework-simplejwt==5.3.1 \ --hash=sha256:381bc966aa46913905629d472cd72ad45faa265509764e20ffd440164c88d220 \ --hash=sha256:6c4bd37537440bc439564ebf7d6085e74c5411485197073f508ebdfa34bc9fae # via -r requirements.in -docutils==0.21.2 \ - --hash=sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f \ - --hash=sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2 +docutils==0.20.1 \ + --hash=sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6 \ + --hash=sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b # via django-comments-xtd draftjs-exporter==2.1.7 \ --hash=sha256:5839cbc29d7bce2fb99837a404ca40c3a07313f2a20e2700de7ad6aa9a9a18fb \ @@ -385,6 +430,14 @@ inflection==0.5.1 \ --hash=sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417 \ --hash=sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2 # via drf-yasg +jsonfield==3.1.0 \ + --hash=sha256:7e4e84597de21eeaeeaaa7cc5da08c61c48a9b64d0c446b2d71255d01812887a \ + --hash=sha256:df857811587f252b97bafba42e02805e70a398a7a47870bc6358a0308dd689ed + # via django-notifications-hq +kombu==5.5.4 \ + --hash=sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363 \ + --hash=sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8 + # via celery l18n==2021.3 \ --hash=sha256:1956e890d673d17135cc20913253c154f6bc1c00266c22b7d503cc1a5a42d848 \ --hash=sha256:78495d1df95b6f7dcc694d1ba8994df709c463a1cbac1bf016e1b9a5ce7280b9 @@ -514,6 +567,7 @@ packaging==24.1 \ # via # bleach # drf-yasg + # kombu pillow==9.5.0 \ --hash=sha256:07999f5834bdc404c442146942a2ecadd1cb6292f5229f4ed3b31e0a108746b1 \ --hash=sha256:0852ddb76d85f127c135b6dd1f0bb88dbb9ee990d2cd9aa9e28526c93e794fba \ @@ -590,6 +644,10 @@ polib==1.2.0 \ # via # django-translation-manager # wagtail-localize +prompt-toolkit==3.0.51 \ + --hash=sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07 \ + --hash=sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed + # via click-repl psycopg2==2.9.9 \ --hash=sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981 \ --hash=sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516 \ @@ -619,6 +677,10 @@ pyjwt[crypto]==2.8.0 \ # -r requirements.in # django-allauth # djangorestframework-simplejwt +python-dateutil==2.9.0.post0 \ + --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ + --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 + # via celery python3-openid==3.2.0 \ --hash=sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf \ --hash=sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b @@ -630,6 +692,7 @@ pytz==2024.1 \ # django # django-comments-xtd # django-modelcluster + # django-notifications-hq # djangorestframework # drf-yasg # l18n @@ -743,6 +806,7 @@ six==1.16.0 \ # html5lib # l18n # libsass + # python-dateutil soupsieve==2.5 \ --hash=sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690 \ --hash=sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7 @@ -751,6 +815,10 @@ sqlparse==0.5.0 \ --hash=sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93 \ --hash=sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663 # via django +swapper==1.4.0 \ + --hash=sha256:57b8378aad234242542fe32dc6e8cff0ed24b63493d20b3c88ee01f894b9345e \ + --hash=sha256:9e083af114ee0593241a7b877e3e0e7d3a580454f5d59016c667a5563306f8fe + # via django-notifications-hq tablib[xls,xlsx]==3.6.1 \ --hash=sha256:040685fde11e9237675f43e985edb94b63250a5e9236f89d561ce6fb1465b839 \ --hash=sha256:c771d38ed1d74350a69873db43e0afb7f1cca0ed2915a7243094463eb6789207 @@ -773,6 +841,12 @@ typing-extensions==4.12.2 \ --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \ --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8 # via wagtail-localize +tzdata==2025.2 \ + --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \ + --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9 + # via + # celery + # kombu uritemplate==4.1.1 \ --hash=sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0 \ --hash=sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e @@ -783,6 +857,13 @@ urllib3==1.26.19 \ # via # elasticsearch # requests +vine==5.1.0 \ + --hash=sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc \ + --hash=sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0 + # via + # amqp + # celery + # kombu wagtail==3.0.3 \ --hash=sha256:111ed9a0a6ff26d5d881d52deb4bf52b627d79a53c43829611752dbb68a9192f \ --hash=sha256:23b3e541401355ea183372582050ea52b049c956dd5b506197f957bb68423ab3 @@ -822,6 +903,10 @@ wagtailsvg==0.0.37 \ --hash=sha256:4b30433809614fbac09f9088b4aed42a1bfe56f9fb581b858606187cb177ca6b \ --hash=sha256:dc4d698748bde32034b1eac2275cf159597ca8e399d3830e96391f761aa0cacf # via -r requirements.in +wcwidth==0.2.13 \ + --hash=sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 \ + --hash=sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5 + # via prompt-toolkit webencodings==0.5.1 \ --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \ --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923 @@ -850,5 +935,3 @@ xlwt==1.3.0 \ --hash=sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e \ --hash=sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88 # via tablib -msal==1.31.0 \ - --hash=sha256:96bc37cff82ebe4b160d5fc0f1196f6ca8b50e274ecd0ec5bf69c438514086e7 diff --git a/user_notifications/__init__.py b/user_notifications/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/user_notifications/admin.py b/user_notifications/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/user_notifications/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/user_notifications/apps.py b/user_notifications/apps.py new file mode 100644 index 000000000..5937c97ac --- /dev/null +++ b/user_notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserNotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user_notifications' diff --git a/user_notifications/migrations/0001_initial.py b/user_notifications/migrations/0001_initial.py new file mode 100644 index 000000000..92adc3aa0 --- /dev/null +++ b/user_notifications/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.25 on 2025-07-04 09:31 + +from django.db import migrations, models +import django.utils.timezone +import wagtail.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='UserNotificationTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('message', wagtail.fields.RichTextField()), + ('active', models.BooleanField(default=True)), + ('updated_at', models.DateTimeField(default=django.utils.timezone.now)), + ], + options={ + 'verbose_name': 'User Notification Template', + 'verbose_name_plural': 'User Notification Templates', + }, + ), + ] diff --git a/user_notifications/migrations/0002_notificationpreference.py b/user_notifications/migrations/0002_notificationpreference.py new file mode 100644 index 000000000..473a7c939 --- /dev/null +++ b/user_notifications/migrations/0002_notificationpreference.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.25 on 2025-07-04 11:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('user_notifications', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationPreference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('receive_notifications', models.BooleanField(blank=True, null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/user_notifications/migrations/0003_auto_20250728_1222.py b/user_notifications/migrations/0003_auto_20250728_1222.py new file mode 100644 index 000000000..94fceea40 --- /dev/null +++ b/user_notifications/migrations/0003_auto_20250728_1222.py @@ -0,0 +1,61 @@ +# Generated by Django 3.2.25 on 2025-07-28 12:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.NOTIFICATIONS_NOTIFICATION_MODEL), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('user_notifications', '0002_notificationpreference'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(unique=True)), + ], + ), + migrations.AddField( + model_name='notificationpreference', + name='preferred_language', + field=models.CharField(choices=[('en', 'English'), ('es', 'Spanish'), ('fr', 'French'), ('ar', 'Arabic')], default='en', max_length=5), + ), + migrations.AddField( + model_name='usernotificationtemplate', + name='type', + field=models.CharField(choices=[('signup', 'User Signup'), ('article', 'Article Published'), ('survey', 'Survey Published')], default='User Signup', help_text='Select the type of notification this template is used for.', max_length=20, unique=True), + preserve_default=False, + ), + migrations.CreateModel( + name='NotificationMeta', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_clicked', models.BooleanField(default=False)), + ('notification', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='meta', to=settings.NOTIFICATIONS_NOTIFICATION_MODEL)), + ], + ), + migrations.CreateModel( + name='NotificationLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notification_key', models.CharField(max_length=255)), + ('state', models.CharField(choices=[('sent', 'Sent'), ('failed', 'Failed')], default='sent', max_length=20)), + ('received_at', models.DateTimeField(auto_now_add=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('tags', models.CharField(max_length=255)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='notificationpreference', + name='content_tags', + field=models.ManyToManyField(blank=True, to='user_notifications.NotificationTag'), + ), + ] diff --git a/user_notifications/migrations/0004_notificationlog_notification.py b/user_notifications/migrations/0004_notificationlog_notification.py new file mode 100644 index 000000000..a82eb7da0 --- /dev/null +++ b/user_notifications/migrations/0004_notificationlog_notification.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.25 on 2025-07-31 06:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.NOTIFICATIONS_NOTIFICATION_MODEL), + ('user_notifications', '0003_auto_20250728_1222'), + ] + + operations = [ + migrations.AddField( + model_name='notificationlog', + name='notification', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='logs', to=settings.NOTIFICATIONS_NOTIFICATION_MODEL), + ), + ] diff --git a/user_notifications/migrations/0005_seed_notification_tables.py b/user_notifications/migrations/0005_seed_notification_tables.py new file mode 100644 index 000000000..1454cacf1 --- /dev/null +++ b/user_notifications/migrations/0005_seed_notification_tables.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.25 on 2025-07-31 06:02 + +from django.conf import settings +from django.db import migrations, models +from django.contrib.auth.models import Group +from iogt_users.models import User +import django.db.models.deletion +from user_notifications.models import UserNotificationTemplate + + +def reverse_seed_notification_tables(apps, schema_editor): + Group.objects.filter(name="Notify All Users").delete() + UserNotificationTemplate.objects.all().delete() + + +def seed_notification_tables(apps, schema_editor): + group, created = Group.objects.get_or_create(name="Notify All Users") + all_users = User.objects.all() + group.user_set.set(all_users) # replaces current users with all users + + article_template, _ = UserNotificationTemplate.objects.get_or_create( + type='article', + title='A New Article Has Been Published!', + message='Tap here to read the latest article now.' + ) + + survey_template, _ = UserNotificationTemplate.objects.get_or_create( + type='survey', + title='We Value Your Feedback!', + message='Take a quick survey to share your thoughts.' + ) + + signup_template, _ = UserNotificationTemplate.objects.get_or_create( + type='signup', + title='Welcome Aboard!', + message='Your account has been successfully created.' + ) + + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.NOTIFICATIONS_NOTIFICATION_MODEL), + ('user_notifications', '0004_notificationlog_notification'), + ] + + operations = [ + migrations.RunPython(seed_notification_tables, reverse_seed_notification_tables), + ] diff --git a/user_notifications/migrations/__init__.py b/user_notifications/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/user_notifications/models.py b/user_notifications/models.py new file mode 100644 index 000000000..89bb38327 --- /dev/null +++ b/user_notifications/models.py @@ -0,0 +1,92 @@ +from django.db import models +from wagtail.snippets.models import register_snippet +from wagtail.admin.panels import FieldPanel +from django.utils.timezone import now +from wagtail.fields import RichTextField +from iogt.settings.base import AUTH_USER_MODEL +from django.contrib.auth import get_user_model +from notifications.models import Notification + + +class UserNotificationTemplate(models.Model): + NOTIFICATION_TYPES = [ + ("signup", "User Signup"), + ("article", "Article Published"), + ("survey", "Survey Published"), + # add more types as needed + ] + + type = models.CharField( + max_length=20, + choices=NOTIFICATION_TYPES, + unique=True, # optional: only one active per type + help_text="Select the type of notification this template is used for." + ) + title = models.CharField(max_length=255) + message = RichTextField(features=["bold", "italic", "link", "ul", "ol"]) + active = models.BooleanField(default=True) + updated_at = models.DateTimeField(default=now) + + panels = [ + FieldPanel('title'), + FieldPanel('message'), + FieldPanel('active'), + FieldPanel('type'), + ] + + def __str__(self): + return f"{self.title} ({'active' if self.active else 'inactive'})" + + class Meta: + verbose_name = "User Notification Template" + verbose_name_plural = "User Notification Templates" + + +class NotificationTag(models.Model): + name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(unique=True) + + def __str__(self): + return self.name + + +class NotificationPreference(models.Model): + LANGUAGE_CHOICES = [ + ('en', 'English'), + ('es', 'Spanish'), + ('fr', 'French'), + ('ar', 'Arabic'), + ] + user = models.OneToOneField(AUTH_USER_MODEL, on_delete=models.CASCADE) + receive_notifications = models.BooleanField(null=True, blank=True) # NULL = not chosen yet + preferred_language = models.CharField(max_length=5, choices=LANGUAGE_CHOICES, default='en') + content_tags = models.ManyToManyField(NotificationTag, blank=True) + + def __str__(self): + return f"{self.user.username}'s Notification Preferences" + + +User = get_user_model() + + +class NotificationLog(models.Model): + STATE_CHOICES = [ + ('sent', 'Sent'), + ('failed', 'Failed'), + ] + notification = models.OneToOneField(Notification, null=True, blank=True, on_delete=models.SET_NULL, related_name='logs') + user = models.ForeignKey(User, on_delete=models.CASCADE) + notification_key = models.CharField(max_length=255) # e.g. template slug or key + state = models.CharField(max_length=20, choices=STATE_CHOICES, default='sent') + received_at = models.DateTimeField(auto_now_add=True) + error_message = models.TextField(blank=True, null=True) + tags = models.CharField(max_length=255) + + def __str__(self): + return f"{self.notification_key} → {self.user.username} [{self.state}]" + + +class NotificationMeta(models.Model): + notification = models.OneToOneField(Notification, on_delete=models.CASCADE, related_name='meta') + is_clicked = models.BooleanField(default=False) + diff --git a/user_notifications/tasks.py b/user_notifications/tasks.py new file mode 100644 index 000000000..3016053d8 --- /dev/null +++ b/user_notifications/tasks.py @@ -0,0 +1,93 @@ +from celery import shared_task +from django.contrib.auth import get_user_model +from notifications.signals import notify +from user_notifications.models import UserNotificationTemplate, NotificationPreference +from .models import NotificationLog +from notifications.models import Notification +from user_notifications.models import NotificationMeta +from webpush import send_user_notification +from django.utils.html import strip_tags +User = get_user_model() + + +@shared_task +def send_app_notifications(id, url=None, notification_type=None): + from home.models import Article + from questionnaires.models import Survey + try: + template = UserNotificationTemplate.objects.filter(active=True, type=notification_type).latest("updated_at") + if notification_type in ['signup', 'web_push']: + sender = User.objects.get(id=id) + notify.send( + sender=sender, + recipient=sender, + verb=template.title, + description=template.message + ) + notif_instance = Notification.objects.filter( + recipient=notification_preference.user + ).order_by('-timestamp').first() + NotificationLog.objects.create( + user=sender, + notification_key=template.title, + tags=notification_type, + state="sent", + notification=notif_instance + ) + return + except Exception as e: + return + if notification_type=='article': + sender = Article.objects.get(id=id) + elif notification_type == 'survey': + sender = Survey.objects.get(id=id) + + if notification_type != 'signup': + article_or_survey_tags_id = list(sender.notification_tags.values_list('id', flat=True)) + for notification_preference in NotificationPreference.objects.filter( + receive_notifications=True, + content_tags__in=article_or_survey_tags_id + ).select_related('user').distinct(): + try: + notify.send( + sender=sender, + url=url, + recipient=notification_preference.user, + verb=f"{template.title}", + description=template.message + ) + + notif_instance = Notification.objects.filter( + recipient=notification_preference.user + ).order_by('-timestamp').first() + + NotificationLog.objects.create( + user=notification_preference.user, + notification_key=template.title, + tags=notification_type, + state="sent", + notification=notif_instance + ) + + # 3. Avoid duplicate meta creation + NotificationMeta.objects.get_or_create(notification=notif_instance) + + # 4. Send Web Push + send_user_notification( + user=notification_preference.user, + payload={ + "title": f"{template.title}", + "body": strip_tags(template.message), + "url": url, + "notification_id": notif_instance.id + }, + ttl=1000 + ) + except Exception as e: + NotificationLog.objects.create( + user=notification_preference.user, + notification_key=template.title, + tags=notification_type, + state="failed", + error_message=str(e) + ) \ No newline at end of file diff --git a/user_notifications/templates/user_notifications/notification_dropdown.html b/user_notifications/templates/user_notifications/notification_dropdown.html new file mode 100644 index 000000000..0eeea0ed8 --- /dev/null +++ b/user_notifications/templates/user_notifications/notification_dropdown.html @@ -0,0 +1,63 @@ +{% load clean_html %} +{% for n in notifications %} +{% if n.data.url %} + +
+

+ {{ n.verb }} + {% if not n.is_clicked %}{% endif %} +

+ {{ n.timestamp|timesince }} ago +
+

{{ n.description|strip_html }}

+ {% if n.actor.notification_tags.all %} +

+ Name: + {{n.actor.title}} +

+

+ Topic(s): + {% for tag in n.actor.notification_tags.all %} + {{ tag.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

+ {% endif %} +
+{% else %} +
+
+

+ {{ n.verb }} +

+ {{ n.timestamp|timesince }} ago +
+

+ {{ n.description|strip_html }} +

+ {% if n.actor.notification_tags.all %} +

+ Name: + {{n.actor.title}} +

+

+ Topic(s): + {% for tag in n.actor.notification_tags.all %} + {{ tag.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

+ {% endif %} +
+{% endif %} +{% empty %} +
No notifications yet!
+{% endfor %} +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/user_notifications/templates/user_notifications/notification_list.html b/user_notifications/templates/user_notifications/notification_list.html new file mode 100644 index 000000000..357279d6d --- /dev/null +++ b/user_notifications/templates/user_notifications/notification_list.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block content %} +{% load clean_html %} +
+

All Notifications

+
+
+ {% for n in notifications %} + {% if n.data.url %} + +
+

+ {{ n.verb }} + {% if not n.is_clicked %}{% endif %} +

+ {{ n.timestamp|timesince }} ago +
+

{{ n.description|strip_html }}

+ + {% if n.actor.notification_tags.all %} +

+ Name: + {{n.actor.title}} +

+

+ Topic(s): + {% for tag in n.actor.notification_tags.all %} + {{ tag.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

+ {% endif %} +
+ {% else %} +
+
+

+ {{ n.verb }} +

+ {{ n.timestamp|timesince }} ago +
+

{{ n.description|strip_html }}

+ {% if n.actor.notification_tags.all %} +

+ Name: + {{n.actor.title}} +

+

+ Topic(s): + {% for tag in n.actor.notification_tags.all %} + {{ tag.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

+ {% endif %} +
+ {% endif %} + {% empty %} +
No notifications yet.
+ {% endfor %} +
+ +{% endblock %} +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/user_notifications/tests.py b/user_notifications/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/user_notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/user_notifications/urls.py b/user_notifications/urls.py new file mode 100644 index 000000000..a37d06334 --- /dev/null +++ b/user_notifications/urls.py @@ -0,0 +1,16 @@ +# user_notifications/urls.py +from django.urls import path +from . import views + +app_name = 'user_notifications' + +urlpatterns = [ + path('latest_notifications/', views.latest_notifications, name='latest_notifications'), + path('all/', views.all_notifications, name='all'), + path('mark-all-read/', views.mark_all_read, name='mark_all_read'), + path('mark-selected-read/', views.mark_selected_read, name='mark_selected_read'), + path('toggle-read//', views.toggle_read, name='toggle_read'), + path('unread-count/', views.unread_count, name='unread_count'), + path('mark-clicked//', views.mark_notification_clicked, name='mark_notification_clicked') + +] diff --git a/user_notifications/views.py b/user_notifications/views.py new file mode 100644 index 000000000..3d3fe8f2e --- /dev/null +++ b/user_notifications/views.py @@ -0,0 +1,112 @@ +# user_notifications/views.py +from notifications.models import Notification +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect +from django.views.decorators.csrf import csrf_exempt +from django.http import JsonResponse +import json +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.views.decorators.http import require_POST + +from .models import NotificationMeta, NotificationPreference, NotificationTag + +@login_required +def latest_notifications(request): + notifications = Notification.objects.filter(recipient=request.user).order_by('-timestamp')[:5] + for n in notifications: + print('Notification ID:', n.id) + meta_map = { + meta.notification_id: meta.is_clicked + for meta in NotificationMeta.objects.filter(notification__in=notifications) + } + + for notif in notifications: + notif.is_clicked = meta_map.get(notif.id, False) + + return render(request, 'user_notifications/notification_dropdown.html', { + 'notifications': notifications + }) + +@login_required +def all_notifications(request): + notifications = Notification.objects.filter(recipient=request.user).order_by('-timestamp') + + # Annotate each notification with is_clicked + for n in notifications: + try: + n.is_clicked = n.meta.is_clicked + except NotificationMeta.DoesNotExist: + n.is_clicked = False # default + + return render(request, 'user_notifications/notification_list.html', {'notifications': notifications}) + +@login_required +def mark_all_read(request): + Notification.objects.filter(recipient=request.user, unread=True).update(unread=False) + return redirect('user_notifications:all') + + +@login_required +def mark_selected_read(request): + ids = request.GET.getlist('ids[]') + if not ids: + return JsonResponse({'status': 'error', 'message': 'No IDs provided'}, status=400) + Notification.objects.filter(recipient=request.user, id__in=ids, unread=True).update(unread=False) + return JsonResponse({'status': 'success', 'message': f'{len(ids)} notifications marked as read'}) + +@login_required +def toggle_read(request, pk): + notif = Notification.objects.get(id=pk, recipient=request.user) + notif.unread = not notif.unread + notif.save() + return redirect('user_notifications:all') + +@login_required +def unread_count(request): + count = Notification.objects.filter(recipient=request.user, unread=True).count() + return JsonResponse({'unread_count': count}) + + +@require_POST +@login_required +def save_notification_preference(request): + if request.method == "POST" and request.user.is_authenticated: + data = json.loads(request.body) + choice = data.get("preference") # "yes" or "no" + language = data.get('language', 'en') + tag_ids = data.get('tags', []) + + if choice not in [True, False]: + return JsonResponse({"error": "Invalid choice"}, status=400) + + if isinstance(choice, str): + choice = choice.lower() in ['yes', 'true', '1'] + preference, created = NotificationPreference.objects.update_or_create( + user=request.user, + defaults={ + 'receive_notifications': choice, + 'preferred_language': language, + } + ) + + if tag_ids: + tags = NotificationTag.objects.filter(id__in=tag_ids) + preference.content_tags.set(tags) + else: + preference.content_tags.set(tag_ids) + + return JsonResponse({'status': 'ok'}) + return JsonResponse({'error': 'unauthorized'}, status=403) + +@require_POST +@login_required +def mark_notification_clicked(request, notification_id): + try: + notif = Notification.objects.get(id=notification_id) + meta, _ = NotificationMeta.objects.get_or_create(notification=notif) + meta.is_clicked = True + meta.save() + return JsonResponse({"status": "success"}) + except Notification.DoesNotExist: + return JsonResponse({"error": "Notification not found"}, status=404) diff --git a/user_notifications/wagtail_hooks.py b/user_notifications/wagtail_hooks.py new file mode 100644 index 000000000..2925d2242 --- /dev/null +++ b/user_notifications/wagtail_hooks.py @@ -0,0 +1,100 @@ +from django.utils.html import format_html +from wagtail.contrib.modeladmin.options import ( + ModelAdmin, ModelAdminGroup, modeladmin_register +) +from .models import NotificationLog, NotificationPreference, NotificationTag, UserNotificationTemplate +from admin_notifications.wagtail_hooks import NotificationModelAdmin +from django.conf import settings + +class NotificationTagAdmin(ModelAdmin): + model = NotificationTag + menu_label = "Notification Tags" + menu_icon = "tag" + list_display = ("name", "slug") + + +class NotificationPreferenceAdmin(ModelAdmin): + model = NotificationPreference + menu_label = "Notification Preferences" + menu_icon = "user" + + def get_content_tags(self, obj): + return ", ".join([tag.name for tag in obj.content_tags.all()]) + + get_content_tags.short_description = "Tags" + list_display = ("user", "receive_notifications", "preferred_language", "get_content_tags") + search_fields = ("user__username", "user__email", "preferred_language") + list_filter = ('preferred_language', 'content_tags') + + +class NotificationLogAdmin(ModelAdmin): + model = NotificationLog + menu_label = "Notification Logs" + menu_icon = "list-ul" # Wagtail icon name + menu_order = 200 + list_display = ("notification_key", "user", "state", "get_notification_url", "get_is_clicked", "tags", "received_at") + search_fields = ("notification_key", "user__username", "user__email", "tags") + list_filter = ("state", "tags", "user") + ordering = ["-received_at"] + + def has_add_permission(self, request): + return False + + def has_edit_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def get_is_clicked(self, obj): + try: + return obj.notification.meta.is_clicked + except AttributeError: + return False # or 'N/A' if you prefer + + get_is_clicked.boolean = True # shows a checkmark in admin + get_is_clicked.short_description = 'Is Clicked' + + def get_notification_url(self, obj): + url = obj.notification.data.get("url") if hasattr(obj.notification, "data") else "" + if url: + return format_html('{}', url, url) + return "-" + get_notification_url.short_description = "Target URL" + + + +class UserNotificationTemplateAdmin(ModelAdmin): + model = UserNotificationTemplate + menu_label = "User Notification Template" + menu_icon = "tag" + list_display = ("title", "message", "active", "updated_at") + + +class NotificationsParentGroup(ModelAdminGroup): + menu_label = "Notifications" + menu_icon = "bell" + items = ( + NotificationTagAdmin, + NotificationPreferenceAdmin, + NotificationLogAdmin, + UserNotificationTemplateAdmin + ) + if settings.PUSH_NOTIFICATION: + items += (NotificationModelAdmin,) + + + +# class NotificationPreferenceAdmin(ModelAdmin): +# model = NotificationPreference +# menu_label = "Notification Preferences" +# menu_icon = "bell" # Wagtail icon name +# list_display = ("user", "receive_notifications") +# search_fields = ("user__username", "user__email") + + + + +modeladmin_register(NotificationsParentGroup) +# modeladmin_register(NotificationPreferenceAdmin) +# modeladmin_register(NotificationLogAdmin) \ No newline at end of file