diff --git a/nextcloudappstore/settings/base.py b/nextcloudappstore/settings/base.py index ebdbfcb30f8..23962574ed4 100644 --- a/nextcloudappstore/settings/base.py +++ b/nextcloudappstore/settings/base.py @@ -363,3 +363,11 @@ NEXTCLOUD_INTEGRATIONS_APPROVAL_EMAILS = ["marketing-team@nextcloud.com"] DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + + +# Do not fill in these values here, use the "development.py" or "production.py" files. +ODOO_URL = "" +ODOO_DB = "" +ODOO_USERNAME = "" +ODOO_PASSWORD = "" +ODOO_MAILING_LIST_ID = 0 diff --git a/nextcloudappstore/user/forms.py b/nextcloudappstore/user/forms.py index 7abc5bd7079..2af5b20e645 100644 --- a/nextcloudappstore/user/forms.py +++ b/nextcloudappstore/user/forms.py @@ -11,6 +11,8 @@ from django.forms import CharField, EmailField, PasswordInput from django.utils.translation import gettext_lazy as _ +from .odoo import subscribe_user_to_news + class SignupFormRecaptcha(forms.Form): """integrate a recaptcha field.""" @@ -18,12 +20,24 @@ class SignupFormRecaptcha(forms.Form): captcha = CaptchaField() first_name = CharField(max_length=30, label=_("First name")) last_name = CharField(max_length=30, label=_("Last name")) + subscribe_to_news = forms.BooleanField( + label=_("I would like to receive app developer news and updates from Nextcloud by email (optional)"), + required=False, + initial=False, + ) def signup(self, request, user): user.first_name = self.cleaned_data["first_name"] user.last_name = self.cleaned_data["last_name"] user.save() + # Set the subscription preference on the user's profile + user.profile.subscribe_to_news = self.cleaned_data["subscribe_to_news"] + user.profile.save() + + if self.cleaned_data["subscribe_to_news"]: + subscribe_user_to_news(user) + class DeleteAccountForm(forms.Form): email = EmailField(required=True, label=_("Your email address")) @@ -65,10 +79,25 @@ class AccountForm(forms.ModelForm): "password!" ), ) + subscribe_to_news = forms.BooleanField( + label=_("I would like to receive app developer news and updates from Nextcloud by email (optional)"), + required=False, + ) class Meta: model = get_user_model() - fields = ("first_name", "last_name", "email") + fields = ( + "first_name", + "last_name", + "email", + ) + + def __init__(self, *args, **kwargs): + self.user = kwargs.get("instance", None) + super().__init__(*args, **kwargs) + # Set initial value of subscribe_to_news based on user's profile + if self.user and hasattr(self.user, "profile"): + self.fields["subscribe_to_news"].initial = self.user.profile.subscribe_to_news def clean_email(self): value = self.cleaned_data["email"] diff --git a/nextcloudappstore/user/management/commands/create_missing_profiles.py b/nextcloudappstore/user/management/commands/create_missing_profiles.py new file mode 100644 index 00000000000..404bf7c683c --- /dev/null +++ b/nextcloudappstore/user/management/commands/create_missing_profiles.py @@ -0,0 +1,16 @@ +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand + +from nextcloudappstore.user.models import UserProfile + + +class Command(BaseCommand): + help = "Create missing UserProfile instances for existing users" + + def handle(self, *args, **kwargs): + users_without_profiles = User.objects.filter(profile__isnull=True) + for user in users_without_profiles: + UserProfile.objects.create(user=user, subscribe_to_news=False) + self.stdout.write(f"Created profile for user: {user.username}") + + self.stdout.write("Finished creating missing profiles.") diff --git a/nextcloudappstore/user/migrations/0001_initial.py b/nextcloudappstore/user/migrations/0001_initial.py new file mode 100644 index 00000000000..06f2c0d33f6 --- /dev/null +++ b/nextcloudappstore/user/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.16 on 2024-12-06 11:59 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserProfile", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "subscribe_to_news", + models.BooleanField( + default=True, help_text="User has opted in to receive Nextcloud news and updates." + ), + ), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, related_name="profile", to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + ] diff --git a/nextcloudappstore/user/models.py b/nextcloudappstore/user/models.py index 9d57c559986..6e7b8a0b874 100644 --- a/nextcloudappstore/user/models.py +++ b/nextcloudappstore/user/models.py @@ -1,3 +1,45 @@ -from django.db import models # noqa +from django.contrib.auth.models import User +from django.db import models +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver -# Create your models here. +from .odoo import subscribe_user_to_news, unsubscribe_user_from_news + + +class UserProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") + subscribe_to_news = models.BooleanField( + default=True, help_text="User has opted in to receive Nextcloud news and updates." + ) + + def __str__(self): + return f"Profile of {self.user.username}" + + +# Signal to create a profile for each new user +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + if created: + UserProfile.objects.create(user=instance) + + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + instance.profile.save() + + +# Detect changes to subscribe_to_news and trigger actions +@receiver(pre_save, sender=UserProfile) +def handle_subscription_change(sender, instance, **kwargs): + if instance.pk: # Ensure this is an update, not a creation + # Fetch the old value of subscribe_to_news + old_value = UserProfile.objects.filter(pk=instance.pk).values_list("subscribe_to_news", flat=True).first() + new_value = instance.subscribe_to_news + + if old_value != new_value: + if new_value: + # Logic to subscribe the user + subscribe_user_to_news(instance.user) + else: + # Logic to unsubscribe the user + unsubscribe_user_from_news(instance.user) diff --git a/nextcloudappstore/user/odoo.py b/nextcloudappstore/user/odoo.py new file mode 100644 index 00000000000..b52c746ca91 --- /dev/null +++ b/nextcloudappstore/user/odoo.py @@ -0,0 +1,162 @@ +from defusedxml import xmlrpc +from django.conf import settings + +xmlrpc.monkey_patch() + +import xmlrpc.client # noqa: E402 # nosec + + +def authenticate(): + common = xmlrpc.client.ServerProxy(f"{settings.ODOO_URL}/xmlrpc/2/common") + uid = common.authenticate(settings.ODOO_DB, settings.ODOO_USERNAME, settings.ODOO_PASSWORD, {}) + if not uid: + raise Exception("Authentication failed with Odoo") + return uid + + +def is_odoo_config_valid(): + """Validate Odoo configuration settings.""" + required_fields = [ + settings.ODOO_URL, + settings.ODOO_DB, + settings.ODOO_USERNAME, + settings.ODOO_PASSWORD, + settings.ODOO_MAILING_LIST_ID, + ] + return all(required_fields) + + +def subscribe_user_to_news(user): + if not is_odoo_config_valid(): + print("Odoo configuration is invalid. Skipping subscription.") + return + + uid = authenticate() + models = xmlrpc.client.ServerProxy(f"{settings.ODOO_URL}/xmlrpc/2/object") + + # Check if the contact already exists in Odoo + contact_ids = models.execute_kw( + settings.ODOO_DB, + uid, + settings.ODOO_PASSWORD, + "mailing.contact", + "search", + [[("email", "=", user["email"])]], + ) + + if not contact_ids: + # Create a new contact if it doesn't exist + contact_data = { + "name": user.get("name", user["email"]), # Use name if provided, fallback to email + "email": user["email"], + } + contact_id = models.execute_kw( + settings.ODOO_DB, + uid, + settings.ODOO_PASSWORD, + "mailing.contact", + "create", + [contact_data], + ) + print(f"New user created with ID {contact_id}") + else: + contact_id = contact_ids[0] + print(f"User exists with ID {contact_id}") + + # Check if the user is subscribed to the specific mailing list + subscription_ids = models.execute_kw( + settings.ODOO_DB, + uid, + settings.ODOO_PASSWORD, + "mailing.contact.subscription", + "search", + [ + [ + ("contact_id", "=", contact_id), + ("list_id", "=", settings.ODOO_MAILING_LIST_ID), + ] + ], + ) + + if not subscription_ids: + # Subscribe the user to the mailing list + subscription_data = { + "contact_id": contact_id, + "list_id": settings.ODOO_MAILING_LIST_ID, + "opt_out": False, + } + subscription_id = models.execute_kw( + settings.ODOO_DB, + uid, + settings.ODOO_PASSWORD, + "mailing.contact.subscription", + "create", + [subscription_data], + ) + print(f"User subscribed to mailing list with subscription ID {subscription_id}") + else: + # Update the existing subscription to ensure opt_out is False + models.execute_kw( + settings.ODOO_DB, + uid, + settings.ODOO_PASSWORD, + "mailing.contact.subscription", + "write", + [subscription_ids, {"opt_out": False}], + ) + print(f"User's subscription updated to opt-in for mailing list {settings.ODOO_MAILING_LIST_ID}") + + +def unsubscribe_user_from_news(user): + if not is_odoo_config_valid(): + print("Odoo configuration is invalid. Skipping subscription.") + return + uid = authenticate() + models = xmlrpc.client.ServerProxy(f"{settings.ODOO_URL}/xmlrpc/2/object") + + # Find the contact by email + contact_ids = models.execute_kw( + settings.ODOO_DB, + uid, + settings.ODOO_PASSWORD, + "mailing.contact", + "search", + [[("email", "=", user["email"])]], + ) + + if not contact_ids: + print(f"No contact found for email {user['email']} to unsubscribe") + return + + contact_id = contact_ids[0] + + # Find the subscription for the specific mailing list + subscription_ids = models.execute_kw( + settings.ODOO_DB, + uid, + settings.ODOO_PASSWORD, + "mailing.contact.subscription", + "search", + [ + [ + ("contact_id", "=", contact_id), + ("list_id", "=", settings.ODOO_MAILING_LIST_ID), + ] + ], + ) + + if not subscription_ids: + print(f"No subscription found for contact {contact_id} to mailing list {settings.ODOO_MAILING_LIST_ID}") + return + + # Update the subscription to set opt_out to True + models.execute_kw( + settings.ODOO_DB, + uid, + settings.ODOO_PASSWORD, + "mailing.contact.subscription", + "write", + [subscription_ids, {"opt_out": True}], + ) + + print(f"User {user['email']} has been unsubscribed from mailing list {settings.ODOO_MAILING_LIST_ID}") diff --git a/nextcloudappstore/user/views.py b/nextcloudappstore/user/views.py index 66675267baa..dbecdfff388 100644 --- a/nextcloudappstore/user/views.py +++ b/nextcloudappstore/user/views.py @@ -88,7 +88,7 @@ def post(self, request, *args, **kwargs): class AccountView(LoginRequiredMixin, UpdateView): - """Display and allow changing of the user's name.""" + """Display and allow changing of the user's name and subscription.""" template_name = "user/account.html" template_name_suffix = "" @@ -113,6 +113,11 @@ def form_valid(self, form): if email.email != form.cleaned_data["email"]: email.email = form.cleaned_data["email"] email.save(update_fields=["email"]) + + # Update subscription preference + self.request.user.profile.subscribe_to_news = form.cleaned_data["subscribe_to_news"] + self.request.user.profile.save() + messages.success(self.request, "Account details saved.") self.request.session["account_update_failed_count"] = 0 return super().form_valid(form) diff --git a/poetry.lock b/poetry.lock index 16e5c6587bf..8f40ae86fb8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alabaster" @@ -412,6 +412,17 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "django" version = "4.2.16" @@ -1042,16 +1053,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -1524,7 +1525,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -2088,4 +2088,4 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c1fdc5188f584f031531f86462cb0c56d31886bb4c436a1da1653c05c544fa99" +content-hash = "fa225bc0724d201f3b13cee9ffaf0ab3b8ccfbf159f14918e7aac0f7580ab7ee" diff --git a/pyproject.toml b/pyproject.toml index dd1ac39d810..a86975f7f72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ redis = { version = "^5.0.0", extras = [ ] } email-validator = "^2.1.0-post.0" jschon = "^0.11.1" +defusedxml = "^0.7.1" [tool.poetry.group.docs] optional = true @@ -85,6 +86,10 @@ lint.extend-per-file-ignores."nextcloudappstore/core/migrations/**/*.py" = [ "E501", "F401", ] +lint.extend-per-file-ignores."nextcloudappstore/user/odoo.py" = [ + "E402", +] + lint.extend-per-file-ignores."scripts/development/settings/**/*.py" = [ "F403", "F405",