diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02adaa2..e96b01a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,13 +6,10 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 - with: - python-version: '3.10' - name: Install requirements - run: pip install flake8 pycodestyle black[python2]==21.12b0 click==8.0.4 isort - - name: Check syntax and complexity - run: | - flake8 . --count --select=C901,E901,E999,F401,F821,F822,F823 --show-source --statistics + run: pip install flake8 pycodestyle black isort + - name: Check syntax + run: flake8 . --count --select=C901,E721,E901,E999,F401,F821,F822,F823,F841 --show-source --statistics --extend-exclude ckan - name: Check codestyle run: | isort --diff --check ckanext/ @@ -22,16 +19,17 @@ jobs: needs: lint strategy: matrix: - ckan-version: [2.8, 2.7] + ckan-version: ["2.10", "2.11"] fail-fast: false name: CKAN ${{ matrix.ckan-version }} runs-on: ubuntu-latest container: - image: openknowledge/ckan-dev:${{ matrix.ckan-version }} + image: ckan/ckan-dev:${{ matrix.ckan-version }} + options: --user root services: solr: - image: ckan/ckan-solr:${{ matrix.ckan-version }} + image: ckan/ckan-solr:${{ matrix.ckan-version }}-solr9 postgres: image: ckan/ckan-postgres-dev:${{ matrix.ckan-version }} env: @@ -64,10 +62,11 @@ jobs: - name: Setup extension run: | - paster --plugin=ckan db init -c test.ini + ckan -c test.ini db init + ckan -c test.ini db pending-migrations --apply - name: Run tests - run: nosetests --ckan --nologcapture --with-pylons=test.ini --with-coverage --cover-package=ckanext.subscribe --cover-inclusive --cover-erase --cover-tests ckanext/subscribe + run: pytest --ckan-ini=test.ini --cov=ckanext.subscribe --cov-report=term-missing --cov-append --disable-warnings ckanext/subscribe/tests - name: Coveralls run: | diff --git a/MANIFEST.in b/MANIFEST.in index 4dae70b..58610ba 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,4 @@ include README.rst include LICENSE include requirements.txt recursive-include ckanext/subscribe *.html *.json *.js *.less *.css *.mo +recursive-include ckanext/subscribe/migration *.ini *.py *.mako diff --git a/ckanext/__init__.py b/ckanext/__init__.py index 35ee891..6d83202 100644 --- a/ckanext/__init__.py +++ b/ckanext/__init__.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # this is a namespace package try: import pkg_resources diff --git a/ckanext/subscribe/action.py b/ckanext/subscribe/action.py index c95f24f..0a6b706 100644 --- a/ckanext/subscribe/action.py +++ b/ckanext/subscribe/action.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - import datetime import logging @@ -79,7 +77,7 @@ def subscribe_signup(context, data_dict): raise tk.ValidationError("Invalid reCAPTCHA. Please try again.") log.info("reCAPTCHA verification passed.") - _check_access(u"subscribe_signup", context, data_dict) + _check_access("subscribe_signup", context, data_dict) data = { "email": data_dict["email"], @@ -130,7 +128,7 @@ def subscribe_signup(context, data_dict): try: email_verification.send_request_email(subscription) except MailerException as exc: - log.error("Could not email manage code: {}".format(exc)) + log.error(f"Could not email manage code: {exc}") raise subscription_dict = dictization.dictize_subscription(subscription, context) @@ -150,7 +148,7 @@ def subscribe_verify(context, data_dict): model = context["model"] user = context["user"] - _check_access(u"subscribe_verify", context, data_dict) + _check_access("subscribe_verify", context, data_dict) code = p.toolkit.get_or_bust(data_dict, "code") subscription = ( @@ -194,7 +192,7 @@ def subscribe_update(context, data_dict): """ model = context["model"] - _check_access(u"subscribe_update", context, data_dict) + _check_access("subscribe_update", context, data_dict) id_ = p.toolkit.get_or_bust(data_dict, "id") subscription = model.Session.query(Subscription).get(id_) @@ -218,7 +216,7 @@ def subscribe_list_subscriptions(context, data_dict): """ model = context["model"] - _check_access(u"subscribe_list_subscriptions", context, data_dict) + _check_access("subscribe_list_subscriptions", context, data_dict) email = p.toolkit.get_or_bust(data_dict, "email") subscription_objs = ( @@ -235,19 +233,17 @@ def subscribe_list_subscriptions(context, data_dict): subscription["object_name"] = package.name subscription["object_title"] = package.title subscription["object_link"] = p.toolkit.url_for( - controller="package", action="read", id=package.name + "dataset.read", id=package.name ) elif group and not group.is_organization: subscription["object_name"] = group.name subscription["object_title"] = group.title - subscription["object_link"] = p.toolkit.url_for( - controller="group", action="read", id=group.name - ) + subscription["object_link"] = p.toolkit.url_for("group.read", id=group.name) elif group and group.is_organization: subscription["object_name"] = group.name subscription["object_title"] = group.title subscription["object_link"] = p.toolkit.url_for( - controller="organization", action="read", id=group.name + "organization.read", id=group.name ) subscriptions.append(subscription) return subscriptions @@ -272,7 +268,7 @@ def subscribe_unsubscribe(context, data_dict): """ model = context["model"] - _check_access(u"subscribe_unsubscribe", context, data_dict) + _check_access("subscribe_unsubscribe", context, data_dict) data = {"email": p.toolkit.get_or_bust(data_dict, "email"), "user": context["user"]} if data_dict.get("dataset_id"): @@ -315,7 +311,7 @@ def subscribe_unsubscribe_all(context, data_dict): """ model = context["model"] - _check_access(u"subscribe_unsubscribe_all", context, data_dict) + _check_access("subscribe_unsubscribe_all", context, data_dict) data = {"email": p.toolkit.get_or_bust(data_dict, "email"), "user": context["user"]} @@ -340,7 +336,7 @@ def subscribe_request_manage_code(context, data_dict): """ model = context["model"] - _check_access(u"subscribe_request_manage_code", context, data_dict) + _check_access("subscribe_request_manage_code", context, data_dict) email = data_dict["email"] @@ -356,7 +352,7 @@ def subscribe_request_manage_code(context, data_dict): try: email_auth.send_manage_email(manage_code, email=email) except MailerException as exc: - log.error("Could not email manage code: {}".format(exc)) + log.error(f"Could not email manage code: {exc}") raise return None diff --git a/ckanext/subscribe/fanstatic/subscribe.css b/ckanext/subscribe/assets/css/subscribe.css similarity index 100% rename from ckanext/subscribe/fanstatic/subscribe.css rename to ckanext/subscribe/assets/css/subscribe.css diff --git a/ckanext/subscribe/assets/webassets.yml b/ckanext/subscribe/assets/webassets.yml new file mode 100644 index 0000000..fa1a616 --- /dev/null +++ b/ckanext/subscribe/assets/webassets.yml @@ -0,0 +1,4 @@ +subscribe/css/subscribe: + output: subscribe/css/subscribe.css + contents: + - css/subscribe.css \ No newline at end of file diff --git a/ckanext/subscribe/auth.py b/ckanext/subscribe/auth.py index afa6ef1..c681e05 100644 --- a/ckanext/subscribe/auth.py +++ b/ckanext/subscribe/auth.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - from ckan.plugins.toolkit import _, auth_allow_anonymous_access, check_access @@ -19,7 +17,7 @@ def subscribe_signup(context, data_dict): group = model.Group.get(group_id) check_access("group_show", context, {"id": group.id}) else: - return {"success": False, "msg": _(u"No object specified")} + return {"success": False, "msg": _("No object specified")} if ( skip_verification @@ -27,7 +25,7 @@ def subscribe_signup(context, data_dict): and skip_verification not in ("false", "f", "no", "n", "0") ): # sysadmins only - return {"success": False, "msg": _(u"Not authorized to skip verification")} + return {"success": False, "msg": _("Not authorized to skip verification")} return {"success": True} diff --git a/ckanext/subscribe/blueprints.py b/ckanext/subscribe/blueprints.py new file mode 100644 index 0000000..263d536 --- /dev/null +++ b/ckanext/subscribe/blueprints.py @@ -0,0 +1,383 @@ +import re + +import ckan.lib.helpers as h +from ckan import model +from ckan.common import g +from ckan.lib.mailer import MailerException +from ckan.plugins.toolkit import ( + ObjectNotFound, + ValidationError, + _, + abort, + config, + get_action, + redirect_to, + render, + request, +) +from flask import Blueprint + +from ckanext.subscribe import email_auth +from ckanext.subscribe import model as subscribe_model + +log = __import__("logging").getLogger(__name__) + +subscribe_blueprint = Blueprint("subscribe", __name__, url_prefix="/subscribe") + + +def _redirect_back_to_subscribe_page(object_name, object_type): + if object_type == "dataset": + return redirect_to("dataset.read", id=object_name) + elif object_type == "group": + return redirect_to("group.read", id=object_name) + elif object_type == "organization": + return redirect_to("organization.read", id=object_name) + else: + return redirect_to("home.index") + + +def _redirect_back_to_subscribe_page_from_request(data_dict): + if data_dict.get("dataset_id"): + dataset_obj = model.Package.get(data_dict["dataset_id"]) + return redirect_to( + "dataset.read", + id=dataset_obj.name if dataset_obj else data_dict["dataset_id"], + ) + elif data_dict.get("group_id"): + group_obj = model.Group.get(data_dict["group_id"]) + group_type = ( + "organization" if group_obj and group_obj.is_organization else "group" + ) + return redirect_to( + f"{group_type}.read", + id=group_obj.name if group_obj else data_dict["group_id"], + ) + else: + return redirect_to("home.index") + + +def _request_manage_code_form(): + return redirect_to( + "subscribe.request_manage_code", + ) + + +def signup(): + # validate inputs + email = request.form.get("email") + if not email: + abort(400, _("No email address supplied")) + email = email.strip() + # pattern from https://html.spec.whatwg.org/#e-mail-state-(type=email) + email_re = ( + r"^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9]" + r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9]" + r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + ) + if not re.match(email_re, email): + abort(400, _("Email supplied is invalid")) + + # create subscription + data_dict = { + "email": email, + "dataset_id": request.form.get("dataset"), + "group_id": request.form.get("group"), + "organization_id": request.form.get("organization"), + "g_recaptcha_response": request.form.get("g-recaptcha-response"), + } + context = { + "model": model, + "session": model.Session, + "user": g.user, + "auth_user_obj": g.userobj, + } + try: + subscription = get_action("subscribe_signup")(context, data_dict) + except ValidationError as err: + error_messages = [] + for key_ignored in ("message", "__before", "dataset_id", "group_id"): + if key_ignored in err.error_dict: + error_messages.extend(err.error_dict.pop(key_ignored)) + if err.error_dict: + error_messages.append(repr(err.error_dict)) + h.flash_error(_(f"Error subscribing: {'; '.join(error_messages)}")) + return _redirect_back_to_subscribe_page_from_request(data_dict) + except MailerException: + h.flash_error( + _("Error sending email - please contact an " "administrator for help") + ) + return _redirect_back_to_subscribe_page_from_request(data_dict) + else: + h.flash_success( + _( + "Subscription requested. Please confirm, by clicking in the " + "link in the email just sent to you" + ) + ) + return _redirect_back_to_subscribe_page( + subscription["object_name"], subscription["object_type"] + ) + + +def verify_subscription(): + data_dict = {"code": request.params.get("code")} + context = { + "model": model, + "session": model.Session, + "user": g.user, + "auth_user_obj": g.userobj, + } + + try: + subscription = get_action("subscribe_verify")(context, data_dict) + except ValidationError as err: + h.flash_error(_(f"Error subscribing: {err.error_dict['message']}")) + return redirect_to("home.index") + + h.flash_success(_("Subscription confirmed")) + code = email_auth.create_code(subscription["email"]) + + return redirect_to( + "subscribe.manage", + code=code, + ) + + +def manage(): + code = request.params.get("code") + if not code: + h.flash_error("Code not supplied") + log.debug("No code supplied") + return _request_manage_code_form() + try: + email = email_auth.authenticate_with_code(code) + except ValueError as exp: + h.flash_error(f"Code is invalid: {exp}") + log.debug(f"Code is invalid: {exp}") + return _request_manage_code_form() + + # user has done auth, but it's an email rather than a ckan user, so + # use site_user + site_user = get_action("get_site_user")({"model": model, "ignore_auth": True}, {}) + context = { + "model": model, + "user": site_user["name"], + } + subscriptions = get_action("subscribe_list_subscriptions")( + context, {"email": email} + ) + frequency_options = [ + dict( + text=f.name.lower().capitalize().replace("Immediate", "Immediately"), + value=f.name, + ) + for f in sorted(subscribe_model.Frequency, key=lambda x: x.value) + ] + return render( + "subscribe/manage.html", + extra_vars={ + "email": email, + "code": code, + "subscriptions": subscriptions, + "frequency_options": frequency_options, + }, + ) + + +def update(): + code = request.form.get("code") + if not code: + h.flash_error("Code not supplied") + log.debug("No code supplied") + return _request_manage_code_form() + try: + email = email_auth.authenticate_with_code(code) + except ValueError as exp: + h.flash_error(f"Code is invalid: {exp}") + log.debug(f"Code is invalid: {exp}") + return _request_manage_code_form() + + subscription_id = request.form.get("id") + if not subscription_id: + abort(400, _("No id supplied")) + subscription = model.Session.query(subscribe_model.Subscription).get( + subscription_id + ) + if not subscription: + abort(404, _("That subscription ID does not exist.")) + if subscription.email != email: + h.flash_error("Code is invalid for that subscription") + log.debug("Code is invalid for that subscription") + return _request_manage_code_form() + + frequency = request.form.get("frequency") + if not frequency: + abort(400, _("No frequency supplied")) + + # user has done auth, but it's an email rather than a ckan user, so + # use site_user + site_user = get_action("get_site_user")({"model": model, "ignore_auth": True}, {}) + context = { + "model": model, + "session": model.Session, + "user": site_user["name"], + } + data_dict = { + "id": subscription_id, + "frequency": frequency, + } + try: + get_action("subscribe_update")(context, data_dict) + except ValidationError as err: + h.flash_error(_(f"Error updating subscription: {err.error_dict['message']}")) + else: + h.flash_success(_("Subscription updated")) + + return redirect_to( + "subscribe.manage", + code=code, + ) + + +def unsubscribe(): + # allow a GET or POST to do this, so that we can trigger it from a link + # in an email or a web form + code = request.params.get("code") + if not code: + h.flash_error("Code not supplied") + log.debug("No code supplied") + return _request_manage_code_form() + try: + email = email_auth.authenticate_with_code(code) + except ValueError as exp: + h.flash_error(f"Code is invalid: {exp}") + log.debug(f"Code is invalid: {exp}") + return _request_manage_code_form() + + # user has done auth, but it's an email rather than a ckan user, so + # use site_user + site_user = get_action("get_site_user")({"model": model, "ignore_auth": True}, {}) + context = { + "model": model, + "user": site_user["name"], + } + data_dict = { + "email": email, + "dataset_id": request.params.get("dataset"), + "group_id": request.params.get("group"), + "organization_id": request.params.get("organization"), + } + try: + object_name, object_type = get_action("subscribe_unsubscribe")( + context, data_dict + ) + except ValidationError as err: + error_messages = [] + for key_ignored in ("message", "__before", "dataset_id", "group_id"): + if key_ignored in err.error_dict: + error_messages.extend(err.error_dict.pop(key_ignored)) + if err.error_dict: + error_messages.append(repr(err.error_dict)) + h.flash_error(_(f"Error unsubscribing: {'; '.join(error_messages)}")) + except ObjectNotFound as err: + h.flash_error(_(f"Error unsubscribing: {err}")) + else: + h.flash_success(_(f"You are no longer subscribed to this {object_type}")) + return _redirect_back_to_subscribe_page(object_name, object_type) + return _redirect_back_to_subscribe_page_from_request(data_dict) + + +def unsubscribe_all(): + # allow a GET or POST to do this, so that we can trigger it from a link + # in an email or a web form + code = request.params.get("code") + if not code: + h.flash_error("Code not supplied") + log.debug("No code supplied") + return _request_manage_code_form() + try: + email = email_auth.authenticate_with_code(code) + except ValueError as exp: + h.flash_error(f"Code is invalid: {exp}") + log.debug(f"Code is invalid: {exp}") + return _request_manage_code_form() + + # user has done auth, but it's an email rather than a ckan user, so + # use site_user + site_user = get_action("get_site_user")({"model": model, "ignore_auth": True}, {}) + context = { + "model": model, + "user": site_user["name"], + } + data_dict = { + "email": email, + } + try: + get_action("subscribe_unsubscribe_all")(context, data_dict) + except ValidationError as err: + error_messages = [] + for key_ignored in ("message", "__before"): + if key_ignored in err.error_dict: + error_messages.extend(err.error_dict.pop(key_ignored)) + if err.error_dict: + error_messages.append(repr(err.error_dict)) + h.flash_error(_(f"Error unsubscribing: {'; '.join(error_messages)}")) + except ObjectNotFound as err: + h.flash_error(_(f"Error unsubscribing: {err}")) + else: + h.flash_success( + _( + f"You are no longer subscribed to notifications from " + f"{config.get('ckan.site_title')}" + ) + ) + return redirect_to("home.index") + return redirect_to( + "subscribe.manage", + code=code, + ) + + +def request_manage_code(): + email = request.form.get("email") + if not email: + return render("subscribe/request_manage_code.html", extra_vars={}) + + context = { + "model": model, + } + try: + get_action("subscribe_request_manage_code")(context, dict(email=email)) + except ValidationError as err: + error_messages = [] + for key_ignored in ("message", "__before"): + if key_ignored in err.error_dict: + error_messages.extend(err.error_dict.pop(key_ignored)) + if err.error_dict: + error_messages.append(repr(err.error_dict)) + h.flash_error(_(f"Error requesting code: {'; '.join(error_messages)}")) + except ObjectNotFound as err: + h.flash_error(_(f"Error requesting code: {err}")) + except MailerException: + h.flash_error( + _("Error sending email - please contact an " "administrator for help") + ) + else: + h.flash_success(_(f"An access link has been emailed to: {email}")) + return redirect_to("home.index") + return render("subscribe/request_manage_code.html", extra_vars={"email": email}) + + +subscribe_blueprint.add_url_rule("/signup", view_func=signup, methods=["POST"]) +subscribe_blueprint.add_url_rule("/verify", view_func=verify_subscription) +subscribe_blueprint.add_url_rule("/manage", view_func=manage) +subscribe_blueprint.add_url_rule("/update", view_func=update, methods=["POST"]) +subscribe_blueprint.add_url_rule( + "/unsubscribe", view_func=unsubscribe, methods=["POST", "GET"] +) +subscribe_blueprint.add_url_rule( + "/unsubscribe-all", view_func=unsubscribe_all, methods=["POST", "GET"] +) +subscribe_blueprint.add_url_rule( + "/request_manage_code", view_func=request_manage_code, methods=["POST", "GET"] +) diff --git a/ckanext/subscribe/controller.py b/ckanext/subscribe/controller.py deleted file mode 100644 index 547b3df..0000000 --- a/ckanext/subscribe/controller.py +++ /dev/null @@ -1,387 +0,0 @@ -# encoding: utf-8 - -import re - -import ckan.lib.helpers as h -from ckan import model -from ckan.common import g -from ckan.lib.mailer import MailerException -from ckan.plugins.toolkit import ( - BaseController, - ObjectNotFound, - ValidationError, - _, - abort, - config, - get_action, - redirect_to, - render, - request, -) - -from ckanext.subscribe import email_auth -from ckanext.subscribe import model as subscribe_model - -log = __import__("logging").getLogger(__name__) - - -class SubscribeController(BaseController): - def signup(self): - # validate inputs - email = request.POST.get("email") - if not email: - abort(400, _(u"No email address supplied")) - email = email.strip() - # pattern from https://html.spec.whatwg.org/#e-mail-state-(type=email) - email_re = ( - r"^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9]" - r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9]" - r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" - ) - if not re.match(email_re, email): - abort(400, _(u"Email supplied is invalid")) - - # create subscription - data_dict = { - "email": email, - "dataset_id": request.POST.get("dataset"), - "group_id": request.POST.get("group"), - "organization_id": request.POST.get("organization"), - "g_recaptcha_response": request.POST.get("g-recaptcha-response"), - } - context = { - u"model": model, - u"session": model.Session, - u"user": g.user, - u"auth_user_obj": g.userobj, - } - try: - subscription = get_action(u"subscribe_signup")(context, data_dict) - except ValidationError as err: - error_messages = [] - for key_ignored in ("message", "__before", "dataset_id", "group_id"): - if key_ignored in err.error_dict: - error_messages.extend(err.error_dict.pop(key_ignored)) - if err.error_dict: - error_messages.append(repr(err.error_dict)) - h.flash_error(_("Error subscribing: {}".format("; ".join(error_messages)))) - return self._redirect_back_to_subscribe_page_from_request(data_dict) - except MailerException: - h.flash_error( - _("Error sending email - please contact an " "administrator for help") - ) - return self._redirect_back_to_subscribe_page_from_request(data_dict) - else: - h.flash_success( - _( - "Subscription requested. Please confirm, by clicking in the " - "link in the email just sent to you" - ) - ) - return self._redirect_back_to_subscribe_page( - subscription["object_name"], subscription["object_type"] - ) - - def verify_subscription(self): - data_dict = {"code": request.params.get("code")} - context = { - u"model": model, - u"session": model.Session, - u"user": g.user, - u"auth_user_obj": g.userobj, - } - - try: - subscription = get_action(u"subscribe_verify")(context, data_dict) - except ValidationError as err: - h.flash_error(_("Error subscribing: {}".format(err.error_dict["message"]))) - return redirect_to("home") - - h.flash_success(_("Subscription confirmed")) - code = email_auth.create_code(subscription["email"]) - - return redirect_to( - controller="ckanext.subscribe.controller:SubscribeController", - action="manage", - code=code, - ) - - def manage(self): - code = request.params.get("code") - if not code: - h.flash_error("Code not supplied") - log.debug("No code supplied") - return self._request_manage_code_form() - try: - email = email_auth.authenticate_with_code(code) - except ValueError as exp: - h.flash_error("Code is invalid: {}".format(exp)) - log.debug("Code is invalid: {}".format(exp)) - return self._request_manage_code_form() - - # user has done auth, but it's an email rather than a ckan user, so - # use site_user - site_user = get_action("get_site_user")( - {"model": model, "ignore_auth": True}, {} - ) - context = { - u"model": model, - u"user": site_user["name"], - } - subscriptions = get_action(u"subscribe_list_subscriptions")( - context, {"email": email} - ) - frequency_options = [ - dict( - text=f.name.lower().capitalize().replace("Immediate", "Immediately"), - value=f.name, - ) - for f in sorted(subscribe_model.Frequency, key=lambda x: x.value) - ] - return render( - u"subscribe/manage.html", - extra_vars={ - "email": email, - "code": code, - "subscriptions": subscriptions, - "frequency_options": frequency_options, - }, - ) - - def update(self): - code = request.POST.get("code") - if not code: - h.flash_error("Code not supplied") - log.debug("No code supplied") - return self._request_manage_code_form() - try: - email = email_auth.authenticate_with_code(code) - except ValueError as exp: - h.flash_error("Code is invalid: {}".format(exp)) - log.debug("Code is invalid: {}".format(exp)) - return self._request_manage_code_form() - - subscription_id = request.POST.get("id") - if not subscription_id: - abort(400, _(u"No id supplied")) - subscription = model.Session.query(subscribe_model.Subscription).get( - subscription_id - ) - if not subscription: - abort(404, _(u"That subscription ID does not exist.")) - if subscription.email != email: - h.flash_error("Code is invalid for that subscription") - log.debug("Code is invalid for that subscription") - return self._request_manage_code_form() - - frequency = request.POST.get("frequency") - if not frequency: - abort(400, _(u"No frequency supplied")) - - # user has done auth, but it's an email rather than a ckan user, so - # use site_user - site_user = get_action("get_site_user")( - {"model": model, "ignore_auth": True}, {} - ) - context = { - u"model": model, - u"session": model.Session, - u"user": site_user["name"], - } - data_dict = { - "id": subscription_id, - "frequency": frequency, - } - try: - get_action(u"subscribe_update")(context, data_dict) - except ValidationError as err: - h.flash_error( - _("Error updating subscription: {}".format(err.error_dict["message"])) - ) - else: - h.flash_success(_("Subscription updated")) - - return redirect_to( - controller="ckanext.subscribe.controller:SubscribeController", - action="manage", - code=code, - ) - - def unsubscribe(self): - # allow a GET or POST to do this, so that we can trigger it from a link - # in an email or a web form - code = request.params.get("code") - if not code: - h.flash_error("Code not supplied") - log.debug("No code supplied") - return self._request_manage_code_form() - try: - email = email_auth.authenticate_with_code(code) - except ValueError as exp: - h.flash_error("Code is invalid: {}".format(exp)) - log.debug("Code is invalid: {}".format(exp)) - return self._request_manage_code_form() - - # user has done auth, but it's an email rather than a ckan user, so - # use site_user - site_user = get_action("get_site_user")( - {"model": model, "ignore_auth": True}, {} - ) - context = { - u"model": model, - u"user": site_user["name"], - } - data_dict = { - "email": email, - "dataset_id": request.params.get("dataset"), - "group_id": request.params.get("group"), - "organization_id": request.params.get("organization"), - } - try: - object_name, object_type = get_action(u"subscribe_unsubscribe")( - context, data_dict - ) - except ValidationError as err: - error_messages = [] - for key_ignored in ("message", "__before", "dataset_id", "group_id"): - if key_ignored in err.error_dict: - error_messages.extend(err.error_dict.pop(key_ignored)) - if err.error_dict: - error_messages.append(repr(err.error_dict)) - h.flash_error( - _("Error unsubscribing: {}".format("; ".join(error_messages))) - ) - except ObjectNotFound as err: - h.flash_error(_("Error unsubscribing: {}".format(err))) - else: - h.flash_success( - _("You are no longer subscribed to this {}".format(object_type)) - ) - return self._redirect_back_to_subscribe_page(object_name, object_type) - return self._redirect_back_to_subscribe_page_from_request(data_dict) - - def unsubscribe_all(self): - # allow a GET or POST to do this, so that we can trigger it from a link - # in an email or a web form - code = request.params.get("code") - if not code: - h.flash_error("Code not supplied") - log.debug("No code supplied") - return self._request_manage_code_form() - try: - email = email_auth.authenticate_with_code(code) - except ValueError as exp: - h.flash_error("Code is invalid: {}".format(exp)) - log.debug("Code is invalid: {}".format(exp)) - return self._request_manage_code_form() - - # user has done auth, but it's an email rather than a ckan user, so - # use site_user - site_user = get_action("get_site_user")( - {"model": model, "ignore_auth": True}, {} - ) - context = { - u"model": model, - u"user": site_user["name"], - } - data_dict = { - "email": email, - } - try: - get_action(u"subscribe_unsubscribe_all")(context, data_dict) - except ValidationError as err: - error_messages = [] - for key_ignored in ("message", "__before"): - if key_ignored in err.error_dict: - error_messages.extend(err.error_dict.pop(key_ignored)) - if err.error_dict: - error_messages.append(repr(err.error_dict)) - h.flash_error( - _("Error unsubscribing: {}".format("; ".join(error_messages))) - ) - except ObjectNotFound as err: - h.flash_error(_("Error unsubscribing: {}".format(err))) - else: - h.flash_success( - _( - "You are no longer subscribed to notifications from {}".format( - config.get("ckan.site_title") - ) - ) - ) - return redirect_to("home") - return redirect_to( - controller="ckanext.subscribe.controller:SubscribeController", - action="manage", - code=code, - ) - - def _redirect_back_to_subscribe_page(self, object_name, object_type): - if object_type == "dataset": - return redirect_to(controller="package", action="read", id=object_name) - elif object_type == "group": - return redirect_to(controller="group", action="read", id=object_name) - elif object_type == "organization": - return redirect_to(controller="organization", action="read", id=object_name) - else: - return redirect_to("home") - - def _redirect_back_to_subscribe_page_from_request(self, data_dict): - if data_dict.get("dataset_id"): - dataset_obj = model.Package.get(data_dict["dataset_id"]) - return redirect_to( - controller="package", - action="read", - id=dataset_obj.name if dataset_obj else data_dict["dataset_id"], - ) - elif data_dict.get("group_id"): - group_obj = model.Group.get(data_dict["group_id"]) - controller = ( - "organization" if group_obj and group_obj.is_organization else "group" - ) - return redirect_to( - controller=controller, - action="read", - id=group_obj.name if group_obj else data_dict["group_id"], - ) - else: - return redirect_to("home") - - def _request_manage_code_form(self): - return redirect_to( - controller="ckanext.subscribe.controller:SubscribeController", - action="request_manage_code", - ) - - def request_manage_code(self): - email = request.POST.get("email") - if not email: - return render(u"subscribe/request_manage_code.html", extra_vars={}) - - context = { - u"model": model, - } - try: - get_action(u"subscribe_request_manage_code")(context, dict(email=email)) - except ValidationError as err: - error_messages = [] - for key_ignored in ("message", "__before"): - if key_ignored in err.error_dict: - error_messages.extend(err.error_dict.pop(key_ignored)) - if err.error_dict: - error_messages.append(repr(err.error_dict)) - h.flash_error( - _("Error requesting code: {}".format("; ".join(error_messages))) - ) - except ObjectNotFound as err: - h.flash_error(_("Error requesting code: {}".format(err))) - except MailerException: - h.flash_error( - _("Error sending email - please contact an " "administrator for help") - ) - else: - h.flash_success(_("An access link has been emailed to: {}".format(email))) - return redirect_to("home") - return render( - u"subscribe/request_manage_code.html", extra_vars={"email": email} - ) diff --git a/ckanext/subscribe/email_auth.py b/ckanext/subscribe/email_auth.py index 39e548d..7892c53 100644 --- a/ckanext/subscribe/email_auth.py +++ b/ckanext/subscribe/email_auth.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - """ "Email auth" is a way to authenticate to access certain pages to ckanext-subscribe. It is used for changing your subscription options. Rather diff --git a/ckanext/subscribe/email_verification.py b/ckanext/subscribe/email_verification.py index a79af31..68b4110 100644 --- a/ckanext/subscribe/email_verification.py +++ b/ckanext/subscribe/email_verification.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - import datetime import random import string @@ -59,14 +57,12 @@ def get_verification_email_vars(subscription): ) verification_link = p.toolkit.url_for( - controller="ckanext.subscribe.controller:SubscribeController", - action="verify_subscription", + "subscribe.verify_subscription", code=subscription.verification_code, qualified=True, ) manage_link = p.toolkit.url_for( - controller="ckanext.subscribe.controller:SubscribeController", - action="manage", + "subscribe.manage", qualified=True, ) email_vars.update( diff --git a/ckanext/subscribe/fanstatic/.gitignore b/ckanext/subscribe/fanstatic/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/ckanext/subscribe/mailer.py b/ckanext/subscribe/mailer.py index 7dcb254..b854b40 100644 --- a/ckanext/subscribe/mailer.py +++ b/ckanext/subscribe/mailer.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - # For sending HTML emails. Based on core ckan's mailer import smtplib @@ -46,18 +44,18 @@ def _mail_recipient( else: # just plain text msg = MIMEText(body.encode("utf-8"), "plain", "utf-8") - for k, v in headers.items(): - if k in msg.keys(): + for k, v in list(headers.items()): + if k in list(msg.keys()): msg.replace_header(k, v) else: msg.add_header(k, v) subject = Header(subject.encode("utf-8"), "utf-8") msg["Subject"] = subject msg["From"] = _("%s <%s>") % (sender_name, mail_from) - recipient = u"%s <%s>" % (recipient_name, recipient_email) + recipient = f"{recipient_name} <{recipient_email}>" msg["To"] = Header(recipient, "utf-8") msg["Date"] = utils.formatdate(time()) - msg["X-Mailer"] = "CKAN %s" % ckan.__version__ + msg["X-Mailer"] = f"CKAN {ckan.__version__}" if reply_to and reply_to != "": msg["Reply-to"] = reply_to _mail_payload(msg, mail_from, recipient_email) @@ -84,7 +82,7 @@ def _mail_payload(msg, mail_from, recipient_email): except socket.error as e: log.exception(e) raise MailerException( - 'SMTP server could not be connected to: "%s" %s' % (smtp_server, e) + f'SMTP server could not be connected to: "{smtp_server}" {e}' ) try: # Identify ourselves and prompt the server for supported features. @@ -109,10 +107,10 @@ def _mail_payload(msg, mail_from, recipient_email): smtp_connection.login(smtp_user, smtp_password) smtp_connection.sendmail(mail_from, [recipient_email], msg.as_string()) - log.info("Sent email to {0}".format(recipient_email)) + log.info(f"Sent email to {recipient_email}") except smtplib.SMTPException as e: - msg = "%r" % e + msg = f"{e!r}" log.exception(msg) raise MailerException(msg) finally: diff --git a/ckanext/subscribe/migration/subscribe/README b/ckanext/subscribe/migration/subscribe/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/ckanext/subscribe/migration/subscribe/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/ckanext/subscribe/migration/subscribe/alembic.ini b/ckanext/subscribe/migration/subscribe/alembic.ini new file mode 100644 index 0000000..b40b848 --- /dev/null +++ b/ckanext/subscribe/migration/subscribe/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = %(here)s + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to ckanext/subscribe/migration//versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:ckanext/subscribe/migration//versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/ckanext/subscribe/migration/subscribe/env.py b/ckanext/subscribe/migration/subscribe/env.py new file mode 100644 index 0000000..a097b2b --- /dev/null +++ b/ckanext/subscribe/migration/subscribe/env.py @@ -0,0 +1,82 @@ +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + +name = os.path.basename(os.path.dirname(__file__)) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + version_table="{}_alembic_version".format(name), + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + version_table="{}_alembic_version".format(name), + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/ckanext/subscribe/migration/subscribe/script.py.mako b/ckanext/subscribe/migration/subscribe/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/ckanext/subscribe/migration/subscribe/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/ckanext/subscribe/migration/subscribe/versions/62e202866fb5_create_subscribe_tables.py b/ckanext/subscribe/migration/subscribe/versions/62e202866fb5_create_subscribe_tables.py new file mode 100644 index 0000000..b0d7e70 --- /dev/null +++ b/ckanext/subscribe/migration/subscribe/versions/62e202866fb5_create_subscribe_tables.py @@ -0,0 +1,60 @@ +"""empty message + +Revision ID: 62e202866fb5 +Revises: +Create Date: 2025-07-08 15:32:30.292169 + +""" + +import datetime +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from ckan.model.types import make_uuid + +# revision identifiers, used by Alembic. +revision: str = "62e202866fb5" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + engine = op.get_bind() + inspector = sa.inspect(engine) + tables = inspector.get_table_names() + if "subscription" not in tables: + op.create_table( + "subscription", + sa.Column("id", sa.UnicodeText, primary_key=True, default=make_uuid), + sa.Column("email", sa.UnicodeText, nullable=False), + sa.Column("object_type", sa.UnicodeText, nullable=False), + sa.Column("object_id", sa.UnicodeText, nullable=False), + sa.Column("verified", sa.Boolean, default=False), + sa.Column("verification_code", sa.UnicodeText), + sa.Column("verification_code_expires", sa.DateTime), + sa.Column("created", sa.DateTime, default=datetime.datetime.utcnow), + sa.Column("frequency", sa.Integer), + ) + if "subscribe_login_code" not in tables: + op.create_table( + "subscribe_login_code", + sa.Column("id", sa.UnicodeText, primary_key=True, default=make_uuid), + sa.Column("email", sa.UnicodeText, nullable=False), + sa.Column("code", sa.UnicodeText, nullable=False), + sa.Column("expires", sa.DateTime), + ) + if "subscribe" not in tables: + op.create_table( + "subscribe", + sa.Column("id", sa.UnicodeText, primary_key=True, default=make_uuid), + sa.Column("frequency", sa.Integer), + sa.Column("emails_last_sent", sa.DateTime, nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("subscription") + op.drop_table("subscribe_login_code") + op.drop_table("subscribe") diff --git a/ckanext/subscribe/model.py b/ckanext/subscribe/model.py index 42d1995..379452b 100644 --- a/ckanext/subscribe/model.py +++ b/ckanext/subscribe/model.py @@ -4,42 +4,13 @@ from ckan import model from ckan.model.domain_object import DomainObject -from ckan.model.meta import Session, mapper, metadata +from ckan.model.meta import Session from ckan.model.types import make_uuid -from sqlalchemy import Column, Table, types +from ckan.plugins.toolkit import BaseModel +from sqlalchemy import Column, types log = logging.getLogger(__name__) -__all__ = [ - "Subscription", - "subscription_table", -] - -subscription_table = None -login_code_table = None -subscribe_table = None - - -def setup(): - - if subscription_table is None: - define_tables() - log.debug("Subscription tables defined in memory") - - if not model.package_table.exists(): - log.debug("Subscription table creation deferred") - return - - if not subscription_table.exists(): - - # Create each table individually rather than - # using metadata.create_all() - subscription_table.create() - login_code_table.create() - subscribe_table.create() - - log.debug("Subscription tables created") - class _DomainObject(DomainObject): """Convenience methods for searching objects""" @@ -67,7 +38,7 @@ def __str__(self): return self.__repr__().encode("ascii", "ignore") -class Subscription(_DomainObject): +class Subscription(_DomainObject, BaseModel): """A subscription is a record of a user saying they want to get notifications about a particular domain object. """ @@ -86,16 +57,25 @@ class Subscription(_DomainObject): # # The LoginCode.code does not invalidate previous ones, for convenience. + __tablename__ = "subscription" + + id = Column("id", types.UnicodeText, primary_key=True, default=make_uuid) + email = Column("email", types.UnicodeText, nullable=False) + # object_type is: dataset, group or organization + object_type = Column("object_type", types.UnicodeText, nullable=False) + object_id = Column("object_id", types.UnicodeText, nullable=False) + verified = Column("verified", types.Boolean, default=False) + verification_code = Column("verification_code", types.UnicodeText) + verification_code_expires = Column("verification_code_expires", types.DateTime) + created = Column("created", types.DateTime, default=datetime.datetime.utcnow) + # frequency is: immediate, daily, weekly + frequency = Column("frequency", types.Integer) + def __repr__(self): return ( - "".format( - self.id, - self.email, - self.object_type, - self.verified, - Frequency(self.frequency).name, - ) + f"" ) @@ -105,16 +85,24 @@ class Frequency(Enum): WEEKLY = 3 -class LoginCode(_DomainObject): +class LoginCode(_DomainObject, BaseModel): """A login code is sent out in an email to let the user click to login without password. A user can have multiple codes at once - new ones don't invalidate or overwrite each other (to avoid confusion, acknowledging this is at the expense of some security). """ + __tablename__ = "subscribe_login_code" + + id = Column("id", types.UnicodeText, primary_key=True, default=make_uuid) + email = Column("email", types.UnicodeText, nullable=False) + code = Column("code", types.UnicodeText, nullable=False) + expires = Column("expires", types.DateTime) + def __repr__(self): - return "".format( - self.id, self.email, self.code[:4], self.expires + return ( + f"" ) @classmethod @@ -129,11 +117,17 @@ def validate_code(cls, code): return login_code -class Subscribe(_DomainObject): +class Subscribe(_DomainObject, BaseModel): """General state""" + __tablename__ = "subscribe" + + id = Column("id", types.UnicodeText, primary_key=True, default=make_uuid) + frequency = Column("frequency", types.Integer) + emails_last_sent = Column("emails_last_sent", types.DateTime, nullable=False) + def __repr__(self): - return "".format(self.email_last_sent) + return f"" @classmethod def set_emails_last_sent(cls, frequency, emails_last_sent): @@ -157,55 +151,3 @@ def get_emails_last_sent(cls, frequency): ) except AttributeError: return None - - -def define_tables(): - - global subscription_table, login_code_table, subscribe_table - - subscription_table = Table( - "subscription", - metadata, - Column("id", types.UnicodeText, primary_key=True, default=make_uuid), - Column("email", types.UnicodeText, nullable=False), - Column("object_type", types.UnicodeText, nullable=False), - # object_type is: dataset, group or organization - Column("object_id", types.UnicodeText, nullable=False), - Column("verified", types.Boolean, default=False), - Column("verification_code", types.UnicodeText), - Column("verification_code_expires", types.DateTime), - Column("created", types.DateTime, default=datetime.datetime.utcnow), - # frequency is: immediate, daily, weekly - Column("frequency", types.Integer), - ) - - login_code_table = Table( - "subscribe_login_code", - metadata, - Column("id", types.UnicodeText, primary_key=True, default=make_uuid), - Column("email", types.UnicodeText, nullable=False), - Column("code", types.UnicodeText, nullable=False), - Column("expires", types.DateTime), - ) - - subscribe_table = Table( - "subscribe", - metadata, - # just stores one row for each frequency now - Column("id", types.UnicodeText, primary_key=True, default=make_uuid), - Column("frequency", types.Integer), - Column("emails_last_sent", types.DateTime, nullable=False), - ) - - mapper( - Subscription, - subscription_table, - ) - mapper( - LoginCode, - login_code_table, - ) - mapper( - Subscribe, - subscribe_table, - ) diff --git a/ckanext/subscribe/notification.py b/ckanext/subscribe/notification.py index 3f6adf9..68b6c4c 100644 --- a/ckanext/subscribe/notification.py +++ b/ckanext/subscribe/notification.py @@ -4,10 +4,10 @@ import ckan.plugins.toolkit as toolkit from ckan import model from ckan import plugins as p -from ckan.lib.dictization import model_dictize -from ckan.lib.email_notifications import string_to_timedelta from ckan.model import Group, Member, Package +from ckanext.activity.email_notifications import string_to_timedelta +from ckanext.activity.model import activity as model_activity from ckanext.subscribe import dictization, email_auth, notification_email from ckanext.subscribe.interfaces import ISubscribe from ckanext.subscribe.model import Frequency, Subscribe, Subscription @@ -49,14 +49,10 @@ def send_any_immediate_notifications(): log.debug("no emails to send (immediate frequency)") else: log.debug( - "sending {} notification emails (immediate frequency)".format( - len(notifications_by_email) - ) + f"sending {len(notifications_by_email)} notification emails (immediate frequency)" ) log.debug( - "sending {} deletion emails (immediate frequency)".format( - len(deletions_by_email) - ) + f"sending {len(deletions_by_email)} deletion emails (immediate frequency)" ) send_emails(notifications_by_email, deletions_by_email) @@ -80,14 +76,10 @@ def send_weekly_notifications_if_its_time_to(): log.debug("no emails to send (weekly frequency)") else: log.debug( - "sending {} notification emails (weekly frequency)".format( - len(notifications_by_email) - ) + f"sending {len(notifications_by_email)} notification emails (weekly frequency)" ) log.debug( - "sending {} deletion emails (weekly frequency)".format( - len(deletions_by_email) - ) + f"sending {len(deletions_by_email)} deletion emails (weekly frequency)" ) send_emails(notifications_by_email, deletions_by_email) @@ -111,14 +103,10 @@ def send_daily_notifications_if_its_time_to(): log.debug("no emails to send (daily frequency)") else: log.debug( - "sending {} notification emails (daily frequency)".format( - len(notifications_by_email) - ) + f"sending {len(notifications_by_email)} notification emails (daily frequency)" ) log.debug( - "sending {} deletion emails (daily frequency)".format( - len(deletions_by_email) - ) + f"sending {len(deletions_by_email)} deletion emails (daily frequency)" ) send_emails(notifications_by_email, deletions_by_email) @@ -152,7 +140,7 @@ def get_immediate_notifications(notification_datetime=None): include_activity_from = now - catch_up_period activities = get_subscribed_to_activities( - include_activity_from, objects_subscribed_to.keys() + include_activity_from, list(objects_subscribed_to.keys()) ) if not activities: return {}, {} @@ -270,7 +258,7 @@ def get_weekly_notifications(notification_datetime=None): include_activity_from = now - week activities = get_subscribed_to_activities( - include_activity_from, objects_subscribed_to.keys() + include_activity_from, list(objects_subscribed_to.keys()) ) if not activities: return {}, {} @@ -302,7 +290,7 @@ def get_daily_notifications(notification_datetime=None): else: include_activity_from = now - day activities = get_subscribed_to_activities( - include_activity_from, objects_subscribed_to.keys() + include_activity_from, list(objects_subscribed_to.keys()) ) if not activities: return {}, {} @@ -315,8 +303,8 @@ def get_daily_notifications(notification_datetime=None): def get_subscribed_to_activities(include_activity_from, objects_subscribed_to_keys): activities = [] - for subscribe_interface_implementaion in p.PluginImplementations(ISubscribe): - activities = subscribe_interface_implementaion.get_activities( + for subscribe_interface_implementation in p.PluginImplementations(ISubscribe): + activities = subscribe_interface_implementation.get_activities( include_activity_from, objects_subscribed_to_keys ) return activities @@ -345,7 +333,7 @@ def get_notifications_by_email( # dictize notifications_by_email_dictized = defaultdict(list) - for email, subscription_activities in notifications.items(): + for email, subscription_activities in list(notifications.items()): notifications_by_email_dictized[email] = dictize_notifications( subscription_activities ) @@ -361,9 +349,9 @@ def dictize_notifications(subscription_activities): """ context = {"model": model, "session": model.Session} notifications_dictized = [] - for subscription, activities in subscription_activities.items(): + for subscription, activities in list(subscription_activities.items()): subscription_dict = dictization.dictize_subscription(subscription, context) - activity_dicts = model_dictize.activity_list_dictize(activities, context) + activity_dicts = model_activity.activity_list_dictize(activities, context) notifications_dictized.append( { "subscription": subscription_dict, @@ -374,7 +362,7 @@ def dictize_notifications(subscription_activities): def send_emails(notifications_by_email, deletions_by_email): - for email, notifications in notifications_by_email.items(): + for email, notifications in list(notifications_by_email.items()): code = email_auth.create_code(email) notification_email.send_notification_email( code, email, notifications, "notification" diff --git a/ckanext/subscribe/notification_email.py b/ckanext/subscribe/notification_email.py index 92ce2a3..abbd79d 100644 --- a/ckanext/subscribe/notification_email.py +++ b/ckanext/subscribe/notification_email.py @@ -1,6 +1,6 @@ +import dominate.tags as tags from ckan import model from ckan import plugins as p -from webhelpers.html import HTML from ckanext.subscribe import mailer from ckanext.subscribe.interfaces import ISubscribe @@ -60,7 +60,7 @@ def get_notification_email_vars(code, email, notifications): ) ) # get the package/group's name & title - object_type_ = subscription["object_type"].replace("dataset", "package") + object_type_ = subscription["object_type"] try: # activity['data'] should have the package/group table obj = notification["activities"][0]["data"][object_type_] @@ -75,8 +75,7 @@ def get_notification_email_vars(code, email, notifications): object_name = obj.name object_title = obj.title object_link = p.toolkit.url_for( - controller=object_type_, - action="read", + f"{object_type_}.read", id=subscription["object_id"], # prefer id because it is invariant qualified=True, ) @@ -101,7 +100,7 @@ def dataset_link_from_activity(activity): return "" try: title = activity["data"]["package"]["title"] - return HTML.a(title, href=href) + return tags.a(title, href=href) except KeyError: return "" diff --git a/ckanext/subscribe/plugin.py b/ckanext/subscribe/plugin.py index a03a573..d045ee0 100644 --- a/ckanext/subscribe/plugin.py +++ b/ckanext/subscribe/plugin.py @@ -1,72 +1,27 @@ -# encoding: utf-8 - import ckan.plugins as plugins import ckan.plugins.toolkit as tk import ckanext.subscribe.helpers as subscribe_helpers from ckanext.subscribe import action, auth -from ckanext.subscribe import model as subscribe_model +from ckanext.subscribe.blueprints import subscribe_blueprint from ckanext.subscribe.interfaces import ISubscribe class SubscribePlugin(plugins.SingletonPlugin): plugins.implements(plugins.IConfigurer) - plugins.implements(plugins.IRoutes) plugins.implements(plugins.IActions) plugins.implements(plugins.IAuthFunctions) plugins.implements(ISubscribe, inherit=True) plugins.implements(plugins.ITemplateHelpers) + plugins.implements(plugins.IBlueprint, inherit=True) # IConfigurer def update_config(self, config_): tk.add_template_directory(config_, "templates") tk.add_public_directory(config_, "public") - tk.add_resource("fanstatic", "subscribe") - - subscribe_model.setup() - - # IRoutes - - def before_map(self, map): - controller = "ckanext.subscribe.controller:SubscribeController" - map.connect( - "signup", "/subscribe/signup", controller=controller, action="signup" - ) - map.connect( - "verify", - "/subscribe/verify", - controller=controller, - action="verify_subscription", - ) - map.connect( - "update", "/subscribe/update", controller=controller, action="update" - ) - map.connect( - "manage", "/subscribe/manage", controller=controller, action="manage" - ) - map.connect( - "unsubscribe", - "/subscribe/unsubscribe", - controller=controller, - action="unsubscribe", - ) - map.connect( - "unsubscribe_all", - "/subscribe/unsubscribe-all", - controller=controller, - action="unsubscribe_all", - ) - map.connect( - "request_manage_code", - "/subscribe/request_manage_code", - controller=controller, - action="request_manage_code", - ) - return map - - def after_map(self, map): - return map + # Register WebAssets + tk.add_resource("assets", "subscribe") # IActions @@ -101,6 +56,10 @@ def get_helpers(self): """Provide template helper functions""" return { - "get_recaptcha_publickey": subscribe_helpers.get_recaptcha_publickey, # noqa + "get_recaptcha_publickey": subscribe_helpers.get_recaptcha_publickey, "apply_recaptcha": subscribe_helpers.apply_recaptcha, } + + # IBlueprint + def get_blueprint(self): + return [subscribe_blueprint] diff --git a/ckanext/subscribe/schema.py b/ckanext/subscribe/schema.py index 9431117..e9cf43c 100644 --- a/ckanext/subscribe/schema.py +++ b/ckanext/subscribe/schema.py @@ -1,5 +1,3 @@ -# encoding: utf-8 - import ckan.plugins as p from ckan.common import _ @@ -17,9 +15,15 @@ def one_package_or_group_or_org(key, data, errors, context): num_objects_specified = len( - filter( - None, - [data[("dataset_id",)], data[("group_id",)], data[("organization_id",)]], + list( + filter( + None, + [ + data[("dataset_id",)], + data[("group_id",)], + data[("organization_id",)], + ], + ) ) ) if num_objects_specified > 1: @@ -41,52 +45,51 @@ def frequency_name_to_int(name, context): except KeyError: raise Invalid( _( - "Frequency must be one of: {}".format( - " ".join(f.name.lower() for f in Frequency) - ) + f"Frequency must be one of: " + f"{' '.join(f.name.lower() for f in Frequency)}" ) ) def subscribe_schema(): return { - u"__before": [one_package_or_group_or_org], - u"dataset_id": [ignore_empty, package_id_or_name_exists], - u"group_id": [ignore_empty, group_id_or_name_exists], - u"organization_id": [ignore_empty, group_id_or_name_exists], - u"email": [email], - u"frequency": [ignore_empty, frequency_name_to_int], - u"skip_verification": [boolean_validator], - u"g_recaptcha_response": [ignore_empty], + "__before": [one_package_or_group_or_org], + "dataset_id": [ignore_empty, package_id_or_name_exists], + "group_id": [ignore_empty, group_id_or_name_exists], + "organization_id": [ignore_empty, group_id_or_name_exists], + "email": [email], + "frequency": [ignore_empty, frequency_name_to_int], + "skip_verification": [boolean_validator], + "g_recaptcha_response": [ignore_empty], } def update_schema(): return { - u"id": [subscription_id_exists], - u"frequency": [ignore_empty, frequency_name_to_int], + "id": [subscription_id_exists], + "frequency": [ignore_empty, frequency_name_to_int], } def unsubscribe_schema(): return { - u"__before": [one_package_or_group_or_org], - u"dataset_id": [ignore_empty, package_id_or_name_exists], - u"group_id": [ignore_empty, group_id_or_name_exists], - u"organization_id": [ignore_empty, group_id_or_name_exists], - u"email": [email], + "__before": [one_package_or_group_or_org], + "dataset_id": [ignore_empty, package_id_or_name_exists], + "group_id": [ignore_empty, group_id_or_name_exists], + "organization_id": [ignore_empty, group_id_or_name_exists], + "email": [email], } def unsubscribe_all_schema(): return { - u"email": [email], + "email": [email], } def request_manage_code_schema(): return { - u"email": [email], + "email": [email], } diff --git a/ckanext/subscribe/templates/group/snippets/info.html b/ckanext/subscribe/templates/group/snippets/info.html index 6342873..f46ea1c 100644 --- a/ckanext/subscribe/templates/group/snippets/info.html +++ b/ckanext/subscribe/templates/group/snippets/info.html @@ -15,7 +15,7 @@ {% if c.user %} {{ super() }} {% else %} - {% resource 'subscribe/subscribe.css' %} + {% asset 'subscribe/css/subscribe' %}
{% import 'macros/form.html' as form %} diff --git a/ckanext/subscribe/templates/package/snippets/info.html b/ckanext/subscribe/templates/package/snippets/info.html index e0355b2..a37ae92 100644 --- a/ckanext/subscribe/templates/package/snippets/info.html +++ b/ckanext/subscribe/templates/package/snippets/info.html @@ -15,7 +15,7 @@ {% if c.user %} {{ super() }} {% else %} - {% resource 'subscribe/subscribe.css' %} + {% asset 'subscribe/css/subscribe' %}
{% import 'macros/form.html' as form %} diff --git a/ckanext/subscribe/templates/snippets/organization.html b/ckanext/subscribe/templates/snippets/organization.html index 5a4ee07..ddd9328 100644 --- a/ckanext/subscribe/templates/snippets/organization.html +++ b/ckanext/subscribe/templates/snippets/organization.html @@ -15,7 +15,7 @@ {% if c.user %} {{ super() }} {% else %} - {% resource 'subscribe/subscribe.css' %} + {% asset 'subscribe/css/subscribe' %}
{% import 'macros/form.html' as form %} diff --git a/ckanext/subscribe/templates/subscribe/manage.html b/ckanext/subscribe/templates/subscribe/manage.html index debbb08..007f873 100644 --- a/ckanext/subscribe/templates/subscribe/manage.html +++ b/ckanext/subscribe/templates/subscribe/manage.html @@ -2,7 +2,7 @@ {% block styles %} {{ super() }} - {% resource 'subscribe/subscribe.css' %} + {% asset 'subscribe/css/subscribe' %} {% endblock styles %} {% block primary %} @@ -24,7 +24,7 @@

Manage subscriptions

{% if not subscription.verified %} -
+ @@ -36,7 +36,7 @@

Manage subscriptions

{% if subscription.verified %} {% import 'macros/form.html' as form %} - + {{ form.select('frequency', label=_('Emails are sent'), options=frequency_options, selected=subscription.frequency, error=None) }} @@ -47,7 +47,7 @@

Manage subscriptions

{% endif %} - + @@ -59,7 +59,7 @@

Manage subscriptions

{% endfor %} - + diff --git a/ckanext/subscribe/templates/subscribe/request_manage_code.html b/ckanext/subscribe/templates/subscribe/request_manage_code.html index 45b1532..1db609d 100644 --- a/ckanext/subscribe/templates/subscribe/request_manage_code.html +++ b/ckanext/subscribe/templates/subscribe/request_manage_code.html @@ -2,7 +2,7 @@ {% block styles %} {{ super() }} - {% resource 'subscribe/subscribe.css' %} + {% asset 'subscribe/css/subscribe' %} {% endblock styles %} {% block primary_content %} @@ -13,7 +13,7 @@

Email check

send you a fresh access link to the subscription management page.

- + Email: diff --git a/ckanext/subscribe/tests/conftest.py b/ckanext/subscribe/tests/conftest.py new file mode 100644 index 0000000..ac48abd --- /dev/null +++ b/ckanext/subscribe/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest +from ckan.plugins import toolkit + + +@pytest.fixture +def clean_db(reset_db, migrate_db_for): + reset_db() + migrate_db_for("subscribe") + if toolkit.check_ckan_version(min_version="2.11.0"): + migrate_db_for("activity") diff --git a/ckanext/subscribe/tests/factories.py b/ckanext/subscribe/tests/factories.py index 985d082..a3aa9ba 100644 --- a/ckanext/subscribe/tests/factories.py +++ b/ckanext/subscribe/tests/factories.py @@ -1,8 +1,5 @@ -# encoding: utf-8 - import datetime -import ckan.lib.dictization.model_dictize as model_dictize import ckan.plugins as p import ckan.tests.factories as ckan_factories import factory @@ -10,6 +7,8 @@ from ckan.lib.dictization import table_dictize import ckanext.subscribe.model +from ckanext.activity.model import Activity as BaseActivity +from ckanext.activity.model import activity as model_activity from ckanext.subscribe import dictization @@ -18,9 +17,10 @@ class Subscription(factory.Factory): action. """ - FACTORY_FOR = ckanext.subscribe.model.Subscription + class Meta: + model = ckanext.subscribe.model.Subscription - id = factory.Sequence(lambda n: "test_sub_{n}".format(n=n)) + id = factory.Sequence(lambda n: f"test_sub_{n}") email = "bob@example.com" return_object = False created = datetime.datetime.now() - datetime.timedelta(hours=1) @@ -65,9 +65,10 @@ def _create(cls, target_class, *args, **kwargs): class SubscriptionLowLevel(factory.Factory): """A factory class for creating subscription object directly""" - FACTORY_FOR = ckanext.subscribe.model.Subscription + class Meta: + model = ckanext.subscribe.model.Subscription - id = factory.Sequence(lambda n: "test_sub_{n}".format(n=n)) + id = factory.Sequence(lambda n: f"test_sub_{n}") email = "bob@example.com" return_object = False @@ -107,7 +108,8 @@ def _create(cls, target_class, **kwargs): class Activity(factory.Factory): """A factory class for creating CKAN activity objects.""" - FACTORY_FOR = model.Activity + class Meta: + model = BaseActivity @classmethod def _build(cls, target_class, *args, **kwargs): @@ -126,7 +128,7 @@ def _create(cls, target_class, *args, **kwargs): activity_dict = p.toolkit.get_action("activity_create")(context, kwargs) # to set the timestamp we need to edit the object - activity = model.Session.query(model.Activity).get(activity_dict["id"]) + activity = model.Session.query(BaseActivity).get(activity_dict["id"]) if kwargs.get("timestamp"): activity.timestamp = kwargs["timestamp"] model.repo.commit() @@ -134,14 +136,15 @@ def _create(cls, target_class, *args, **kwargs): if kwargs.get("return_object"): return activity - return model_dictize.activity_dictize(activity, context) + return model_activity.activity_dictize(activity, context) class DatasetActivity(factory.Factory): """A factory class for creating a CKAN dataset and associated activity object.""" - FACTORY_FOR = model.Activity + class Meta: + model = BaseActivity @classmethod def _build(cls, target_class, *args, **kwargs): @@ -163,13 +166,11 @@ def _create(cls, target_class, *args, **kwargs): # the activity object is made as a byproduct activity_obj = ( - model.Session.query(model.Activity) - .filter_by(object_id=dataset["id"]) - .first() + model.Session.query(BaseActivity).filter_by(object_id=dataset["id"]).first() ) if kwargs: - for k, v in kwargs.items(): + for k, v in list(kwargs.items()): setattr(activity_obj, k, v) model.repo.commit_and_remove() @@ -182,7 +183,8 @@ class GroupActivity(factory.Factory): """A factory class for creating a CKAN group and associated activity object.""" - FACTORY_FOR = model.Activity + class Meta: + model = BaseActivity @classmethod def _build(cls, target_class, *args, **kwargs): @@ -204,11 +206,11 @@ def _create(cls, target_class, *args, **kwargs): # the activity object is made as a byproduct activity_obj = ( - model.Session.query(model.Activity).filter_by(object_id=group["id"]).first() + model.Session.query(BaseActivity).filter_by(object_id=group["id"]).first() ) if kwargs: - for k, v in kwargs.items(): + for k, v in list(kwargs.items()): setattr(activity_obj, k, v) model.repo.commit_and_remove() @@ -221,7 +223,8 @@ class OrganizationActivity(factory.Factory): """A factory class for creating a CKAN org and associated activity object.""" - FACTORY_FOR = model.Activity + class Meta: + model = BaseActivity @classmethod def _build(cls, target_class, *args, **kwargs): @@ -243,11 +246,11 @@ def _create(cls, target_class, *args, **kwargs): # the activity object is made as a byproduct activity_obj = ( - model.Session.query(model.Activity).filter_by(object_id=org["id"]).first() + model.Session.query(BaseActivity).filter_by(object_id=org["id"]).first() ) if kwargs: - for k, v in kwargs.items(): + for k, v in list(kwargs.items()): setattr(activity_obj, k, v) model.repo.commit_and_remove() diff --git a/ckanext/subscribe/tests/test_action.py b/ckanext/subscribe/tests/test_action.py index 24afb64..d0adadc 100644 --- a/ckanext/subscribe/tests/test_action.py +++ b/ckanext/subscribe/tests/test_action.py @@ -1,14 +1,10 @@ -# encoding: utf-8 - import datetime -import ckan.plugins.toolkit as tk import mock +import pytest from ckan import model from ckan.plugins.toolkit import ValidationError from ckan.tests import factories, helpers -from nose.tools import assert_equal as eq -from nose.tools import assert_in, assert_raises from ckanext.subscribe import model as subscribe_model from ckanext.subscribe.tests.factories import ( @@ -18,10 +14,9 @@ ) +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestSubscribeSignup(object): - def setup(self): - helpers.reset_db() - @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_basic(self, send_request_email): dataset = factories.Dataset() @@ -34,13 +29,13 @@ def test_basic(self, send_request_email): ) send_request_email.assert_called_once() - eq(send_request_email.call_args[0][0].object_type, "dataset") - eq(send_request_email.call_args[0][0].object_id, dataset["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") - eq(subscription["object_type"], "dataset") - eq(subscription["object_id"], dataset["id"]) - eq(subscription["email"], "bob@example.com") - eq(subscription["verified"], False) + assert send_request_email.call_args[0][0].object_type == "dataset" + assert send_request_email.call_args[0][0].object_id == dataset["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" + assert subscription["object_type"] == "dataset" + assert subscription["object_id"] == dataset["id"] + assert subscription["email"] == "bob@example.com" + assert subscription["verified"] == False assert "verification_code" not in subscription subscription_obj = model.Session.query(subscribe_model.Subscription).get( subscription["id"] @@ -59,9 +54,9 @@ def test_dataset_name(self, send_request_email): ) send_request_email.assert_called_once() - eq(send_request_email.call_args[0][0].object_type, "dataset") - eq(send_request_email.call_args[0][0].object_id, dataset["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") + assert send_request_email.call_args[0][0].object_type == "dataset" + assert send_request_email.call_args[0][0].object_id == dataset["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_group_id(self, send_request_email): @@ -75,9 +70,9 @@ def test_group_id(self, send_request_email): ) send_request_email.assert_called_once() - eq(send_request_email.call_args[0][0].object_type, "group") - eq(send_request_email.call_args[0][0].object_id, group["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") + assert send_request_email.call_args[0][0].object_type == "group" + assert send_request_email.call_args[0][0].object_id == group["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_group_name(self, send_request_email): @@ -91,9 +86,9 @@ def test_group_name(self, send_request_email): ) send_request_email.assert_called_once() - eq(send_request_email.call_args[0][0].object_type, "group") - eq(send_request_email.call_args[0][0].object_id, group["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") + assert send_request_email.call_args[0][0].object_type == "group" + assert send_request_email.call_args[0][0].object_id == group["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_org_id(self, send_request_email): @@ -107,9 +102,9 @@ def test_org_id(self, send_request_email): ) send_request_email.assert_called_once() - eq(send_request_email.call_args[0][0].object_type, "organization") - eq(send_request_email.call_args[0][0].object_id, org["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") + assert send_request_email.call_args[0][0].object_type == "organization" + assert send_request_email.call_args[0][0].object_id == org["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_org_name(self, send_request_email): @@ -123,9 +118,9 @@ def test_org_name(self, send_request_email): ) send_request_email.assert_called_once() - eq(send_request_email.call_args[0][0].object_type, "organization") - eq(send_request_email.call_args[0][0].object_id, org["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") + assert send_request_email.call_args[0][0].object_type == "organization" + assert send_request_email.call_args[0][0].object_id == org["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_skip_verification(self, send_request_email): @@ -140,10 +135,10 @@ def test_skip_verification(self, send_request_email): ) assert not send_request_email.called - eq(subscription["object_type"], "dataset") - eq(subscription["object_id"], dataset["id"]) - eq(subscription["email"], "bob@example.com") - eq(subscription["verified"], True) + assert subscription["object_type"] == "dataset" + assert subscription["object_id"] == dataset["id"] + assert subscription["email"] == "bob@example.com" + assert subscription["verified"] == True @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_resend_verification(self, send_request_email): @@ -164,41 +159,40 @@ def test_resend_verification(self, send_request_email): ) send_request_email.assert_called_once() - eq(send_request_email.call_args[0][0].id, existing_subscription["id"]) - eq(send_request_email.call_args[0][0].object_type, "dataset") - eq(send_request_email.call_args[0][0].object_id, dataset["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") + assert send_request_email.call_args[0][0].id == existing_subscription["id"] + assert send_request_email.call_args[0][0].object_type == "dataset" + assert send_request_email.call_args[0][0].object_id == dataset["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" assert send_request_email.call_args[0][0].verification_code != "original_code" - eq(subscription["object_type"], "dataset") - eq(subscription["object_id"], dataset["id"]) - eq(subscription["email"], "bob@example.com") - eq(subscription["verified"], False) + assert subscription["object_type"] == "dataset" + assert subscription["object_id"] == dataset["id"] + assert subscription["email"] == "bob@example.com" + assert subscription["verified"] == False @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_dataset_doesnt_exist(self, send_request_email): - with assert_raises(ValidationError) as cm: + with pytest.raises(ValidationError) as exc_info: helpers.call_action( "subscribe_signup", {}, email="bob@example.com", dataset_id="doesnt-exist", ) - assert_in("dataset_id': [u'Not found", str(cm.exception.error_dict)) + assert "dataset_id': ['Not found" in str(exc_info.value) assert not send_request_email.called @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_group_doesnt_exist(self, send_request_email): - with assert_raises(ValidationError) as cm: + with pytest.raises(ValidationError) as exc_info: helpers.call_action( "subscribe_signup", {}, email="bob@example.com", group_id="doesnt-exist", ) - assert_in( - "group_id': [u'That group name or ID does not exist", - str(cm.exception.error_dict), + assert "group_id': ['That group name or ID does not exist" in str( + exc_info.value ) assert not send_request_email.called @@ -208,7 +202,7 @@ def test_dataset_and_group_at_same_time(self, send_request_email): dataset = factories.Dataset() group = factories.Group() - with assert_raises(ValidationError) as cm: + with pytest.raises(ValidationError) as exc_info: helpers.call_action( "subscribe_signup", {}, @@ -216,24 +210,19 @@ def test_dataset_and_group_at_same_time(self, send_request_email): dataset_id=dataset["id"], group_id=group["id"], ) - assert_in( + assert ( 'Must not specify more than one of: "dataset_id", "group_id"' - ' or "organization_id"', - str(cm.exception.error_dict), + ' or "organization_id"' in str(exc_info.value) ) assert not send_request_email.called # The reCAPTCHA tests +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.ckan_config("ckanext.subscribe.apply_recaptcha", "true") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestRecaptchaOfSubscribeSignup(object): - def setup(self): - helpers.reset_db() - tk.config["ckanext.subscribe.apply_recaptcha"] = "true" - - def teardown(self): - tk.config["ckanext.subscribe.apply_recaptcha"] = "false" - # mock the _verify_recaptcha function and test both # successful and unsuccessful reCAPTCHA verification scenarios @mock.patch("requests.post") @@ -261,15 +250,15 @@ def test_verify_recaptcha_success( # Asserting that the email verification function was called once send_request_email.assert_called_once() - eq(send_request_email.call_args[0][0].object_type, "dataset") - eq(send_request_email.call_args[0][0].object_id, dataset["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") + assert send_request_email.call_args[0][0].object_type == "dataset" + assert send_request_email.call_args[0][0].object_id == dataset["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" # Asserting that the subscription was created with the correct details - eq(subscription["object_type"], "dataset") - eq(subscription["object_id"], dataset["id"]) - eq(subscription["email"], "bob@example.com") - eq(subscription["verified"], False) + assert subscription["object_type"] == "dataset" + assert subscription["object_id"] == dataset["id"] + assert subscription["email"] == "bob@example.com" + assert subscription["verified"] == False assert "verification_code" not in subscription # Checking that the subscription object exists in the database @@ -305,10 +294,9 @@ def test_verify_recaptcha_failure(self, mock_verify_recaptcha, send_request_emai assert False, "ValidationError not raised" +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestSubscribeVerify(object): - def setup(self): - helpers.reset_db() - @mock.patch("ckanext.subscribe.email_auth.send_subscription_confirmation_email") def test_basic(self, send_confirmation_email): dataset = factories.Dataset() @@ -329,21 +317,21 @@ def test_basic(self, send_confirmation_email): ) send_confirmation_email.assert_called_once() - eq( - send_confirmation_email.call_args[1]["subscription"].email, - "bob@example.com", + assert ( + send_confirmation_email.call_args[1]["subscription"].email + == "bob@example.com" ) login_codes = ( model.Session.query(subscribe_model.LoginCode.code) .filter_by(email="bob@example.com") .all() ) - assert_in(send_confirmation_email.call_args[0], login_codes) + assert send_confirmation_email.call_args[0] in login_codes subscribe_model.LoginCode.validate_code(send_confirmation_email.call_args[0]) - eq(subscription["verified"], True) - eq(subscription["object_type"], "dataset") - eq(subscription["object_id"], dataset["id"]) - eq(subscription["email"], "bob@example.com") + assert subscription["verified"] == True + assert subscription["object_type"] == "dataset" + assert subscription["object_id"] == dataset["id"] + assert subscription["email"] == "bob@example.com" assert "verification_code" not in subscription def test_wrong_code(self): @@ -358,18 +346,16 @@ def test_wrong_code(self): + datetime.timedelta(hours=1), ) - with assert_raises(ValidationError) as cm: + with pytest.raises(ValidationError) as exc_info: subscription = helpers.call_action( "subscribe_verify", {}, code="wrong_code", ) - assert_in( - "That validation code is not recognized", str(cm.exception.error_dict) - ) + assert "That validation code is not recognized" in str(exc_info.value) subscription = subscribe_model.Subscription.get(subscription["id"]) - eq(subscription.verified, False) + assert subscription.verified == False def test_code_expired(self): dataset = factories.Dataset() @@ -383,22 +369,21 @@ def test_code_expired(self): - datetime.timedelta(hours=1), # in the past ) - with assert_raises(ValidationError) as cm: + with pytest.raises(ValidationError) as exc_info: subscription = helpers.call_action( "subscribe_verify", {}, code="the_code", ) - assert_in("That validation code has expired", str(cm.exception.error_dict)) + assert "That validation code has expired" in str(exc_info.value) subscription = subscribe_model.Subscription.get(subscription["id"]) - eq(subscription.verified, False) + assert subscription.verified == False +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestSubscribeAndVerify(object): - def setup(self): - helpers.reset_db() - @mock.patch("ckanext.subscribe.email_auth.send_subscription_confirmation_email") @mock.patch("ckanext.subscribe.email_verification.send_request_email") def test_basic(self, send_request_email, send_confirmation_email): @@ -421,21 +406,20 @@ def test_basic(self, send_request_email, send_confirmation_email): send_request_email.assert_called_once() send_confirmation_email.assert_called_once() - eq(send_request_email.call_args[0][0].object_type, "dataset") - eq(send_request_email.call_args[0][0].object_id, dataset["id"]) - eq(send_request_email.call_args[0][0].email, "bob@example.com") - eq(subscription["object_type"], "dataset") - eq(subscription["object_id"], dataset["id"]) - eq(subscription["email"], "bob@example.com") - eq(subscription["verified"], True) + assert send_request_email.call_args[0][0].object_type == "dataset" + assert send_request_email.call_args[0][0].object_id == dataset["id"] + assert send_request_email.call_args[0][0].email == "bob@example.com" + assert subscription["object_type"] == "dataset" + assert subscription["object_id"] == dataset["id"] + assert subscription["email"] == "bob@example.com" + assert subscription["verified"] == True login_code = send_confirmation_email.call_args[0] subscribe_model.LoginCode.validate_code(login_code) +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestSubscribeListSubscriptions(object): - def setup(self): - helpers.reset_db() - def test_basic(self): dataset = factories.Dataset() Subscription( @@ -450,7 +434,7 @@ def test_basic(self): email="bob@example.com", ) - eq([sub["object_id"] for sub in sub_list], [dataset["id"]]) + assert [sub["object_id"] for sub in sub_list] == [dataset["id"]] def test_dataset_details(self): dataset = factories.Dataset() @@ -478,30 +462,29 @@ def test_dataset_details(self): email="bob@example.com", ) - eq( - set(sub["object_id"] for sub in sub_list), - set([dataset["id"], group["id"], org["id"]]), - ) - eq( - set(sub["object_link"] for sub in sub_list), - set( - [ - "/dataset/{}".format(dataset["name"]), - "/group/{}".format(group["name"]), - "/organization/{}".format(org["name"]), - ] - ), - ) - eq( - set(sub.get("object_name") for sub in sub_list), - set([dataset["name"], group["name"], org["name"]]), + assert set(sub["object_id"] for sub in sub_list) == { + dataset["id"], + group["id"], + org["id"], + } + assert ( + set(sub["object_link"] for sub in sub_list) + in { + f"/dataset/{dataset['name']}", + f"/group/{group['name']}", + f"/organization/{org['name']}", + }, ) + assert set(sub.get("object_name") for sub in sub_list) == { + dataset["name"], + group["name"], + org["name"], + } +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestUnsubscribe(object): - def setup(self): - helpers.reset_db() - def test_basic(self): dataset = factories.Dataset() dataset2 = factories.Dataset() @@ -528,7 +511,7 @@ def test_basic(self): {}, email="bob@example.com", ) - eq([sub["object_id"] for sub in sub_list], [dataset2["id"]]) + assert [sub["object_id"] for sub in sub_list] == [dataset2["id"]] def test_group(self): group = factories.Group() @@ -556,7 +539,7 @@ def test_group(self): {}, email="bob@example.com", ) - eq([sub["object_id"] for sub in sub_list], [group2["id"]]) + assert [sub["object_id"] for sub in sub_list] == [group2["id"]] def test_org(self): org = factories.Organization() @@ -584,13 +567,12 @@ def test_org(self): {}, email="bob@example.com", ) - eq([sub["object_id"] for sub in sub_list], [org2["id"]]) + assert [sub["object_id"] for sub in sub_list] == [org2["id"]] +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestUnsubscribeAll(object): - def setup(self): - helpers.reset_db() - def test_basic(self): dataset = factories.Dataset() dataset2 = factories.Dataset() @@ -616,13 +598,12 @@ def test_basic(self): {}, email="bob@example.com", ) - eq([sub["object_id"] for sub in sub_list], []) + assert [sub["object_id"] for sub in sub_list] == [] +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestSendAnyNotifications(object): - def setup(self): - helpers.reset_db() - # Lots of overlap here with: # test_notification.py:TestSendAnyImmediateNotifications @mock.patch("ckanext.subscribe.notification_email.send_notification_email") @@ -635,24 +616,21 @@ def test_basic(self, send_notification_email): helpers.call_action("subscribe_send_any_notifications", {}) send_notification_email.assert_called_once() - code, email, notifications, email_type = send_notification_email.call_args[0] - eq(type(code), type(u"")) - eq(email, "bob@example.com") - eq(len(notifications), 1) - eq( - [ - (a["activity_type"], a["data"]["package"]["id"]) - for a in notifications[0]["activities"] - ], - [("new package", dataset["id"])], - ) - eq(notifications[0]["subscription"]["id"], subscription["id"]) - + code, email, notifications, email_type = send_notification_email.call_args[0] + assert type(code) is type("") + assert email == "bob@example.com" + assert len(notifications) == 1 + assert [ + (a["activity_type"], a["data"]["package"]["id"]) + for a in notifications[0]["activities"] + ] == [("new package", dataset["id"])] + assert notifications[0]["subscription"]["id"] == subscription["id"] + + +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestUpdate(object): - def setup(self): - helpers.reset_db() - def test_basic(self): subscription = Subscription( email="bob@example.com", @@ -667,7 +645,7 @@ def test_basic(self): frequency="DAILY", ) - eq(subscription["frequency"], "DAILY") + assert subscription["frequency"] == "DAILY" def test_frequency_not_specified(self): subscription = Subscription( @@ -682,4 +660,4 @@ def test_frequency_not_specified(self): id=subscription["id"], ) - eq(subscription["frequency"], "WEEKLY") # unchanged + assert subscription["frequency"] == "WEEKLY" # unchanged diff --git a/ckanext/subscribe/tests/test_auth.py b/ckanext/subscribe/tests/test_auth.py index 198f4e8..fa919f0 100644 --- a/ckanext/subscribe/tests/test_auth.py +++ b/ckanext/subscribe/tests/test_auth.py @@ -1,26 +1,23 @@ -# encoding: utf-8 - import ckan.logic as logic import ckan.tests.factories as factories import ckan.tests.helpers as helpers +import pytest from ckan import model -from nose.tools import assert_in, assert_raises +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestSubscribeSignupToDataset(object): - def setup(self): - helpers.reset_db() - def test_no_user_specified(self): dataset = factories.Dataset(state="deleted") context = {"model": model} context["user"] = "" - with assert_raises(logic.NotAuthorized) as cm: + with pytest.raises(logic.NotAuthorized) as exc_info: helpers.call_auth( "subscribe_signup", context=context, dataset_id=dataset["name"] ) - assert_in("not authorized to read package", cm.exception.message) + assert "not authorized to read package" in str(exc_info.value) def test_deleted_dataset_not_subscribable(self): factories.User(name="fred") @@ -28,11 +25,11 @@ def test_deleted_dataset_not_subscribable(self): context = {"model": model} context["user"] = "fred" - with assert_raises(logic.NotAuthorized) as cm: + with pytest.raises(logic.NotAuthorized) as exc_info: helpers.call_auth( "subscribe_signup", context=context, dataset_id=dataset["name"] ) - assert_in("User fred not authorized to read package", cm.exception.message) + assert "User fred not authorized to read package" in str(exc_info.value) def test_private_dataset_is_subscribable_to_editor(self): fred = factories.User(name="fred") @@ -54,11 +51,11 @@ def test_private_dataset_is_not_subscribable_to_public(self): context = {"model": model} context["user"] = "fred" - with assert_raises(logic.NotAuthorized) as cm: + with pytest.raises(logic.NotAuthorized) as exc_info: helpers.call_auth( "subscribe_signup", context=context, dataset_id=dataset["name"] ) - assert_in("User fred not authorized to read package", cm.exception.message) + assert "User fred not authorized to read package" in str(exc_info.value) def test_admin_cant_skip_verification(self): # (only sysadmin can) @@ -69,20 +66,19 @@ def test_admin_cant_skip_verification(self): context = {"model": model} context["user"] = "fred" - with assert_raises(logic.NotAuthorized) as cm: + with pytest.raises(logic.NotAuthorized) as exc_info: helpers.call_auth( "subscribe_signup", context=context, dataset_id=dataset["name"], skip_verification=True, ) - assert_in("Not authorized to skip verification", cm.exception.message) + assert "Not authorized to skip verification" in str(exc_info.value) +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestSubscribeListSubscriptions(object): - def setup(self): - helpers.reset_db() - def test_admin_cant_use_it(self): # (only sysadmin can) fred = factories.User(name="fred") @@ -91,16 +87,15 @@ def test_admin_cant_use_it(self): context = {"model": model} context["user"] = "fred" - with assert_raises(logic.NotAuthorized): + with pytest.raises(logic.NotAuthorized): helpers.call_auth( "subscribe_list_subscriptions", context=context, email=fred["email"] ) +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") class TestSubscribeUnsubscribe(object): - def setup(self): - helpers.reset_db() - def test_admin_cant_use_it(self): # (only sysadmin can) fred = factories.User(name="fred") @@ -109,7 +104,7 @@ def test_admin_cant_use_it(self): context = {"model": model} context["user"] = "fred" - with assert_raises(logic.NotAuthorized): + with pytest.raises(logic.NotAuthorized): helpers.call_auth( "subscribe_unsubscribe", context=context, email=fred["email"] ) diff --git a/ckanext/subscribe/tests/test_blueprints.py b/ckanext/subscribe/tests/test_blueprints.py new file mode 100644 index 0000000..7836a4e --- /dev/null +++ b/ckanext/subscribe/tests/test_blueprints.py @@ -0,0 +1,472 @@ +import datetime + +import mock +import pytest +from ckan.tests.factories import Dataset, Group, Organization + +from ckanext.subscribe import email_auth +from ckanext.subscribe import model as subscribe_model +from ckanext.subscribe.tests.factories import Subscription, SubscriptionLowLevel + + +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") +class TestSignupSubmit(object): + @mock.patch("ckanext.subscribe.mailer.mail_recipient") + def test_signup_to_dataset_ok(self, mock_mailer, app): + dataset = Dataset() + response = app.post( + "/subscribe/signup", + params={"email": "bob@example.com", "dataset": dataset["id"]}, + status=302, + follow_redirects=False, + ) + assert mock_mailer.called + assert response.location == f"http://test.ckan.net/dataset/{dataset['name']}" + + @mock.patch("ckanext.subscribe.mailer.mail_recipient") + def test_signup_to_group_ok(self, mock_mailer, app): + group = Group() + response = app.post( + "/subscribe/signup", + params={"email": "bob@example.com", "group": group["id"]}, + status=302, + follow_redirects=False, + ) + assert mock_mailer.called + assert response.location == f"http://test.ckan.net/group/{group['name']}" + + @mock.patch("ckanext.subscribe.mailer.mail_recipient") + def test_signup_to_org_ok(self, mock_mailer, app): + org = Organization() + response = app.post( + "/subscribe/signup", + params={"email": "bob@example.com", "group": org["id"]}, + status=302, + follow_redirects=False, + ) + assert mock_mailer.called + assert response.location == f"http://test.ckan.net/organization/{org['name']}" + + def test_get_not_post(self, app): + # This url can only receive POST requests + response = app.get("/subscribe/signup", status=404) + assert "The requested URL was not found on the server" in response.body + + def test_object_not_specified(self, app): + response = app.post( + "/subscribe/signup", + params={"email": "bob@example.com"}, # no dataset or group + status=200, + ) + assert ( + "Error subscribing: Must specify one of: " + ""dataset_id"" in response.body + ) + + def test_dataset_missing(self, app): + response = app.post( + "/subscribe/signup", + params={"email": "bob@example.com", "dataset": "unknown"}, + status=404, + ) + assert "Dataset not found" in response.body + + def test_group_missing(self, app): + response = app.post( + "/subscribe/signup", + params={"email": "bob@example.com", "group": "unknown"}, + status=404, + ) + assert "Group not found" in response.body + + def test_empty_email(self, app): + dataset = Dataset() + response = app.post( + "/subscribe/signup", + params={"email": "", "dataset": dataset["id"]}, + status=400, + ) + assert "No email address supplied" in response.body + + def test_bad_email(self, app): + dataset = Dataset() + response = app.post( + "/subscribe/signup", + params={"email": "invalid email", "dataset": dataset["id"]}, + status=400, + ) + assert "Email supplied is invalid" in response.body + + +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") +class TestVerifySubscription(object): + @mock.patch("ckanext.subscribe.mailer.mail_recipient") + def test_verify_dataset_ok(self, mock_mailer, app): + dataset = Dataset() + SubscriptionLowLevel( + object_id=dataset["id"], + object_type="dataset", + email="bob@example.com", + frequency=subscribe_model.Frequency.IMMEDIATE.value, + verification_code="verify_code", + verification_code_expires=datetime.datetime.now() + + datetime.timedelta(hours=1), + ) + + response = app.get("/subscribe/verify", params={"code": "verify_code"}) + assert mock_mailer.called + + assert len(response.history) == 1 + assert response.history[0].status_code == 302 + assert response.history[0].location.startswith( + "http://test.ckan.net/subscribe/manage?code=" + ) + + def test_wrong_code(self, app): + response = app.get( + "/subscribe/verify", + params={"code": "unknown_code"}, + ) + assert len(response.history) == 1 + assert response.history[0].status_code == 302 + assert response.history[0].location == "http://test.ckan.net/" + + +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") +class TestManage(object): + def test_basic(self, app): + dataset = Dataset() + Subscription( + dataset_id=dataset["id"], + email="bob@example.com", + skip_verification=True, + ) + code = email_auth.create_code("bob@example.com") + + response = app.get("/subscribe/manage", params={"code": code}, status=200) + + assert dataset["title"] in response.body + + def test_no_code(self, app): + response = app.get( + "/subscribe/manage", + params={"code": ""}, + status=302, + follow_redirects=False, + ) + + assert response.location.startswith( + "http://test.ckan.net/subscribe/request_manage_code" + ) + + def test_bad_code(self, app): + response = app.get( + "/subscribe/manage", + params={"code": "bad-code"}, + status=302, + follow_redirects=False, + ) + + assert response.location.startswith( + "http://test.ckan.net/subscribe/request_manage_code" + ) + + +@pytest.mark.ckan_config("ckan.plugins", "subscribe activity") +@pytest.mark.usefixtures("with_plugins", "clean_db") +class TestUpdate(object): + def test_submit(self, app): + subscription = Subscription( + email="bob@example.com", + frequency="WEEKLY", + skip_verification=True, + ) + code = email_auth.create_code("bob@example.com") + + response = app.post( + "/subscribe/update", + params={"code": code, "id": subscription["id"], "frequency": "daily"}, + ) + + assert len(response.history) == 1 + assert 302 == response.history[0].status_code + assert response.history[0].location.startswith( + "http://test.ckan.net/subscribe/manage?code=" + ) + assert '

{line}

'.format(line=line) + f'

{line}

' for line in html_lines ).format(**email_vars) @@ -64,14 +64,12 @@ def get_email_vars(code, subscription=None, email=None): assert code assert subscription or email unsubscribe_all_link = p.toolkit.url_for( - controller="ckanext.subscribe.controller:SubscribeController", - action="unsubscribe_all", + "subscribe.unsubscribe_all", code=code, qualified=True, ) manage_link = p.toolkit.url_for( - controller="ckanext.subscribe.controller:SubscribeController", - action="manage", + "subscribe.manage", code=code, qualified=True, ) @@ -89,19 +87,15 @@ def get_email_vars(code, subscription=None, email=None): else: subscription_object = model.Group.get(subscription.object_id) object_link = p.toolkit.url_for( - controller="package" - if subscription.object_type == "dataset" - else subscription.object_type, - action="read", + f"{subscription.object_type}.read", id=subscription.object_id, qualified=True, ) unsubscribe_link = p.toolkit.url_for( - controller="ckanext.subscribe.controller:SubscribeController", - action="unsubscribe", + "subscribe.unsubscribe", code=code, qualified=True, - **{subscription.object_type: subscription.object_id} + **{subscription.object_type: subscription.object_id}, ) extra_vars.update( object_type=subscription.object_type, diff --git a/dev-requirements.txt b/dev-requirements.txt index 42484a5..a61f397 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1 +1,2 @@ coveralls # for reporting test coverage to coveralls.io in .github/workflows/test.yml +mock diff --git a/setup.py b/setup.py index 3ac0767..670c0bb 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,8 @@ # the version across setup.py and the project code, see # http://packaging.python.org/en/latest/tutorial.html#version version="1.1.0", - description="""CKAN extension that allows users to subscribe to updates WITHOUT requiring loginn""", + description=""" +CKAN extension that allows users to subscribe to updates WITHOUT requiring login""", long_description=long_description, long_description_content_type="text/x-rst", # The project's main homepage. diff --git a/test.ini b/test.ini index 6f784b3..396ad0e 100644 --- a/test.ini +++ b/test.ini @@ -9,7 +9,7 @@ host = 0.0.0.0 port = 5000 [app:main] -use = config:../ckan/test-core.ini +use = config:../../src/ckan/test-core.ini # Insert any custom config settings to be used when running your extension's # tests here.