diff --git a/.docker/Dockerfile.debug.api b/.docker/Dockerfile.debug.api new file mode 100644 index 000000000..38ce2ef78 --- /dev/null +++ b/.docker/Dockerfile.debug.api @@ -0,0 +1,53 @@ +FROM python:3.10-slim-bookworm + +RUN mkdir /badgr_server +WORKDIR /badgr_server + +RUN apt-get clean all && apt-get update && apt-get upgrade -y +RUN apt-get install -y default-libmysqlclient-dev \ + python3-dev \ + python3-cairo \ + build-essential \ + xmlsec1 \ + libxmlsec1-dev \ + pkg-config \ + default-mysql-client \ + curl \ + xz-utils + +RUN pip install uwsgi + +COPY requirements.txt /badgr_server + +COPY crontab /etc/cron.d/crontab + +RUN touch /var/log/cron_cleartokens.log && chmod 644 /var/log/cron_cleartokens.log +RUN touch /var/log/cron_qr_badgerequests.log && chmod 644 /var/log/cron_qr_badgerequests.log +RUN touch /var/log/cron_clear_altcha.log && chmod 644 /var/log/cron_clear_altcha.log +RUN touch /var/log/cron_clear_iframe_urls.log && chmod 644 /var/log/cron_clear_iframe_urls.log + +ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.30/supercronic-linux-amd64 \ + SUPERCRONIC=supercronic-linux-amd64 \ + SUPERCRONIC_SHA1SUM=9f27ad28c5c57cd133325b2a66bba69ba2235799 +ENV TZ="Europe/Berlin" + +RUN curl -fsSLO "$SUPERCRONIC_URL" \ + && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ + && chmod +x "$SUPERCRONIC" \ + && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \ + && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic + +# Get node and install mjml for email templates +ARG NODE_VERSION=v24.6.0 +ARG MJML_VERSION=4.17.1 +RUN curl -fsSL https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz -o node.tar.xz \ + && tar -xf node.tar.xz -C /usr/local --strip-components=1 \ + && rm node.tar.xz +ENV PATH="/usr/local/bin:${PATH}" +RUN npm install -g mjml@${MJML_VERSION} + +# Add timestamp +RUN date +"%d.%m.%y %T" > timestamp + +RUN pip --timeout=1000 install --no-dependencies -r requirements.txt +RUN pip --timeout=1000 install debugpy diff --git a/.docker/Dockerfile.dev.api b/.docker/Dockerfile.dev.api index 952196262..330c9285b 100644 --- a/.docker/Dockerfile.dev.api +++ b/.docker/Dockerfile.dev.api @@ -1,18 +1,62 @@ -FROM python:3.8-slim +FROM python:3.10-slim-bookworm RUN mkdir /badgr_server WORKDIR /badgr_server -RUN apt-get update && apt-get upgrade -y +RUN apt-get clean all && apt-get update && apt-get upgrade -y RUN apt-get install -y default-libmysqlclient-dev \ - python3-dev \ - python3-cairo \ - build-essential \ - xmlsec1 \ - libxmlsec1-dev \ - pkg-config + python3-dev \ + python3-cairo \ + build-essential \ + xmlsec1 \ + libxmlsec1-dev \ + pkg-config \ + default-mysql-client \ + curl \ + xz-utils RUN pip install uwsgi COPY requirements.txt /badgr_server -RUN pip install -r requirements.txt + +COPY entrypoint.dev.sh /badgr_server + +COPY crontab /etc/cron.d/crontab + +COPY openbadges /badgr_server + +COPY openbadges_bakery /badgr_server + +RUN chmod +x entrypoint.dev.sh + +RUN touch /var/log/cron_cleartokens.log && chmod 644 /var/log/cron_cleartokens.log +RUN touch /var/log/cron_qr_badgerequests.log && chmod 644 /var/log/cron_qr_badgerequests.log +RUN touch /var/log/cron_clear_altcha.log && chmod 644 /var/log/cron_clear_altcha.log +RUN touch /var/log/cron_clear_iframe_urls.log && chmod 644 /var/log/cron_clear_iframe_urls.log + +ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.30/supercronic-linux-amd64 \ + SUPERCRONIC=supercronic-linux-amd64 \ + SUPERCRONIC_SHA1SUM=9f27ad28c5c57cd133325b2a66bba69ba2235799 +ENV TZ="Europe/Berlin" + +RUN curl -fsSLO "$SUPERCRONIC_URL" \ + && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ + && chmod +x "$SUPERCRONIC" \ + && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \ + && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic + +# Get node and install mjml for email templates +ARG NODE_VERSION=v24.6.0 +ARG MJML_VERSION=4.17.1 +RUN curl -fsSL https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz -o node.tar.xz \ + && tar -xf node.tar.xz -C /usr/local --strip-components=1 \ + && rm node.tar.xz +ENV PATH="/usr/local/bin:${PATH}" +RUN npm install -g mjml@${MJML_VERSION} + +# Add timestamp +RUN date +"%d.%m.%y %T" > timestamp + +RUN pip --timeout=1000 install --no-dependencies -r requirements.txt + +ENTRYPOINT ["/badgr_server/entrypoint.dev.sh"] diff --git a/.docker/Dockerfile.nginx b/.docker/Dockerfile.nginx deleted file mode 100644 index 3e449e482..000000000 --- a/.docker/Dockerfile.nginx +++ /dev/null @@ -1,9 +0,0 @@ -FROM nginx:latest - -COPY .docker/etc/nginx.conf /etc/nginx/nginx.conf -COPY .docker/etc/site.conf /etc/nginx/sites-available/ - -RUN mkdir -p /etc/nginx/sites-enabled/\ - && ln -s /etc/nginx/sites-available/site.conf /etc/nginx/sites-enabled/ - -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/.docker/Dockerfile.prod.api b/.docker/Dockerfile.prod.api index ededf1765..a40c4030b 100644 --- a/.docker/Dockerfile.prod.api +++ b/.docker/Dockerfile.prod.api @@ -1,23 +1,90 @@ -FROM python:3.8-slim +# Best practies taken from here: https://snyk.io/blog/best-practices-containerizing-python-docker/ + +# ------------------------------> Build image +FROM python:3.10-slim-bookworm AS build +RUN apt-get update +RUN apt-get install -y default-libmysqlclient-dev \ + python3-dev \ + python3-cairo \ + build-essential \ + xmlsec1 \ + libxmlsec1-dev \ + pkg-config \ + cron RUN mkdir /badgr_server WORKDIR /badgr_server +RUN python -m venv /badgr_server/venv +ENV PATH="/badgr_server/venv/bin:$PATH" + +COPY requirements.txt . +RUN pip install --no-dependencies -r requirements.txt -RUN apt-get update && apt-get upgrade -y +# ------------------------------> Final image +FROM python:3.10-slim-bookworm +RUN apt-get clean all && apt-get update RUN apt-get install -y default-libmysqlclient-dev \ - python3-dev \ - python3-cairo \ - build-essential \ - xmlsec1 \ - libxmlsec1-dev \ - pkg-config - -COPY requirements.txt /badgr_server -COPY manage.py /badgr_server -COPY .docker/etc/uwsgi.ini /badgr_server -COPY .docker/etc/wsgi.py /badgr_server -COPY apps /badgr_server/apps -COPY .docker/etc/settings_local.py /badgr_server/apps/mainsite/ - -RUN pip install uwsgi -RUN pip install -r requirements.txt + python3-cairo \ + libxml2 \ + default-mysql-client \ + cron \ + curl \ + xz-utils + +RUN groupadd -g 999 python && \ + useradd -r -u 999 -g python python + +RUN mkdir /badgr_server && chown python:python /badgr_server +RUN mkdir /backups && chown python:python /backups + +WORKDIR /badgr_server + +# Copy installed dependencies +COPY --chown=python:python --from=build /badgr_server/venv /badgr_server/venv + +# Copy everything related Django stuff +COPY --chown=python:python manage.py . +COPY --chown=python:python .docker/etc/uwsgi.ini . +COPY --chown=python:python .docker/etc/wsgi.py . +COPY --chown=python:python apps ./apps +COPY --chown=python:python openbadges ./openbadges +COPY --chown=python:python openbadges_bakery ./openbadges_bakery +COPY --chown=python:python .docker/etc/settings_local.py ./apps/mainsite/ +COPY --chown=python:python entrypoint.sh . + +RUN chmod +x entrypoint.sh + +# Latest releases available at https://github.com/aptible/supercronic/releases +ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.30/supercronic-linux-amd64 \ + SUPERCRONIC=supercronic-linux-amd64 \ + SUPERCRONIC_SHA1SUM=9f27ad28c5c57cd133325b2a66bba69ba2235799 +ENV TZ="Europe/Berlin" + +RUN curl -fsSLO "$SUPERCRONIC_URL" \ + && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ + && chmod +x "$SUPERCRONIC" \ + && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \ + && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic + +# Get node and install mjml for email templates +ARG NODE_VERSION=v24.6.0 +ARG MJML_VERSION=4.17.1 +RUN curl -fsSL https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz -o node.tar.xz \ + && tar -xf node.tar.xz -C /usr/local --strip-components=1 \ + && rm node.tar.xz +ENV PATH="/usr/local/bin:${PATH}" +RUN npm install -g mjml@${MJML_VERSION} + +# Add timestamp +RUN date +"%d.%m.%y %T" > timestamp && chown python:python timestamp + +USER 999 + +# Create necessary log files as user +RUN touch /var/log/cron_cleartokens.log && chmod 644 /var/log/cron_cleartokens.log +RUN touch /var/log/cron_qr_badgerequests.log && chmod 644 /var/log/cron_qr_badgerequests.log +RUN touch /var/log/cron_clear_altcha.log && chmod 644 /var/log/cron_clear_altcha.log +RUN touch /var/log/cron_clear_iframe_urls.log && chmod 644 /var/log/cron_clear_iframe_urls.log + +ENV PATH="/badgr_server/venv/bin:$PATH" +ENTRYPOINT ["./entrypoint.sh"] diff --git a/.docker/etc/nginx.conf b/.docker/config/nginx/nginx.conf similarity index 58% rename from .docker/etc/nginx.conf rename to .docker/config/nginx/nginx.conf index 1a0394bb4..4ba6044fb 100644 --- a/.docker/etc/nginx.conf +++ b/.docker/config/nginx/nginx.conf @@ -17,11 +17,19 @@ http { sendfile on; keepalive_timeout 65; + client_max_body_size 10M; gzip on; gzip_types text/plain application/xml; gzip_proxied expired no-cache no-store private auth; gzip_vary on; + map $http_origin $allowed_origin { + default ""; + "https://develop.openbadges.education" "https://develop.openbadges.education"; + "https://staging.openbadges.education" "https://staging.openbadges.education"; + "https://openbadges.education" "https://openbadges.education"; + } + include /etc/nginx/sites-available/*; } \ No newline at end of file diff --git a/.docker/config/nginx/sites-available/site.conf b/.docker/config/nginx/sites-available/site.conf new file mode 100644 index 000000000..7f20690cf --- /dev/null +++ b/.docker/config/nginx/sites-available/site.conf @@ -0,0 +1,31 @@ +upstream uwsgi { + server unix:/sock/app.sock; +} + +server { + listen 80; + charset utf-8; + + location /media { + alias /mediafiles; + add_header Access-Control-Allow-Origin $allowed_origin always; + add_header Vary Origin; + } + + location /static { + alias /staticfiles; + add_header Access-Control-Allow-Origin *; + } + + location / { + uwsgi_pass uwsgi; + + uwsgi_param HTTP_HOST $http_host; + uwsgi_param HTTP_REFERER $http_referer; + uwsgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for; + uwsgi_param HTTP_X_FORWARDED_PROTO $scheme; + + include /etc/nginx/uwsgi_params; + } + +} diff --git a/.docker/etc/init.sql b/.docker/etc/init.sql index 7a605eca6..3e5862d20 100644 --- a/.docker/etc/init.sql +++ b/.docker/etc/init.sql @@ -1 +1 @@ -CREATE DATABASE IF NOT EXISTS badgr; \ No newline at end of file +CREATE DATABASE IF NOT EXISTS badgr CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; \ No newline at end of file diff --git a/.docker/etc/settings_local.dev.py.example b/.docker/etc/settings_local.dev.py.example index 36d71dfdf..89558247f 100644 --- a/.docker/etc/settings_local.dev.py.example +++ b/.docker/etc/settings_local.dev.py.example @@ -28,7 +28,8 @@ DATABASES = { 'HOST': 'db', 'PORT': '', 'OPTIONS': { -# "SET character_set_connection=utf8mb3, collation_connection=utf8_unicode_ci", # Uncomment when using MySQL to ensure consistency across servers + 'charset': 'utf8mb4', + 'init_command': "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_general_ci'" }, } } @@ -41,7 +42,7 @@ DATABASES = { ### CACHES = { 'default': { - 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', 'LOCATION': 'memcached:11211', 'KEY_FUNCTION': 'mainsite.utils.filter_cache_key' } @@ -57,15 +58,18 @@ CACHES = { DEFAULT_FROM_EMAIL = '' # e.g. "noreply@example.com" EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +# use these settings if you want to use mailhog instead of console backend +# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +# EMAIL_HOST = 'mailhog' +# EMAIL_PORT = 1025 ### -# -# Celery Asynchronous Task Processing (Optional) -# -### -CELERY_RESULT_BACKEND = None -# Run celery tasks in same thread as webserver (True means that asynchronous processing is OFF) -CELERY_ALWAYS_EAGER = True +# Using dedicated Celery workers for asynchronous tasks (required e.g. for asynchronous batch badge-awarding) +# Set to True to run celery tasks in same thread as webserver (True means that asynchronous processing is OFF) +CELERY_ALWAYS_EAGER = False + +CELERY_BROKER_URL = 'redis://redis:6379/0' +CELERY_RESULT_BACKEND = 'redis://redis:6379/0' ### @@ -77,10 +81,14 @@ HTTP_ORIGIN = 'http://localhost:8000' ALLOWED_HOSTS = ['*'] STATIC_URL = HTTP_ORIGIN + '/static/' -CORS_ORIGIN_ALLOW_ALL = False -CORS_ORIGIN_WHITELIST = ( +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [ 'http://localhost:4200', -) + 'http://localhost:8000', +] + +CSRF_COOKIE_DOMAIN = '' +CSRF_TRUSTED_ORIGINS = [] # Optionally restrict issuer creation to accounts that have the 'issuer.add_issuer' permission BADGR_APPROVED_ISSUERS_ONLY = False @@ -105,47 +113,58 @@ LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': [], - 'class': 'django.utils.log.AdminEmailHandler' - }, - - # badgr events log to disk by default - 'badgr_events': { + # Only logs to the console appear in the docker / grafana logs + 'console': { 'level': 'INFO', - 'formatter': 'json', - 'class': 'logging.FileHandler', - 'filename': os.path.join(LOGS_DIR, 'badgr_events.log') - } + 'formatter': 'default', + 'class': 'logging.StreamHandler' + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", }, 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, - }, - # Badgr.Events emits all badge related activity 'Badgr.Events': { - 'handlers': ['badgr_events'], - 'level': 'INFO', - 'propagate': False, - - } - + 'handlers': ['console'], + 'level': 'DEBUG', + }, }, 'formatters': { 'default': { 'format': '%(asctime)s %(levelname)s %(module)s %(message)s' - }, - 'json': { - '()': 'mainsite.formatters.JsonFormatter', - 'format': '%(asctime)s', - 'datefmt': '%Y-%m-%dT%H:%M:%S%z', } }, } NOUNPROJECT_API_KEY = '' -NOUNPROJECT_SECRET = '' \ No newline at end of file +NOUNPROJECT_SECRET = '' + +AISKILLS_API_KEY = '' +AISKILLS_ENDPOINT_CHATS = '' +AISKILLS_ENDPOINT_KEYWORDS = '' +AISKILLS_ENDPOINT_TREE = '' + +OIDC_RP_CLIENT_ID = '' +OIDC_RP_CLIENT_SECRET = '' +OIDC_OP_AUTHORIZATION_ENDPOINT = '' +OIDC_OP_TOKEN_ENDPOINT = '' +OIDC_OP_USER_ENDPOINT = '' +OIDC_OP_JWKS_ENDPOINT = '' +OIDC_OP_END_SESSION_ENDPOINT = '' +LOGIN_BASE_URL = '' + +# Don't change these +LOGIN_REDIRECT_URL = f'{LOGIN_BASE_URL}?validateToken' +LOGOUT_REDIRECT_URL = f'{LOGIN_BASE_URL}' + +ALTCHA_API_KEY = '' +ALTCHA_SECRET = '' + +ALTCHA_SPAMFILTER_ENDPOINT = 'https://eu.altcha.org/api/v1/classify?' + +# CMS contents +CMS_API_BASE_URL = '' +CMS_API_BASE_PATH = '' +CMS_API_KEY = '' diff --git a/.docker/etc/settings_local.prod.py.example b/.docker/etc/settings_local.prod.py.example index 4f42b1207..abb21379e 100644 --- a/.docker/etc/settings_local.prod.py.example +++ b/.docker/etc/settings_local.prod.py.example @@ -28,7 +28,8 @@ DATABASES = { 'HOST': 'db', 'PORT': '', 'OPTIONS': { -# "SET character_set_connection=utf8mb3, collation_connection=utf8_unicode_ci", # Uncomment when using MySQL to ensure consistency across servers + 'charset': 'utf8mb4', + 'init_command': "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_general_ci'" }, } } @@ -41,7 +42,7 @@ DATABASES = { ### CACHES = { 'default': { - 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', 'LOCATION': 'memcached:11211', 'KEY_FUNCTION': 'mainsite.utils.filter_cache_key' } @@ -63,9 +64,12 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Celery Asynchronous Task Processing (Optional) # ### -CELERY_RESULT_BACKEND = None -# Run celery tasks in same thread as webserver (True means that asynchronous processing is OFF) -CELERY_ALWAYS_EAGER = True +# Using dedicated Celery workers for asynchronous tasks (required e.g. for asynchronous batch badge-awarding) +# Set to True to run celery tasks in same thread as webserver (True means that asynchronous processing is OFF) +CELERY_ALWAYS_EAGER = False + +CELERY_BROKER_URL = 'redis://redis:6379/0' +CELERY_RESULT_BACKEND = 'redis://redis:6379/0' ### @@ -77,6 +81,10 @@ HTTP_ORIGIN = 'http://localhost:8080' ALLOWED_HOSTS = ['*'] STATIC_URL = HTTP_ORIGIN + '/static/' +CSRF_COOKIE_DOMAIN = '' +CSRF_TRUSTED_ORIGINS = [] + + # Optionally restrict issuer creation to accounts that have the 'issuer.add_issuer' permission BADGR_APPROVED_ISSUERS_ONLY = False @@ -100,47 +108,58 @@ LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': [], - 'class': 'django.utils.log.AdminEmailHandler' - }, - - # badgr events log to disk by default - 'badgr_events': { + # Only logs to the console appear in the docker / grafana logs + 'console': { 'level': 'INFO', - 'formatter': 'json', - 'class': 'logging.FileHandler', - 'filename': os.path.join(LOGS_DIR, 'badgr_events.log') - } + 'formatter': 'default', + 'class': 'logging.StreamHandler' + }, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", }, 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, - }, - # Badgr.Events emits all badge related activity 'Badgr.Events': { - 'handlers': ['badgr_events'], - 'level': 'INFO', - 'propagate': False, - - } - + 'handlers': ['console'], + # INFO contains sensible information about users + 'level': 'WARNING', + }, }, 'formatters': { 'default': { 'format': '%(asctime)s %(levelname)s %(module)s %(message)s' - }, - 'json': { - '()': 'mainsite.formatters.JsonFormatter', - 'format': '%(asctime)s', - 'datefmt': '%Y-%m-%dT%H:%M:%S%z', } }, } NOUNPROJECT_API_KEY = '' -NOUNPROJECT_SECRET = '' \ No newline at end of file +NOUNPROJECT_SECRET = '' + +AISKILLS_API_KEY = '' +AISKILLS_ENDPOINT_CHATS = '' +AISKILLS_ENDPOINT_KEYWORDS = '' +AISKILLS_ENDPOINT_TREE = '' + +OIDC_RP_CLIENT_ID = '' +OIDC_RP_CLIENT_SECRET = '' +OIDC_OP_AUTHORIZATION_ENDPOINT = '' +OIDC_OP_TOKEN_ENDPOINT = '' +OIDC_OP_USER_ENDPOINT = '' +OIDC_OP_JWKS_ENDPOINT = '' +OIDC_OP_END_SESSION_ENDPOINT = '' +LOGIN_BASE_URL = '' + +# Don't change these +LOGIN_REDIRECT_URL = f'{LOGIN_BASE_URL}?validateToken' +LOGOUT_REDIRECT_URL = f'{LOGIN_BASE_URL}' + +ALTCHA_API_KEY = '' +ALTCHA_SECRET = '' +ALTCHA_SPAMFILTER_ENDPOINT = "https://eu.altcha.org/api/v1/classify?" + +# CMS contents +CMS_API_BASE_URL = '' +CMS_API_BASE_PATH = '' +CMS_API_KEY = '' diff --git a/.docker/etc/site.conf b/.docker/etc/site.conf deleted file mode 100644 index 6ccc4f3b6..000000000 --- a/.docker/etc/site.conf +++ /dev/null @@ -1,26 +0,0 @@ -upstream uwsgi { - server unix:/sock/app.sock; -} - -server { - listen 80; - charset utf-8; - - location /media { - alias /mediafiles; - add_header Access-Control-Allow-Origin "https://mybadges.org"; - } - - location /static { - alias /staticfiles; - } - - location / { - uwsgi_pass uwsgi; - - proxy_set_header Host $http_host; - - include /etc/nginx/uwsgi_params; - } - -} diff --git a/.docker/etc/uwsgi.ini b/.docker/etc/uwsgi.ini index b6b8f42ac..8e0ac2591 100644 --- a/.docker/etc/uwsgi.ini +++ b/.docker/etc/uwsgi.ini @@ -7,3 +7,8 @@ threads=2 chdir=/badgr_server module=wsgi:application vacuum=true +gid=999 +uid=999 + +buffer-size = 8192 +header-buffer-size = 8192 diff --git a/.docker/etc/wsgi.py b/.docker/etc/wsgi.py index c9014721f..ee3769002 100644 --- a/.docker/etc/wsgi.py +++ b/.docker/etc/wsgi.py @@ -1,14 +1,13 @@ import os import sys +from django.core.wsgi import get_wsgi_application OUR_DIR = os.path.abspath(os.path.dirname(__file__)) -APPS_DIR = os.path.join(OUR_DIR, 'apps') +APPS_DIR = os.path.join(OUR_DIR, "apps") sys.path.insert(0, APPS_DIR) -from django.core.wsgi import get_wsgi_application - os.environ["DJANGO_SETTINGS_MODULE"] = "mainsite.settings_local" -application = get_wsgi_application() \ No newline at end of file +application = get_wsgi_application() diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..4f92a467d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Tab indentation +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +# The indent size used in the `package.json` file cannot be changed +# https://github.com/npm/npm/pull/3180#issuecomment-16336516 +[{.travis.yml,npm-shrinkwrap.json,package.json}] +indent_style = space +indent_size = 4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..79330b237 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/registry-build-push.yml b/.github/workflows/registry-build-push.yml new file mode 100644 index 000000000..2deeafb05 --- /dev/null +++ b/.github/workflows/registry-build-push.yml @@ -0,0 +1,58 @@ +name: đŸ—ī¸ Build and publish to Github Container Registry + +on: + push: + branches: [main, production, develop] + tags: ["v*.*.*"] + pull_request: + branches: + - production + - main + - develop + merge_group: + branches: + - production + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: âŦ‡ī¸ Checkout repository + uses: actions/checkout@v6 + + - name: 🏄 Copy default env vars + run: cp .docker/etc/settings_local.prod.py.example .docker/etc/settings_local.py + + - name: 🔑 Log in to the Container registry + uses: docker/login-action@v3.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 📋 Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5.10.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=auto + prefix= + suffix= + + - name: đŸ—ī¸ Build and push Docker image + uses: docker/build-push-action@v6.18.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/registry-purge-pr.yml b/.github/workflows/registry-purge-pr.yml new file mode 100644 index 000000000..c9ed15433 --- /dev/null +++ b/.github/workflows/registry-purge-pr.yml @@ -0,0 +1,22 @@ +name: đŸ—‘ī¸ Purge Pull Request Image + +# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request +# Purge Pull Request Image if pull request gets closed +on: + pull_request: + types: [closed] + +permissions: + packages: write + +jobs: + purge_pr_image: + runs-on: ubuntu-latest + steps: + - name: đŸ’Ŗ Purge Pull Request Image + uses: vlaurin/action-ghcr-prune@v0.6.0 + with: + token: ${{ secrets.GITHUB_TOKEN}} + organization: ${{ github.repository_owner}} + container: ${{ github.event.repository.name }} + prune-tags-regexes: pr-${{github.event.pull_request.number}}$ \ No newline at end of file diff --git a/.github/workflows/registry-purge.yml b/.github/workflows/registry-purge.yml new file mode 100644 index 000000000..e68b272a7 --- /dev/null +++ b/.github/workflows/registry-purge.yml @@ -0,0 +1,22 @@ +name: đŸ—‘ī¸ Purge untagged images + +# https://docs.github.com/en/actions/reference/events-that-trigger-workflows#registry_package +# Run cleanup job every day +on: + schedule: + - cron: "0 0 * * *" + +permissions: + packages: write + +jobs: + purge_untagged_images: + runs-on: ubuntu-latest + steps: + - name: đŸ’Ŗ Purge untagged images + uses: vlaurin/action-ghcr-prune@v0.6.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + organization: ${{ github.repository_owner }} + container: ${{ github.event.repository.name }} + prune-untagged: true \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..e540e1c16 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +on: + push: + branches: + - production + +jobs: + release-on-merge: + runs-on: ubuntu-latest + permissions: + contents: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Create release and release notes + uses: dexwritescode/release-on-merge-action@v1 + with: + version-increment-strategy: minor + initial-version: '2.10.0' + tag-prefix: v + generate-release-notes: true diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml new file mode 100644 index 000000000..e13e415d2 --- /dev/null +++ b/.github/workflows/sync.yml @@ -0,0 +1,55 @@ +name: 🔄 Snyc nginx configuration + +on: + push: + branches: + - main + - develop + paths: + - '.docker/config/nginx/**' + +jobs: + sync: + runs-on: ubuntu-24.04 + environment: ${{ github.ref == 'refs/heads/main' && 'staging' || 'development' }} # check branch and decide which environment to use + + steps: + - name: 🚀 Checkout repository + uses: actions/checkout@v6 + + - name: âš™ī¸ Configure SSH + run: | + mkdir -p ~/.ssh/ + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy.key + ssh-keyscan -t ed25519 ${{ secrets.REMOTE_HOST }} >> ~/.ssh/known_hosts + chmod 600 ~/.ssh/deploy.key ~/.ssh/known_hosts + chmod 700 ~/.ssh/ + cat >>~/.ssh/config < Build image +FROM python:3.10-slim-bookworm AS build +RUN apt-get clean all && apt-get update +RUN apt-get install -y default-libmysqlclient-dev \ + python3-dev \ + python3-cairo \ + build-essential \ + xmlsec1 \ + libxmlsec1-dev \ + pkg-config \ + curl + +RUN mkdir /badgr_server +WORKDIR /badgr_server +RUN python -m venv /badgr_server/venv +ENV PATH="/badgr_server/venv/bin:$PATH" +ENV TZ="Europe/Berlin" + +COPY requirements.txt . +RUN pip install --no-dependencies -r requirements.txt + +# ------------------------------> Final image +FROM python:3.10-slim-bookworm +RUN apt-get update +RUN apt-get install -y default-libmysqlclient-dev \ + python3-cairo \ + libxml2 \ + curl \ + default-mysql-client \ + xz-utils + +RUN groupadd -g 999 python && \ + useradd -r -u 999 -g python python + +RUN mkdir /badgr_server && chown python:python /badgr_server +RUN mkdir /backups && chown python:python /backups + +RUN touch /badgr_server/user_emails.csv && chown python:python /badgr_server/user_emails.csv +RUN touch /badgr_server/esco_issuers.txt && chown python:python /badgr_server/esco_issuers.txt + +WORKDIR /badgr_server + +# Copy installed dependencies +COPY --chown=python:python --from=build /badgr_server/venv /badgr_server/venv + +# Copy everything related Django stuff +COPY --chown=python:python manage.py . +COPY --chown=python:python .docker/etc/uwsgi.ini . +COPY --chown=python:python .docker/etc/wsgi.py . +COPY --chown=python:python apps ./apps +COPY --chown=python:python openbadges ./openbadges +COPY --chown=python:python openbadges_bakery ./openbadges_bakery +COPY --chown=python:python .docker/etc/settings_local.py ./apps/mainsite/settings_local.py +COPY --chown=python:python entrypoint.sh . +COPY --chown=python:python crontab /etc/cron.d/crontab + +RUN chmod +x entrypoint.sh + +RUN touch /var/log/cron_cleartokens.log && \ + chown python:python /var/log/cron_cleartokens.log && \ + chmod 644 /var/log/cron_cleartokens.log + +RUN touch /var/log/cron_qr_badgerequests.log && \ + chown python:python /var/log/cron_qr_badgerequests.log && \ + chmod 644 /var/log/cron_qr_badgerequests.log + +RUN touch /var/log/cron_clear_altcha.log \ + && chmod 644 /var/log/cron_clear_altcha.log + +RUN touch /var/log/cron_clear_iframe_urls.log \ + && chmod 644 /var/log/cron_clear_iframe_urls.log + +# Latest releases available at https://github.com/aptible/supercronic/releases +ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.30/supercronic-linux-amd64 \ + SUPERCRONIC=supercronic-linux-amd64 \ + SUPERCRONIC_SHA1SUM=9f27ad28c5c57cd133325b2a66bba69ba2235799 +ENV TZ="Europe/Berlin" + +RUN curl -fsSLO "$SUPERCRONIC_URL" \ + && echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ + && chmod +x "$SUPERCRONIC" \ + && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \ + && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic + +# Get node and install mjml for email templates +ARG NODE_VERSION=v24.6.0 +ARG MJML_VERSION=4.17.1 +RUN curl -fsSL https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-x64.tar.xz -o node.tar.xz \ + && tar -xf node.tar.xz -C /usr/local --strip-components=1 \ + && rm node.tar.xz +ENV PATH="/usr/local/bin:${PATH}" +RUN npm install -g mjml@${MJML_VERSION} + +# Add timestamp +RUN date +"%d.%m.%y %T" > timestamp && chown python:python timestamp + +USER 999 +COPY entrypoint.dev.sh /badgr_server/entrypoint.dev.sh +RUN chmod +x /badgr_server/entrypoint.dev.sh +ENV PATH="/badgr_server/venv/bin:$PATH" +ENTRYPOINT ["./entrypoint.sh"] diff --git a/README.md b/README.md index d01f9d865..884847e8e 100644 --- a/README.md +++ b/README.md @@ -1,141 +1,288 @@ # Badgr Server -*Digital badge management for issuers, earners, and consumers* + +_Digital badge management for issuers, earners, and consumers_ Badgr-server is the Python/Django API backend for issuing [Open Badges](http://openbadges.org). In addition to a powerful Issuer API and browser-based user interface for issuing, Badgr offers integrated badge management and sharing for badge earners. Free accounts are hosted by Concentric Sky at [Badgr.com](http://info.badgr.com), but for complete control over your own issuing environment, Badgr Server is available open source as a Python/Django application. See also [badgr-ui](https://github.com/concentricsky/badgr-ui), the front end written in Angular that serves as users' interface for this project. -### About the Badgr Project +## About the Badgr Project + Badgr was developed by [Concentric Sky](https://concentricsky.com), starting in 2015 to serve as an open source reference implementation of the Open Badges Specification. It provides functionality to issue portable, verifiable Open Badges as well as to allow users to manage badges they have been awarded by any issuer that uses this open data standard. Since 2015, Badgr has grown to be used by hundreds of educational institutions and other people and organizations worldwide. See [Project Homepage](https://badgr.org) for more details about contributing to and integrating with Badgr. -### Open Badges Implementation +## Open Badges Implementation + Badgr-server hosts standard-compliant endpoints that implement the [Open Badges 2.0 specification](https://openbadgespec.org). For each of the core Open Badges objects Issuer, BadgeClass and Assertion, there is a standards-compliant public JSON endpoint handled by the Django application as well as an image -redirect path. +redirect path. Each JSON endpoint, such as `/public/assertions/{entity_id}`, performs content negotiation. It will return a standardized JSON-LD payload when the path is requested with no `Accept` header or when JSON payloads are requested. Additionally, User-Agent detection allows bots attempting to render a preview card for social sharing to access a clean HTML response that includes [Open Graph](https://ogp.me/) meta tags. Other clients requesting `text/html` will receive a redirect to the corresponding public route on the UI application that runs in parallel to Badgr-server where humans -can be presented with a representation of the badge data in their browser. +can be presented with a representation of the badge data in their browser. Each image endpoint typically redirects to an image within the associated storage system. The system can convert from SVG to PNG and adapt images to a common "wide" radio for the images needed for card-based previews in many social network systems. ## How to get started on your local development environment. + Prerequisites: -* Install docker (see [instructions](https://docs.docker.com/install/)) +- Install docker (see [instructions](https://docs.docker.com/install/)) +- Install python + - Make sure you have the version(s) installed referenced in the [.pre-commit-config.yaml](.pre-commit-config.yaml) + - Also install `python-devel`, required to run the pre-commit hooks + +### Setup your IDE/Editor + +[Ruff](https://github.com/astral-sh/ruff) is used for linting and formatting. +It is providing an [LSP](https://github.com/astral-sh/ruff-lsp) for all editors supporting it. + +_Note: For Visual Studio Code, the LSP is part of the extension and does not need to be installed separately._ + +#### Visual Studio Code + +Setup is easiest with VS Code using the [recommended extensions](.vscode/extensions.json) [settings.default.json](.vscode/settings.default.json). +To install extensions look for "Extensions: Show Recommended Extensions" via the [Command Palette](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette) and install the highlighted extensions. + +To set up your editor, copy the values from [settings.default.json](.vscode/settings.default.json) to your [User or Workspace (recommended) Settings](https://code.visualstudio.com/docs/configure/settings). +This will e.g. setup `ruff` as the default formatter and make sure linting works as expected. ### Copy local settings example file Copy the example development settings: - * `cp .docker/etc/settings_local.dev.py.example .docker/etc/settings_local.dev.py` - -**NOTE**: you *may* wish to copy and edit the production config. See Running the Django Server in "Production" below for more details. - * `cp .docker/etc/settings_local.prod.py.example .docker/etc/settings_local.prod.py` + +- `cp .docker/etc/settings_local.dev.py.example .docker/etc/settings_local.dev.py` + +**NOTE**: you _may_ wish to copy and edit the production config. See Running the Django Server in "Production" below for more details. + +- `cp .docker/etc/settings_local.prod.py.example .docker/etc/settings_local.prod.py` ### Customize local settings to your environment - + Edit the `settings_local.dev.py` and/or `settings_local.prod.py` to adjust the following settings: -* Set `DEFAULT_FROM_EMAIL` to an address, for instance `"noreply@localhost"` - * The default `EMAIL_BACKEND= 'django.core.mail.backends.console.EmailBackend'` will log email content to console, which is often adequate for development. Other options are available. See Django docs for [sending email](https://docs.djangoproject.com/en/1.11/topics/email/). -* Set `SECRET_KEY` and `UNSUBSCRIBE_SECRET_KEY` each to (different) cryptographically secure random values. - * Generate values with: `python -c "import base64; import os; print(base64.b64encode(os.urandom(30)).decode('utf-8'))"` -* Set `AUTHCODE_SECRET_KEY` to a 32 byte url-safe base64-encoded random string. This key is used for symmetrical encryption of authentication tokens. If not defined, services like OAuth will not work. - * Generate a value with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key())"` - + +- Set `DEFAULT_FROM_EMAIL` to an address, for instance `"noreply@localhost"` + - The default `EMAIL_BACKEND= 'django.core.mail.backends.console.EmailBackend'` will log email content to console, which is often adequate for development. Other options are available. See Django docs for [sending email](https://docs.djangoproject.com/en/1.11/topics/email/). +- Set `SECRET_KEY` and `UNSUBSCRIBE_SECRET_KEY` each to (different) cryptographically secure random values. + - Generate values with: `python -c "import base64; import os; print(base64.b64encode(os.urandom(30)).decode('utf-8'))"` + - Remove that part `.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(40))` to prevent issues with the admin panel login +- Set `AUTHCODE_SECRET_KEY` to a 32 byte url-safe base64-encoded random string. This key is used for symmetrical encryption of authentication tokens. If not defined, services like OAuth will not work. + - Generate a value with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key())"` + #### Additional configuration options + Set or adjust these values in your `settings_local.dev.py` and/or `settings_local.prod.py` file to further configure the application to your specific needs. -* `HELP_EMAIL`: - - An email address for your support staff. The default is `help@badgr.io`. -* `BADGR_APPROVED_ISSUERS_ONLY`: - - If you choose to use set this value to `True`, that means new user accounts will not be able to define new issuers (though they can be added as staff on issuers defined by others) unless they have the Django user permission 'issuer.add_issuer'. The recommended way to grant users this privilege is to create a group that grants it in the `/staff` admin area and addthe appropriate users to that group. -* `PINGDOM_MONITORING_ID`: - - If you use [Pingdom](https://www.pingdom.com/) to monitor site performance, including this setting will embed Pingdom tracking script into the header. -* `CELERY_ALWAYS_EAGER`: - - Setting this value to `True` causes Celery to immediately run tasks synchronously. Celery is an asynchronous task runner built into Django and Badgr. Advanced deployments may separate celery workers from web nodes for improved performance. For development environments where Celery tasks should run synchronously, set this flag to true. Very few time-intensive tasks are part of this repository, and eager is a safe setting for most production deploys. -* `OPEN_FOR_SIGNUP`: - - Allows you to turn off signup through the API by setting to `False` if you would like to use Badgr for only single-account use or to manually create all users in `/staff`. The default is `True` (signup API is enabled). UX is not well-supported in the `/staff` interface. -* `DEFAULT_FILE_STORAGE` and `MEDIA_URL`: - - Django supports various backends for storing media, as applicable for your deployment strategy. See Django docs on the [file storage API](https://docs.djangoproject.com/en/1.11/ref/files/storage/) - + +- `HELP_EMAIL`: + - An email address for your support staff. The default is `help@badgr.io`. +- `BADGR_APPROVED_ISSUERS_ONLY`: + - If you choose to use set this value to `True`, that means new user accounts will not be able to define new issuers (though they can be added as staff on issuers defined by others) unless they have the Django user permission 'issuer.add_issuer'. The recommended way to grant users this privilege is to create a group that grants it in the `/staff` admin area and addthe appropriate users to that group. +- `PINGDOM_MONITORING_ID`: + - If you use [Pingdom](https://www.pingdom.com/) to monitor site performance, including this setting will embed Pingdom tracking script into the header. +- `CELERY_ALWAYS_EAGER`: + - Setting this value to `True` causes Celery to immediately run tasks synchronously. Setting this value to `False` enables asynchronous processing using Celery workers, which can be used e.g. in the + batch badge-awarding process. Celery is an asynchronous task runner built into Django and Badgr. Advanced deployments may separate celery workers from web nodes for improved performance. The default is `False`. When `CELERY_ALWAYS_EAGER=False`, ensure `CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND` are properly configured (defaults to Redis at `redis://redis:6379/0`). +- `OPEN_FOR_SIGNUP`: + - Allows you to turn off signup through the API by setting to `False` if you would like to use Badgr for only single-account use or to manually create all users in `/staff`. The default is `True` (signup API is enabled). UX is not well-supported in the `/staff` interface. +- `DEFAULT_FILE_STORAGE` and `MEDIA_URL`: + - Django supports various backends for storing media, as applicable for your deployment strategy. See Django docs on the [file storage API](https://docs.djangoproject.com/en/1.11/ref/files/storage/) +- `NOUNPROJECT_API_KEY` and `NOUNPROJECT_SECRET`: + - Set these values to be able to search for icons with in the badge creation process. +- `AISKILLS_API_KEY` and `AISKILLS_ENDPOINT_CHATS`, `AISKILLS_ENDPOINT_KEYWORDS` and `AISKILLS_ENDPOINT_TREE`: + - Set these values to be able to get AI skill suggestions within the badge creation process. +- `OIDC_RP_CLIENT_ID` and `OIDC_RP_CLIENT_SECRET` + - The credentials for the meinBildungsraum SSO connection +- `OIDC_OP_AUTHORIZATION_ENDPOINT`, `OIDC_OP_TOKEN_ENDPOINT`, `OIDC_OP_USER_ENDPOINT`, `OIDC_OP_JWKS_ENDPOINT`, `OIDC_OP_END_SESSION_ENDPOINT` + - The endpoints for the meinBildungsraum SSO connection + - For the demo as specified [here](https://aai.demo.meinbildungsraum.de/realms/nbp-aai/.well-known/openid-configuration) +- `LOGIN_BASE_URL` + - The base url for the redirect urls + - E.g. `http://localhost:4200/auth/login` +- `LOGIN_REDIRECT_URL` and `LOGOUT_REDIRECT_URL` + - The redirect urls to our application after login / logout via meinBildungsraum + - After the login with meinBildungsraum, the OIDC session authentication needs to be converted to an access token + - This is done with the `auth/login?validateToken` url + - E.g. `http://localhost:4200/auth/login?validateToken` and `http://localhost:4200/auth/login` + - Typically you don't need to change these if you used the example with `LOGIN_BASE_URL` +- `ALTCHA_API_KEY` and `ALTCHA_SECRET`: + - Set these values for captcha protection during the registration and issuer creation process. They can be obtained at [altcha.org](https://altcha.org/). +- `WEBCOMPONENTS_ASSETS_PATH`: + - `badgr-ui` builds generate a range of web components that are used for our LTI integration. Set this to the URL of the folder that serves these webcomponents e.g. `https://mydomain.tld/webcomponents` + ### Running the Django Server in Development -For development, it is usually best to run the project with the builtin django development server. The +For development, it is usually best to run the project with the builtin django development server. The development server will reload itself in the docker container whenever changes are made to the code in `apps/`. To run the project with docker in a development mode: -* `docker-compose up`: build and get django and other components running -* `docker-compose exec api python /badgr_server/manage.py migrate` - (while running) set up database tables -* `docker-compose exec api python /badgr_server/manage.py dist` - generate docs swagger file(s) -* `docker-compose exec api python /badgr_server/manage.py collectstatic` - Put built front-end assets into the static directory (Admin panel CSS, swagger docs). -* `docker-compose exec api python /badgr_server/manage.py createsuperuser` - follow prompts to create your first admin user account +- `docker compose up`: build and get django and other components running +- `docker compose exec api python manage.py migrate` - (while running) set up database tables +- `docker compose exec api python manage.py dist` - generate docs swagger file(s) +- `docker compose exec api python manage.py collectstatic` - Put built front-end assets into the static directory (Admin panel CSS, swagger docs). +- `docker compose exec api python manage.py createsuperuser` - follow prompts to create your first admin user account ### Running the Django Server in "Production" -By default `docker-compose` will look for a `docker-compose.yml` for instructions of what to do. This file -is the development (and thus default) config for `docker-compose`. +By default `docker compose` will look for a `docker-compose.yml` for instructions of what to do. This file +is the development (and thus default) config for `docker compose`. -If you'd like to run the project with a more production-like setup, you can specify the `docker-compose.prod.yml` +If you'd like to run the project with a more production-like setup, you can specify the `docker-compose.prod.yml` file. This setup **copies** the project code in (instead of mirroring) and uses nginx with uwsgi to run django. -* `docker-compose -f docker-compose.prod.yml up -d` - build and get django and other components (production mode) +- `docker compose -f docker-compose.prod.yml up -d` - build and get django and other components (production mode) -* `docker-compose -f docker-compose.prod.yml exec api python /badgr_server/manage.py migrate` - (while running) set up database tables +- `docker compose -f docker-compose.prod.yml exec api python manage.py migrate` - (while running) set up database tables If you are using the production setup and you have made changes you wish to see reflected in the running container, you will need to stop and then rebuild the production containers: -* `docker-compose -f docker-compose.prod.yml build` - (re)build the production containers +- `docker compose -f docker-compose.prod.yml build` - (re)build the production containers + +- If the extension urls aren't adjusted (or the url changes, or for some other reason it seems as if extension schemas can't be loaded, e.g. because of 401 errors in the badge creation process), run the script in `scripts/change-extension-url.sh`. + +#### Deployment +Checkout `deployment.md` ### Accessing the Django Server Running in Docker The development server will be reachable on port `8000`: -* http://localhost:8000/ (development) +- http://localhost:8000/ (development) The production server will be reachable on port `8080`: -* http://localhost:8080/ (production) +- http://localhost:8080/ (production) + +Note: An error message when accessing the above mentioned URLs is perfectly fine, since the server doesn't actually serve anything on the root url. There are various examples of URLs in this readme and they all feature the development port. You will need to adjust that if you are using the production server. ### First Time Setup -* Sign in to http://localhost:8000/staff/ -* Add an `EmailAddress` object for your superuser. [Edit your super user](http://localhost:8000/staff/badgeuser/badgeuser/1/change/) -* Add an initial `TermsVersion` object + +- Sign in to http://localhost:8000/staff/ +- Add an `EmailAddress` object for your superuser. [Edit your super user](http://localhost:8000/staff/badgeuser/badgeuser/1/change/) +- Add an initial `TermsVersion` object #### Badgr App Configuration -* Sign in to http://localhost:8000/staff -* View the "Badgr app" records and use the staff admin forms to create a BadgrApp. BadgrApp(s) describe the configuration that badgr-server needs to know about an associated installation of badgr-ui. + +- Sign in to http://localhost:8000/staff +- View the "Badgr app" records and use the staff admin forms to create a BadgrApp. BadgrApp(s) describe the configuration that badgr-server needs to know about an associated installation of badgr-ui. If your [badgr-ui](https://github.com/concentricsky/badgr-ui) is running on http://localhost:4000, use the following values: -* CORS: ensure this setting matches the domain on which you are running badgr-ui, including the port if other than the standard HTTP or HTTPS ports. `localhost:4000` -* Signup redirect: `http://localhost:4000/signup/` -* Email confirmation redirect: `http://localhost:4000/auth/login/` -* Forgot password redirect: `http://localhost:4000/change-password/` -* UI login redirect: `http://localhost:4000/auth/login/` -* UI signup success redirect: `http://localhost:4000/signup/success/` -* UI connect success redirect: `http://localhost:4000/profile/` -* Public pages redirect: `http://localhost:4000/public/` + +- CORS: ensure this setting matches the domain on which you are running badgr-ui, including the port if other than the standard HTTP or HTTPS ports. `localhost:4000` +- Oauth authorization redirect: `http://localhost:4000/` +- Signup redirect: `http://localhost:4000/signup/` +- Email confirmation redirect: `http://localhost:4000/auth/login/` +- Forgot password redirect: `http://localhost:4000/change-password/` +- UI login redirect: `http://localhost:4000/auth/login/` +- UI signup success redirect: `http://localhost:4000/signup/success/` +- UI signup failure redirect: `http://localhost:4000/signup/failure/` +- UI connect success redirect: `http://localhost:4000/profile/` +- Public pages redirect: `http://localhost:4000/public/` #### Authentication Configuration -* [Create an OAuth2 Provider Application](http://localhost:8000/staff/oauth2_provider/application/add/) for the Badgr-UI to use with - * Client id: `public` - * Client type: Public - * allowed scopes: `rw:profile rw:issuer rw:backpack` - * Authorization grant type: Resource owner password-based - * Name: `Badgr UI` - * Redirect uris: blank (for Resource owner password-based. You can use this to set up additional OAuth applications that use authorization code token grants as well.) + +- [Create an OAuth2 Provider Application](http://localhost:8000/staff/oauth2_provider/application/add/) for the Badgr-UI to use with + - Client id: `public` + - Client type: Public + - allowed scopes: `rw:profile rw:issuer rw:backpack` + - Authorization grant type: Resource owner password-based + - Name: `Badgr UI` + - Redirect uris: blank (for Resource owner password-based. You can use this to set up additional OAuth applications that use authorization code token grants as well.) + +#### OIDC authentication + +If you set up the _Additional configuration options_ (or at least the parts relevant for OIDC authentication), you shouldn't have to configure anything else; the "Anmelden mit Mein Bildungsraum" button should work out of the box. +Do note that the OIDC authentication mechanism produces access tokens that, in contrast to the ones we generate ourselves, aren't restricted to any scopes. +They can thus access anything on the page not limited to admin / superuser users. This also is the default behavior for the tokens we generate ourselves. + +### Run the tests + +For the tests to run you first need to run docker (`docker compose up`). +Then within docker, run `tox`: `docker compose exec api tox`. +Note that you might have to run `docker compose build` once for the new changes to the testing enviornment to take effect. +To just run a single test: + +```bash +docker compose exec api python /badgr_server/manage.py test -k +# Example: +docker compose exec api python /badgr_server/manage.py test issuer.tests.test_issuer.IssuerTests.test_cant_create_issuer_with_unverified_email_v1 +``` + +### Debug + +For debugging, in the `Dockerfile.debug.api` `debugpy` is also installed and there is the docker compose file `docker-compose.debug.yml`. +In VSCode you can create a `launch.json` by choosing `Python` as debugger and `Remote Attach` as debug configuration (and defaults for the rest). +You can then start the application with + +```bash +docker compose -f docker-compose.debug.yml up +``` + +and attach the debugger in VSCode by selecting _Python: Remote Attach_. +This process is heavily inspired by [this tutorial](https://dev.to/ferkarchiloff/how-to-debug-django-inside-a-docker-container-with-vscode-4ef9). ### Install and run Badgr UI {#badgr-ui} + Start in your `badgr` directory and clone badgr-ui source code: `git clone https://github.com/concentricsky/badgr-ui.git badgr-ui` For more details view the Readme for [Badgr UI](https://github.com/concentricsky/badgr-ui). + +### Code Quality + +To ensure consistency and quality in code contributions, we use pre-commit hooks to adhere to commit message conventions and code quality guidelines. Follow these steps to set up your development environment: + +- Install Pre-commit + +Make sure you have `pre-commit` installed on your machine. You can install it using pip: + +```bash +pip install pre-commit +``` + +- Initialize Pre-commit Hooks + +Navigate to the root directory of the repository and run the following command to initialize pre-commit hooks: + +```bash +pre-commit install +``` + +This command sets up the pre-commit hooks defined in the `pre-commit-config.yaml` file. + +To run the configured hooks on some / all files of the project run: + +```bash +pre-commit run --files +pre-commit run --all-files +``` + +You will also need to have `commitizen` installed, e.g. via + +```bash +pip install commitizen +``` + +## Branches + +Development happens in feature branches (e.g. `feat/foo` or `fix/bar`). Those are then merged (via a PR) into `develop`. The `develop` branch is synchronized automatically with `develop.openbadges.education`. Once dev tests have completed on `develop.openbadges.education`, `develop` is merged (via a PR) into `main`. The `main` branch is synchronized automatically with `staging.openbadges.education`. Once this state is ready for a deployment, `main` is merged (via a PR) into `production`. The `production` branch is synchronized automatically with `openbadges.education`. + +## API Documentation + +This project includes an automatically generated API documentation using [drf-spectacular](https://drf-spectacular.readthedocs.io/). + +You can access it at: + +- **Swagger UI:** `/docs/` +- **Redoc:** `/redoc/` +- **OpenAPI schema (JSON):** `/api/schema/` + diff --git a/apps/backpack/__init__.py b/apps/backpack/__init__.py index d4896b838..bca5f6749 100644 --- a/apps/backpack/__init__.py +++ b/apps/backpack/__init__.py @@ -1,4 +1 @@ # encoding: utf-8 - - - diff --git a/apps/backpack/admin.py b/apps/backpack/admin.py index 452915fbe..94b536909 100644 --- a/apps/backpack/admin.py +++ b/apps/backpack/admin.py @@ -1,7 +1,9 @@ from django.contrib.admin import ModelAdmin, TabularInline from mainsite.admin import badgr_admin -from .models import (BackpackCollection, ) +from .models import ( + BackpackCollection, +) ### @@ -14,18 +16,35 @@ class CollectionInstanceInline(TabularInline): model = BackpackCollection.assertions.through extra = 0 - raw_id_fields = ('badgeinstance',) + raw_id_fields = ("badgeinstance",) class CollectionAdmin(ModelAdmin): - list_display = ('created_by', 'name', 'entity_id', ) - search_fields = ('created_by__email', 'name', 'entity_id') + list_display = ( + "created_by", + "name", + "entity_id", + ) + search_fields = ("created_by__email", "name", "entity_id") fieldsets = ( - (None, {'fields': ('created_by', 'name', 'entity_id', 'description', 'share_hash')}), + ( + None, + { + "fields": ( + "created_by", + "name", + "entity_id", + "description", + "share_hash", + ) + }, + ), ) - readonly_fields = ('created_by', 'entity_id') + readonly_fields = ("created_by", "entity_id") inlines = [ CollectionInstanceInline, ] pass + + badgr_admin.register(BackpackCollection, CollectionAdmin) diff --git a/apps/backpack/api.py b/apps/backpack/api.py index cd041686d..ea7bebb21 100644 --- a/apps/backpack/api.py +++ b/apps/backpack/api.py @@ -1,7 +1,7 @@ # encoding: utf-8 - - -import badgrlog +from django.http import Http404, JsonResponse +from apps.backpack.utils import get_skills_tree +import logging import datetime from django.utils import timezone @@ -10,24 +10,46 @@ from rest_framework import serializers from rest_framework import status -from backpack.models import BackpackCollection, BackpackBadgeShare, BackpackCollectionShare -from backpack.serializers_v1 import CollectionSerializerV1, LocalBadgeInstanceUploadSerializerV1 -from backpack.serializers_v2 import BackpackAssertionSerializerV2, BackpackCollectionSerializerV2, \ - BackpackImportSerializerV2, BackpackAssertionAcceptanceSerializerV2 +from backpack.models import ( + BackpackCollection, + BackpackBadgeShare, + BackpackCollectionShare, +) +from backpack.serializers_v1 import ( + CollectionSerializerV1, + ImportedBadgeAssertionSerializer, + LocalBadgeInstanceUploadSerializerV1, +) +from backpack.serializers_v2 import ( + BackpackAssertionSerializerV2, + BackpackCollectionSerializerV2, + BackpackImportSerializerV2, + BackpackAssertionAcceptanceSerializerV2, +) from entity.api import BaseEntityListView, BaseEntityDetailView -from issuer.models import BadgeInstance -from issuer.permissions import AuditedModelOwner, VerifiedEmailMatchesRecipientIdentifier, BadgrOAuthTokenHasScope +from issuer.models import BadgeInstance, ImportedBadgeAssertion +from issuer.permissions import ( + AuditedModelOwner, + VerifiedEmailMatchesRecipientIdentifier, + BadgrOAuthTokenHasScope, +) from issuer.public_api import ImagePropertyDetailView -from apispec_drf.decorators import apispec_list_operation, apispec_post_operation, apispec_get_operation, \ - apispec_delete_operation, apispec_put_operation, apispec_operation +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + OpenApiParameter, + OpenApiTypes, + inline_serializer, +) from mainsite.permissions import AuthenticatedWithVerifiedIdentifier, IsServerAdmin from badgeuser.models import BadgeUser -logger = badgrlog.BadgrLogger() +logger = logging.getLogger("Badgr.Events") + +_TRUE_VALUES = ["true", "t", "on", "yes", "y", "1", 1, 1.0, True] +_FALSE_VALUES = ["false", "f", "off", "no", "n", "0", 0, 0.0, False] -_TRUE_VALUES = ['true', 't', 'on', 'yes', 'y', '1', 1, 1.0, True] -_FALSE_VALUES = ['false', 'f', 'off', 'no', 'n', '0', 0, 0.0, False] def _scrub_boolean(boolean_str, default=None): if boolean_str in _TRUE_VALUES: @@ -36,67 +58,204 @@ def _scrub_boolean(boolean_str, default=None): return False return default + +@extend_schema_view( + get=extend_schema( + summary="List imported badge assertions", + description="Get a list of all imported badge assertions for the authenticated user", + tags=["Backpack"], + responses={200: ImportedBadgeAssertionSerializer(many=True)}, + ), + post=extend_schema( + summary="Import a new badge assertion", + description="Create a new imported badge instance", + tags=["Backpack"], + request=ImportedBadgeAssertionSerializer, + responses={201: ImportedBadgeAssertionSerializer}, + ), +) +class ImportedBadgeInstanceList(BaseEntityListView): + """ + API endpoint for importing and listing imported badge assertions + """ + + model = ImportedBadgeAssertion + v1_serializer_class = ImportedBadgeAssertionSerializer + permission_classes = (permissions.IsAuthenticated,) + http_method_names = ("get", "post") + + def get_objects(self, request, **kwargs): + return ImportedBadgeAssertion.objects.filter(user=self.request.user) + + def get_queryset(self): + """Filter imported badges to the current user""" + return ImportedBadgeAssertion.objects.filter(user=self.request.user) + + def post(self, request, **kwargs): + """Create a new imported badge instance""" + serializer_class = self.get_serializer_class() + serializer = serializer_class(data=request.data) + if serializer.is_valid(): + serializer.validated_data["user"] = request.user + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@extend_schema_view( + get=extend_schema( + summary="Get an imported badge assertion", + description="Retrieve details of a specific imported badge", + tags=["Backpack"], + responses={200: ImportedBadgeAssertionSerializer}, + ), + delete=extend_schema( + summary="Delete an imported badge", + description="Remove an imported badge from the backpack", + tags=["Backpack"], + responses={204: None}, + ), +) +class ImportedBadgeInstanceDetail(BaseEntityDetailView): + """ + API endpoint for retrieving, updating, or deleting an imported badge + """ + + model = ImportedBadgeAssertion + v1_serializer_class = ImportedBadgeAssertionSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_object(self, request, **kwargs): + entity_id = kwargs.get("entity_id") + try: + return ImportedBadgeAssertion.objects.get(entity_id=entity_id) + except ImportedBadgeAssertion.DoesNotExist: + raise Http404 + + def get_queryset(self): + """Filter imported badges to the current user""" + return ImportedBadgeAssertion.objects.filter(user=self.request.user) + + def delete(self, request, **kwargs): + """Delete an imported badge from the backpack""" + badge = self.get_object(request, **kwargs) + badge.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +@extend_schema_view( + get=extend_schema( + operation_id="earner_badges_list", + summary="Get a list of Assertions in authenticated user's backpack", + description="Retrieve all badge assertions from the authenticated user's backpack", + tags=["Backpack"], + parameters=[ + OpenApiParameter( + name="include_expired", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Include expired badges (default: true for v1, false for v2)", + ), + OpenApiParameter( + name="include_revoked", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Include revoked badges (default: false)", + ), + OpenApiParameter( + name="include_pending", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Include pending badges (default: false)", + ), + OpenApiParameter( + name="expand", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Expand related objects (badgeclass, issuer)", + many=True, + ), + ], + ), + post=extend_schema( + operation_id="earner_badges_upload", + summary="Upload a new Assertion to the backpack", + description="Upload a new badge assertion to the user's backpack", + tags=["Backpack"], + ), +) class BackpackAssertionList(BaseEntityListView): model = BadgeInstance v1_serializer_class = LocalBadgeInstanceUploadSerializerV1 v2_serializer_class = BackpackAssertionSerializerV2 - create_event = badgrlog.BadgeUploaded - permission_classes = (AuthenticatedWithVerifiedIdentifier, VerifiedEmailMatchesRecipientIdentifier, BadgrOAuthTokenHasScope) - http_method_names = ('get', 'post') + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + VerifiedEmailMatchesRecipientIdentifier, + BadgrOAuthTokenHasScope, + ) + http_method_names = ("get", "post") valid_scopes = { - 'get': ['r:backpack', 'rw:backpack'], - 'post': ['rw:backpack'], + "get": ["r:backpack", "rw:backpack"], + "post": ["rw:backpack"], } include_defaults = { - 'include_expired': {'v1': 'true', 'v2': 'false'}, - 'include_revoked': {'v1': 'false', 'v2': 'false'}, - 'include_pending': {'v1': 'false', 'v2': 'false'}, + "include_expired": {"v1": "true", "v2": "false"}, + "include_revoked": {"v1": "false", "v2": "false"}, + "include_pending": {"v1": "false", "v2": "false"}, } + def get_filtered_objects( + self, instances, include_expired, include_revoked, include_pending + ): + def badge_filter(b): + if ( + (b.acceptance == BadgeInstance.ACCEPTANCE_REJECTED) + or ( + not include_expired + and b.expires_at is not None + and b.expires_at < timezone.now() + ) + or (not include_revoked and b.revoked) + or (not include_pending and b.pending) + ): + return False + return True + + return list(filter(badge_filter, instances)) + def get_objects(self, request, **kwargs): - version = kwargs.get('version', 'v1') + version = kwargs.get("version", "v1") include_expired = request.query_params.get( - 'include_expired', self.include_defaults['include_expired'][version] - ).lower() in ['1', 'true'] + "include_expired", self.include_defaults["include_expired"][version] + ).lower() in ["1", "true"] include_revoked = request.query_params.get( - 'include_revoked', self.include_defaults['include_revoked'][version] - ).lower() in ['1', 'true'] + "include_revoked", self.include_defaults["include_revoked"][version] + ).lower() in ["1", "true"] include_pending = request.query_params.get( - 'include_pending', self.include_defaults['include_pending'][version] - ).lower() in ['1', 'true'] - - def badge_filter(b): - if ((b.acceptance == BadgeInstance.ACCEPTANCE_REJECTED) or - (not include_expired and b.expires_at != None and b.expires_at < timezone.now()) or - (not include_revoked and b.revoked) or - (not include_pending and b.pending)): - return False - return True + "include_pending", self.include_defaults["include_pending"][version] + ).lower() in ["1", "true"] - return list(filter(badge_filter, self.request.user.cached_badgeinstances())) + return self.get_filtered_objects( + self.request.user.cached_badgeinstances(), + include_expired, + include_revoked, + include_pending, + ) - @apispec_list_operation('Assertion', - summary="Get a list of Assertions in authenticated user's backpack ", - tags=['Backpack'] - ) def get(self, request, **kwargs): mykwargs = kwargs.copy() - mykwargs['expands'] = [] - expands = request.GET.getlist('expand', []) + mykwargs["expands"] = [] + expands = request.GET.getlist("expand", []) - if 'badgeclass' in expands: - mykwargs['expands'].append('badgeclass') - if 'issuer' in expands: - mykwargs['expands'].append('issuer') + if "badgeclass" in expands: + mykwargs["expands"].append("badgeclass") + if "issuer" in expands: + mykwargs["expands"].append("issuer") return super(BackpackAssertionList, self).get(request, **mykwargs) - @apispec_post_operation('Assertion', - summary="Upload a new Assertion to the backpack", - tags=['Backpack'] - ) def post(self, request, **kwargs): - if kwargs.get('version', 'v1') == 'v1': + if kwargs.get("version", "v1") == "v1": try: return super(BackpackAssertionList, self).post(request, **kwargs) except serializers.ValidationError as e: @@ -107,72 +266,113 @@ def post(self, request, **kwargs): def log_not_created(self, error): request = self.request user = request.user - image_data = '' - user_entity_id = '' - error_name = '' - error_result = '' + image_data = "" + user_entity_id = "" + error_name = "" + error_result = "" - if request.data.get('image', None) is not None: - image_data = request.data.get('image', '')[:1024] + if request.data.get("image", None) is not None: + image_data = request.data.get("image", "")[:1024] if user is not None: user_entity_id = user.entity_id if len(error.detail) <= 1: - #grab first error + # grab first error e = error.detail[0] - error_name = e.get('name', '') - error_result = e.get('result', '') + error_name = e.get("name", "") + error_result = e.get("result", "") - invalid_badge_upload_report = badgrlog.InvalidBadgeUploadReport(image_data, user_entity_id, error_name, error_result) - logger.event(badgrlog.InvalidBadgeUploaded(invalid_badge_upload_report)) + logger.warning( + "Invalid badge uploaded. Image data: '%s'; user_entity_id: '%s'; error_name: '%s'; error_result: '%s'", + image_data, + user_entity_id, + error_name, + error_result, + ) def get_context_data(self, **kwargs): context = super(BackpackAssertionList, self).get_context_data(**kwargs) - context['format'] = self.request.query_params.get('json_format', 'v1') # for /v1/earner/badges compat + context["format"] = self.request.query_params.get( + "json_format", "v1" + ) # for /v1/earner/badges compat return context +@extend_schema_view( + get=extend_schema( + operation_id="earner_badge_retrieve", + summary="Get detail on an Assertion in the user's Backpack", + description="Retrieve detailed information about a specific badge assertion", + tags=["Backpack"], + parameters=[ + OpenApiParameter( + name="expand", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Expand related objects (badgeclass, issuer)", + many=True, + ), + ], + ), + delete=extend_schema( + operation_id="earner_badge_delete", + summary="Remove an assertion from the backpack", + description="Delete a badge assertion from the user's backpack", + tags=["Backpack"], + responses={204: None}, + ), + put=extend_schema( + operation_id="earner_badge_update_acceptance", + summary="Update acceptance of an Assertion in the user's Backpack", + description="Update the acceptance status of a badge assertion", + tags=["Backpack"], + request=BackpackAssertionAcceptanceSerializerV2, + responses={200: BackpackAssertionSerializerV2}, + ), +) class BackpackAssertionDetail(BaseEntityDetailView): model = BadgeInstance v1_serializer_class = LocalBadgeInstanceUploadSerializerV1 v2_serializer_class = BackpackAssertionSerializerV2 - permission_classes = (AuthenticatedWithVerifiedIdentifier, VerifiedEmailMatchesRecipientIdentifier, BadgrOAuthTokenHasScope) - http_method_names = ('get', 'delete', 'put') + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + VerifiedEmailMatchesRecipientIdentifier, + BadgrOAuthTokenHasScope, + ) + http_method_names = ("get", "delete", "put") valid_scopes = { - 'get': ['r:backpack', 'rw:backpack'], - 'put': ['rw:backpack'], - 'delete': ['rw:backpack'], + "get": ["r:backpack", "rw:backpack"], + "put": ["rw:backpack"], + "delete": ["rw:backpack"], } def get_context_data(self, **kwargs): context = super(BackpackAssertionDetail, self).get_context_data(**kwargs) - context['format'] = self.request.query_params.get('json_format', 'v1') # for /v1/earner/badges compat + context["format"] = self.request.query_params.get( + "json_format", "v1" + ) # for /v1/earner/badges compat return context - @apispec_get_operation('BackpackAssertion', - summary="Get detail on an Assertion in the user's Backpack", - tags=['Backpack'] - ) def get(self, request, **kwargs): mykwargs = kwargs.copy() - mykwargs['expands'] = [] - expands = request.GET.getlist('expand', []) + mykwargs["expands"] = [] + expands = request.GET.getlist("expand", []) - if 'badgeclass' in expands: - mykwargs['expands'].append('badgeclass') - if 'issuer' in expands: - mykwargs['expands'].append('issuer') + if "badgeclass" in expands: + mykwargs["expands"].append("badgeclass") + if "issuer" in expands: + mykwargs["expands"].append("issuer") return super(BackpackAssertionDetail, self).get(request, **mykwargs) - @apispec_delete_operation('BackpackAssertion', - summary='Remove an assertion from the backpack', - tags=['Backpack'] - ) def delete(self, request, **kwargs): obj = self.get_object(request, **kwargs) - related_collections = list(BackpackCollection.objects.filter(backpackcollectionbadgeinstance__badgeinstance=obj)) + related_collections = list( + BackpackCollection.objects.filter( + backpackcollectionbadgeinstance__badgeinstance=obj + ) + ) if obj.source_url is None: obj.acceptance = BadgeInstance.ACCEPTANCE_REJECTED @@ -186,12 +386,8 @@ def delete(self, request, **kwargs): request.user.save() return Response(status=status.HTTP_204_NO_CONTENT) - @apispec_put_operation('BackpackAssertion', - summary="Update acceptance of an Assertion in the user's Backpack", - tags=['Backpack'] - ) def put(self, request, **kwargs): - fields_whitelist = ('acceptance',) + fields_whitelist = ("acceptance",) data = {k: v for k, v in list(request.data.items()) if k in fields_whitelist} obj = self.get_object(request, **kwargs) @@ -200,7 +396,9 @@ def put(self, request, **kwargs): context = self.get_context_data(**kwargs) - update_serializer = BackpackAssertionAcceptanceSerializerV2(obj, data, context=context) + update_serializer = BackpackAssertionAcceptanceSerializerV2( + obj, data, context=context + ) update_serializer.is_valid(raise_exception=True) update_serializer.save(updated_by=request.user) @@ -210,138 +408,202 @@ def put(self, request, **kwargs): return Response(serializer.data) +@extend_schema(exclude=True) class BackpackAssertionDetailImage(ImagePropertyDetailView, BadgrOAuthTokenHasScope): model = BadgeInstance - prop = 'image' - valid_scopes = ['r:backpack', 'rw:backpack'] - + prop = "image" + valid_scopes = ["r:backpack", "rw:backpack"] + + +@extend_schema( + summary="Get skills tree from backpack assertions", + description="Retrieve a hierarchical skills tree from the user's badge assertions", + tags=["Backpack"], + parameters=[ + OpenApiParameter( + name="lang", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Language for skills (de or en, default: de)", + enum=["de", "en"], + ), + ], + responses={200: OpenApiTypes.OBJECT}, +) +class BackpackSkillList(BackpackAssertionList): + def get(self, request, **kwargs): + instances = self.get_objects(request) + if not instances: + return JsonResponse({"skills": []}) + try: + lang = request.query_params.get("lang") + assert lang == "de" or lang == "en" + except Exception: + lang = "de" + + skills = get_skills_tree(instances, lang) + + return JsonResponse(skills) + + +@extend_schema( + summary="Get a list of Badges from a specific user", + description="Retrieve all badges for a user specified by email (admin only)", + tags=["Backpack"], + parameters=[ + OpenApiParameter( + name="email", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="Email address of the user", + required=True, + ), + ], +) class BadgesFromUser(BaseEntityListView): model = BadgeInstance v1_serializer_class = LocalBadgeInstanceUploadSerializerV1 v2_serializer_class = BackpackAssertionSerializerV2 - permission_classes = (IsServerAdmin,) + permission_classes = (IsServerAdmin,) valid_scopes = { - 'get': ['rw:serverAdmin'], + "get": ["rw:serverAdmin"], } def get_objects(self, request, **kwargs): - email = kwargs.get('email') + email = kwargs.get("email") try: user = BadgeUser.cached.get(email=email) return list(user.get_badges_from_user()) - except: BadgeUser.DoesNotExist - raise ValueError('User not found') + except BadgeUser.DoesNotExist: + raise ValueError("User not found") - @apispec_get_operation(['BadgeInstance'], - summary='Get a list of Badges', - tags=['Backpack'] - ) def get(self, request, **kwargs): return super(BadgesFromUser, self).get(request, **kwargs) +@extend_schema_view( + get=extend_schema( + operation_id="earner_collection_list", + summary="Get a list of Collections", + description="Retrieve all badge collections for the authenticated user", + tags=["Backpack"], + responses={200: BackpackCollectionSerializerV2(many=True)}, + ), + post=extend_schema( + operation_id="earner_collection_add", + summary="Create a new Collection", + description="Create a new badge collection", + tags=["Backpack"], + request=BackpackCollectionSerializerV2, + responses={201: BackpackCollectionSerializerV2}, + ), +) class BackpackCollectionList(BaseEntityListView): model = BackpackCollection v1_serializer_class = CollectionSerializerV1 v2_serializer_class = BackpackCollectionSerializerV2 - permission_classes = (AuthenticatedWithVerifiedIdentifier, AuditedModelOwner, BadgrOAuthTokenHasScope) + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + AuditedModelOwner, + BadgrOAuthTokenHasScope, + ) valid_scopes = { - 'get': ['r:backpack', 'rw:backpack'], - 'post': ['rw:backpack'], + "get": ["r:backpack", "rw:backpack"], + "post": ["rw:backpack"], } def get_objects(self, request, **kwargs): return self.request.user.cached_backpackcollections() - @apispec_get_operation('Collection', - summary='Get a list of Collections', - tags=['Backpack'] - ) def get(self, request, **kwargs): return super(BackpackCollectionList, self).get(request, **kwargs) - @apispec_post_operation('Collection', - summary='Create a new Collection', - tags=['Backpack'] - ) def post(self, request, **kwargs): return super(BackpackCollectionList, self).post(request, **kwargs) +@extend_schema_view( + get=extend_schema( + operation_id="earner_collection_retrieve", + summary="Get a single Collection", + description="Retrieve details of a specific badge collection", + tags=["Backpack"], + responses={200: BackpackCollectionSerializerV2}, + ), + put=extend_schema( + operation_id="earner_collection_update", + summary="Update a Collection", + description="Update an existing badge collection", + tags=["Backpack"], + request=BackpackCollectionSerializerV2, + responses={200: BackpackCollectionSerializerV2}, + ), + delete=extend_schema( + summary="Delete a collection", + description="Remove a badge collection", + tags=["Backpack"], + responses={204: None}, + ), +) class BackpackCollectionDetail(BaseEntityDetailView): model = BackpackCollection v1_serializer_class = CollectionSerializerV1 v2_serializer_class = BackpackCollectionSerializerV2 - permission_classes = (AuthenticatedWithVerifiedIdentifier, AuditedModelOwner, BadgrOAuthTokenHasScope) + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + AuditedModelOwner, + BadgrOAuthTokenHasScope, + ) valid_scopes = { - 'get': ['r:backpack', 'rw:backpack'], - 'post': ['rw:backpack'], - 'put': ['rw:backpack'], - 'delete': ['rw:backpack'] + "get": ["r:backpack", "rw:backpack"], + "post": ["rw:backpack"], + "put": ["rw:backpack"], + "delete": ["rw:backpack"], } - @apispec_get_operation('Collection', - summary='Get a single Collection', - tags=['Backpack'] - ) def get(self, request, **kwargs): return super(BackpackCollectionDetail, self).get(request, **kwargs) - @apispec_put_operation('Collection', - summary='Update a Collection', - tags=['Backpack'] - ) def put(self, request, **kwargs): return super(BackpackCollectionDetail, self).put(request, **kwargs) - @apispec_delete_operation('Collection', - summary='Delete a collection', - tags=['Backpack'] - ) def delete(self, request, **kwargs): return super(BackpackCollectionDetail, self).delete(request, **kwargs) +@extend_schema( + summary="Import a new Assertion to the backpack", + description="Import a badge assertion from URL, image, or JSON", + tags=["Backpack"], + request=inline_serializer( + name="BackpackImportRequest", + fields={ + "url": serializers.URLField( + required=False, + help_text="URL to an OpenBadge compliant badge", + ), + "image": serializers.CharField( + required=False, + help_text="Base64 encoded Baked OpenBadge image (data:image/png;base64,...)", + ), + "assertion": serializers.JSONField( + required=False, + help_text="OpenBadge compliant JSON", + ), + }, + ), + responses={201: BackpackAssertionSerializerV2}, +) class BackpackImportBadge(BaseEntityListView): v2_serializer_class = BackpackImportSerializerV2 - permission_classes = (AuthenticatedWithVerifiedIdentifier, BadgrOAuthTokenHasScope,) - http_method_names = ('post',) - valid_scopes = ['rw:backpack'] - - @apispec_operation( - summary="Import a new Assertion to the backpack", - tags=['Backpack'], - parameters=[ - { - "in": "body", - "name": "body", - "required": True, - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "url", - "description": "URL to an OpenBadge compliant badge", - 'required': False - }, - "image": { - 'type': "string", - 'format': "data:image/png;base64", - 'description': "base64 encoded Baked OpenBadge image", - 'required': False - }, - "assertion": { - 'type': "json", - 'description': "OpenBadge compliant json", - 'required': False - }, - } - }, - } - ] + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + BadgrOAuthTokenHasScope, ) + http_method_names = ("post",) + valid_scopes = ["rw:backpack"] + def post(self, request, **kwargs): context = self.get_context_data(**kwargs) serializer_class = self.get_serializer_class() @@ -350,95 +612,181 @@ def post(self, request, **kwargs): new_instance = serializer.save(created_by=request.user) self.log_create(new_instance) - response_serializer = BackpackAssertionSerializerV2(new_instance, context=context) + response_serializer = BackpackAssertionSerializerV2( + new_instance, context=context + ) return Response(response_serializer.data, status=status.HTTP_201_CREATED) +@extend_schema( + summary="Share a badge assertion", + description="Share a single badge to a supported share provider (Facebook, LinkedIn)", + tags=["Backpack"], + parameters=[ + OpenApiParameter( + name="provider", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="The share provider (facebook, linkedin)", + required=True, + enum=["facebook", "linkedin"], + ), + OpenApiParameter( + name="redirect", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Whether to redirect to the share URL (default: true)", + ), + OpenApiParameter( + name="source", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Source of the share action", + ), + OpenApiParameter( + name="include_identifier", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Include recipient identifier in share", + ), + ], + responses={ + 302: OpenApiTypes.OBJECT, + 200: inline_serializer( + name="ShareResponse", + fields={"url": serializers.URLField()}, + ), + }, +) class ShareBackpackAssertion(BaseEntityDetailView): model = BadgeInstance - permission_classes = (permissions.AllowAny,) # this is AllowAny to support tracking sharing links in emails - http_method_names = ('get',) + permission_classes = ( + permissions.AllowAny, + ) # this is AllowAny to support tracking sharing links in emails + http_method_names = ("get",) allow_any_unauthenticated_access = True def get(self, request, **kwargs): - """ - Share a single badge to a support share provider - --- - parameters: - - name: provider - description: The identifier of the provider to use. Supports 'facebook', 'linkedin' - required: true - type: string - paramType: query - """ - redirect = _scrub_boolean(request.query_params.get('redirect', "1")) - - provider = request.query_params.get('provider') + """Share a single badge to a support share provider""" + redirect = _scrub_boolean(request.query_params.get("redirect", "1")) + + provider = request.query_params.get("provider") if not provider: - return Response({'error': "unspecified share provider"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "unspecified share provider"}, + status=status.HTTP_400_BAD_REQUEST, + ) provider = provider.lower() - source = request.query_params.get('source', 'unknown') + source = request.query_params.get("source", "unknown") badge = self.get_object(request, **kwargs) if not badge: return Response(status=status.HTTP_404_NOT_FOUND) - include_identifier = _scrub_boolean(request.query_params.get('include_identifier', False)) + include_identifier = _scrub_boolean( + request.query_params.get("include_identifier", False) + ) - share = BackpackBadgeShare(provider=provider, badgeinstance=badge, source=source) + share = BackpackBadgeShare( + provider=provider, badgeinstance=badge, source=source + ) share_url = share.get_share_url(provider, include_identifier=include_identifier) if not share_url: - return Response({'error': "invalid share provider"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "invalid share provider"}, status=status.HTTP_400_BAD_REQUEST + ) share.save() - logger.event(badgrlog.BadgeSharedEvent(badge, provider, datetime.datetime.now(), source)) + logger.info( + "Badge '%s' shared by '%s' at '%s' from '%s'", + badge.entity_id, + provider, + datetime.datetime.now(), + source, + ) if redirect: - headers = {'Location': share_url} + headers = {"Location": share_url} return Response(status=status.HTTP_302_FOUND, headers=headers) else: - return Response({'url': share_url}) - - + return Response({"url": share_url}) + + +@extend_schema( + summary="Share a badge collection", + description="Share a collection to a supported share provider (Facebook, LinkedIn)", + tags=["Backpack"], + parameters=[ + OpenApiParameter( + name="provider", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="The share provider (facebook, linkedin)", + required=True, + enum=["facebook", "linkedin"], + ), + OpenApiParameter( + name="redirect", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Whether to redirect to the share URL (default: true)", + ), + OpenApiParameter( + name="source", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Source of the share action", + ), + ], + responses={ + 302: OpenApiTypes.OBJECT, + 200: inline_serializer( + name="ShareCollectionResponse", + fields={"url": serializers.URLField()}, + ), + }, +) class ShareBackpackCollection(BaseEntityDetailView): model = BackpackCollection - permission_classes = (permissions.AllowAny,) # this is AllowAny to support tracking sharing links in emails - http_method_names = ('get',) + permission_classes = ( + permissions.AllowAny, + ) # this is AllowAny to support tracking sharing links in emails + http_method_names = ("get",) def get(self, request, **kwargs): - """ - Share a collection to a supported share provider - --- - parameters: - - name: provider - description: The identifier of the provider to use. Supports 'facebook', 'linkedin' - required: true - type: string - paramType: query - """ - redirect = _scrub_boolean(request.query_params.get('redirect', "1")) - - provider = request.query_params.get('provider') + """Share a collection to a supported share provider""" + redirect = _scrub_boolean(request.query_params.get("redirect", "1")) + + provider = request.query_params.get("provider") if not provider: - return Response({'error': "unspecified share provider"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "unspecified share provider"}, + status=status.HTTP_400_BAD_REQUEST, + ) provider = provider.lower() - source = request.query_params.get('source', 'unknown') + source = request.query_params.get("source", "unknown") collection = self.get_object(request, **kwargs) if not collection: return Response(status=status.HTTP_404_NOT_FOUND) - share = BackpackCollectionShare(provider=provider, collection=collection, source=source) - share_url = share.get_share_url(provider, title=collection.name, summary=collection.description) + share = BackpackCollectionShare( + provider=provider, collection=collection, source=source + ) + share_url = share.get_share_url( + provider, title=collection.name, summary=collection.description + ) if not share_url: - return Response({'error': "invalid share provider"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "invalid share provider"}, status=status.HTTP_400_BAD_REQUEST + ) share.save() if redirect: - headers = {'Location': share_url} + headers = {"Location": share_url} return Response(status=status.HTTP_302_FOUND, headers=headers) else: - return Response({'url': share_url}) + return Response({"url": share_url}) diff --git a/apps/backpack/api_v1.py b/apps/backpack/api_v1.py index 99371ff3c..df14603c2 100644 --- a/apps/backpack/api_v1.py +++ b/apps/backpack/api_v1.py @@ -5,14 +5,48 @@ from backpack.models import BackpackCollectionBadgeInstance, BackpackCollection from backpack.serializers_v1 import CollectionBadgeSerializerV1 from issuer.models import BadgeInstance -from mainsite.permissions import IsOwner - +from drf_spectacular.utils import extend_schema_view, extend_schema, inline_serializer + + +@extend_schema_view( + get=extend_schema( + summary="Get badges in a collection", + description="Retrieve all badges that belong to a specific collection", + tags=["Collections"], + responses={ + 200: CollectionBadgeSerializerV1(many=True), + 404: {"description": "Collection not found"}, + }, + ), + post=extend_schema( + summary="Add badges to collection", + description="Add one or more badges to an existing collection", + tags=["Collections"], + request=CollectionBadgeSerializerV1, + responses={ + 201: CollectionBadgeSerializerV1, + 400: {"description": "No new records could be added"}, + 404: {"description": "Collection not found"}, + }, + ), + put=extend_schema( + summary="Update collection badges", + description="Replace the entire list of badges in a collection", + tags=["Collections"], + request=CollectionBadgeSerializerV1, + responses={ + 200: CollectionBadgeSerializerV1, + 404: {"description": "Collection not found"}, + }, + ), +) class CollectionLocalBadgeInstanceList(APIView): """ POST to add badges to collection, PUT to update collection to a new list of ids. """ + queryset = BackpackCollectionBadgeInstance.objects.all() permission_classes = (permissions.IsAuthenticated,) @@ -38,18 +72,22 @@ def post(self, request, slug, **kwargs): Returns resulting complete list of collection contents. """ try: - collection = BackpackCollection.objects.get(created_by=request.user, entity_id=slug) + collection = BackpackCollection.objects.get( + created_by=request.user, entity_id=slug + ) except BackpackCollection.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) add_many = isinstance(request.data, list) serializer = CollectionBadgeSerializerV1( - data=request.data, many=add_many, + data=request.data, + many=add_many, context={ - 'collection': collection, - 'request': request, 'user': request.user, - 'add_only': True - } + "collection": collection, + "request": request, + "user": request.user, + "add_only": True, + }, ) serializer.is_valid(raise_exception=True) @@ -57,10 +95,13 @@ def post(self, request, slug, **kwargs): if new_records == []: return Response( - "No new records could be added to collection. " + - "Check for missing/unknown badge references, unauthorized " + - "access, or badges already existing in collection.", - status=status.HTTP_400_BAD_REQUEST) + ( + "No new records could be added to collection. " + + "Check for missing/unknown badge references, unauthorized " + + "access, or badges already existing in collection." + ), + status=status.HTTP_400_BAD_REQUEST, + ) return Response(serializer.data, status=status.HTTP_201_CREATED) def put(self, request, slug, **kwargs): @@ -79,39 +120,67 @@ def put(self, request, slug, **kwargs): type: string paramType: path - name: badges - description: A JSON serialization of all the badges to be included in this collection, replacing the list that currently exists. + description: A JSON serialization of all the badges to be + included in this collection, replacing the list that currently exists. required: true paramType: body """ badges = request.data try: - collection = BackpackCollection.objects.get(created_by=request.user, entity_id=slug) + collection = BackpackCollection.objects.get( + created_by=request.user, entity_id=slug + ) except BackpackCollection.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) - serializer = CollectionBadgeSerializerV1(data=badges, many=True, context={'collection': collection}) + serializer = CollectionBadgeSerializerV1( + data=badges, many=True, context={"collection": collection} + ) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data) +@extend_schema_view( + get=extend_schema( + summary="Get a specific badge in a collection", + description="Retrieve details of a single badge within a collection", + tags=["Collections"], + responses={ + 200: CollectionBadgeSerializerV1, + 404: {"description": "Collection or badge not found"}, + }, + ), + delete=extend_schema( + summary="Remove badge from collection", + description="Remove a badge from a collection (does not delete it from the earner's account)", + tags=["Collections"], + responses={ + 204: {"description": "Badge removed successfully"}, + 404: {"description": "Collection or badge not found"}, + }, + ), +) class CollectionLocalBadgeInstanceDetail(APIView): """ Update details on a single item in the collection or remove it from the collection. """ + queryset = BackpackCollectionBadgeInstance.objects.all() permission_classes = (permissions.IsAuthenticated,) def get(self, request, **kwargs): - collection_slug = kwargs.get('collection_slug', None) - slug = kwargs.get('slug', None) + collection_slug = kwargs.get("collection_slug", None) + slug = kwargs.get("slug", None) if not collection_slug or not slug: return Response(status=status.HTTP_404_NOT_FOUND) try: - collection = BackpackCollection.cached.get_by_slug_or_entity_id(collection_slug) + collection = BackpackCollection.cached.get_by_slug_or_entity_id( + collection_slug + ) except BackpackCollection.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) if collection.created_by != request.user: @@ -194,12 +263,14 @@ def delete(self, request, **kwargs): type: integer paramType: path """ - collection_slug = kwargs.get('collection_slug') - slug = kwargs.get('slug') + collection_slug = kwargs.get("collection_slug") + slug = kwargs.get("slug") if not collection_slug or not slug: return Response(status=status.HTTP_404_NOT_FOUND) try: - collection = BackpackCollection.cached.get_by_slug_or_entity_id(collection_slug) + collection = BackpackCollection.cached.get_by_slug_or_entity_id( + collection_slug + ) except BackpackCollection.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) if collection.created_by != request.user: @@ -227,10 +298,38 @@ def delete(self, request, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) +@extend_schema_view( + get=extend_schema( + summary="Generate shareable URL for collection", + description="Make a collection public by generating a shareable hash and return the share URL", + tags=["Collections"], + responses={ + 200: inline_serializer( + name="CollectionShareUrlResponse", + fields={ + "share_url": serializers.URLField( + help_text="The shareable URL for the collection" + ) + }, + ), + 404: {"description": "Collection not found"}, + }, + ), + delete=extend_schema( + summary="Remove collection share", + description="Make a collection private by removing its share hash", + tags=["Collections"], + responses={ + 204: {"description": "Share hash removed successfully"}, + 404: {"description": "Collection not found"}, + }, + ), +) class CollectionGenerateShare(APIView): """ Allows a Collection to be public by generation of a shareable hash. """ + permission_classes = (permissions.IsAuthenticated,) def get(self, request, slug, **kwargs): @@ -254,9 +353,7 @@ def delete(self, request, slug, **kwargs): if not request.user == collection.owner: return Response(status=status.HTTP_404_NOT_FOUND) - collection.share_hash = '' + collection.share_hash = "" collection.save() return Response(status=status.HTTP_204_NO_CONTENT) - - diff --git a/apps/backpack/api_v3.py b/apps/backpack/api_v3.py new file mode 100644 index 000000000..90ab7ef45 --- /dev/null +++ b/apps/backpack/api_v3.py @@ -0,0 +1,65 @@ +from entity.api_v3 import EntityViewSet +from backpack.serializers_v2 import BackpackAssertionSerializerV2 +from issuer.permissions import ( + BadgrOAuthTokenHasScope, + VerifiedEmailMatchesRecipientIdentifier, +) +from issuer.serializers_v1 import LearningPathSerializerV1 +from issuer.models import BadgeInstance, LearningPath, LearningPathBadge +from mainsite.permissions import AuthenticatedWithVerifiedIdentifier +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes + + +class Badges(EntityViewSet): + serializer_class = BackpackAssertionSerializerV2 + + valid_scopes = { + "get": ["r:backpack", "rw:backpack"], + "post": ["rw:backpack"], + } + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + VerifiedEmailMatchesRecipientIdentifier, + BadgrOAuthTokenHasScope, + ) + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return BadgeInstance.objects.none() + return self.request.user.cached_badgeinstances() + + +class LearningPaths(EntityViewSet): + serializer_class = LearningPathSerializerV1 + + valid_scopes = ["rw:backpack"] + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + VerifiedEmailMatchesRecipientIdentifier, + BadgrOAuthTokenHasScope, + ) + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return LearningPath.objects.none() + + badgeinstances = self.request.user.cached_badgeinstances().all() + badges = list({badgeinstance.badgeclass for badgeinstance in badgeinstances}) + lp_badges = LearningPathBadge.objects.filter(badge__in=badges) + lps = LearningPath.objects.filter(learningpathbadge__in=lp_badges).distinct() + + return lps + + @extend_schema( + parameters=[ + OpenApiParameter( + name="entity_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The entity ID of the learning path", + ) + ] + ) + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) diff --git a/apps/backpack/badge_connect_api.py b/apps/backpack/badge_connect_api.py index 0e7dd0c3e..240ecb645 100644 --- a/apps/backpack/badge_connect_api.py +++ b/apps/backpack/badge_connect_api.py @@ -1,24 +1,31 @@ -from collections import OrderedDict - -from apispec_drf.decorators import apispec_get_operation, apispec_post_operation +from drf_spectacular.utils import ( + extend_schema, +) from django.conf import settings from django.shortcuts import reverse from django.utils.dateparse import parse_datetime from django.views.generic.base import RedirectView from rest_framework import status -from rest_framework.generics import ListCreateAPIView, GenericAPIView +from rest_framework.generics import ListCreateAPIView from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.utils.urls import replace_query_param from rest_framework.views import APIView -from backpack.serializers_bcv1 import BackpackProfilesSerializerBC, BadgeConnectAssertionsSerializer, \ - BadgeConnectImportSerializer, BadgeConnectManifestSerializer +from backpack.serializers_bcv1 import ( + BackpackProfilesSerializerBC, + BadgeConnectAssertionsSerializer, + BadgeConnectImportSerializer, + BadgeConnectManifestSerializer, +) from badgeuser.models import BadgeUser -from entity.api import BaseEntityDetailView, BaseEntityListView +from entity.api import BaseEntityDetailView from issuer.models import BadgeInstance -from issuer.permissions import BadgrOAuthTokenHasScope, VerifiedEmailMatchesRecipientIdentifier +from issuer.permissions import ( + BadgrOAuthTokenHasScope, + VerifiedEmailMatchesRecipientIdentifier, +) from mainsite.permissions import AuthenticatedWithVerifiedIdentifier from mainsite.models import BadgrApp @@ -29,6 +36,7 @@ "https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly", ] + def badge_connect_api_info(domain): try: badgr_app = BadgrApp.cached.get(cors=domain) @@ -37,48 +45,37 @@ def badge_connect_api_info(domain): return { "@context": "https://purl.imsglobal.org/spec/ob/v2p1/ob_v2p1.jsonld", - "id": '{}{}'.format( - settings.HTTP_ORIGIN, - reverse('badge_connect_manifest', kwargs={'domain': domain}) - ), - "badgeConnectAPI": [{ - "name": badgr_app.name, - "image": '{}/images/logo.png'.format(settings.STATIC_URL), - "apiBase": '{}{}'.format(settings.HTTP_ORIGIN, '/bcv1'), - "version": "v1p0", - "termsOfServiceUrl": "https://badgr.com/terms-of-service.html", - "privacyPolicyUrl": "https://badgr.com/privacy-policy.html", - "scopesOffered": BADGE_CONNECT_SCOPES, - "registrationUrl": "{}{}".format( - settings.HTTP_ORIGIN, - reverse('oauth2_api_register') - ), - "authorizationUrl": "https://{}/auth/oauth2/authorize".format(domain), - "tokenUrl": "{}{}".format( - settings.HTTP_ORIGIN, - reverse('oauth2_provider_token') - ), - }] + "id": "{}{}".format( + settings.HTTP_ORIGIN, + reverse("badge_connect_manifest", kwargs={"domain": domain}), + ), + "badgeConnectAPI": [ + { + "name": badgr_app.name, + "image": "{}/images/logo.png".format(settings.STATIC_URL), + "apiBase": "{}{}".format(settings.HTTP_ORIGIN, "/bcv1"), + "version": "v1p0", + "termsOfServiceUrl": "https://badgr.com/terms-of-service.html", + "privacyPolicyUrl": "https://badgr.com/privacy-policy.html", + "scopesOffered": BADGE_CONNECT_SCOPES, + "registrationUrl": "{}{}".format( + settings.HTTP_ORIGIN, reverse("oauth2_api_register") + ), + "authorizationUrl": "https://{}/auth/oauth2/authorize".format(domain), + "tokenUrl": "{}{}".format( + settings.HTTP_ORIGIN, reverse("oauth2_provider_token") + ), + } + ], } class BadgeConnectManifestView(APIView): permission_classes = [AllowAny] - @apispec_get_operation('BadgeConnectManifest', - summary='Fetch Badge Connect Manifest', - tags=['BadgeConnect'], - parameters=[ - { - "in": "query", - 'name': 'domain', - 'type': 'string', - 'description': 'The CORS domain for the BadgrApp' - } - ] - ) + @extend_schema(exclude=True) def get(self, request, **kwargs): - data = badge_connect_api_info(kwargs.get('domain')) + data = badge_connect_api_info(kwargs.get("domain")) if data is None: return Response(status=status.HTTP_404_NOT_FOUND) serializer = BadgeConnectManifestSerializer(data) @@ -88,7 +85,9 @@ def get(self, request, **kwargs): class BadgeConnectManifestRedirectView(RedirectView): def get_redirect_url(self, *args, **kwargs): badgr_app = BadgrApp.objects.get_current(self.request) - return settings.HTTP_ORIGIN + reverse('badge_connect_manifest', kwargs={'domain': badgr_app.cors}) + return settings.HTTP_ORIGIN + reverse( + "badge_connect_manifest", kwargs={"domain": badgr_app.cors} + ) class BadgeConnectPagination(LimitOffsetPagination): @@ -97,6 +96,7 @@ def get_first_link(self): url = replace_query_param(url, self.limit_query_param, self.limit) return replace_query_param(url, self.offset_query_param, 0) + def get_last_link(self): offset = self.count // self.limit if offset != 0 and self.count % (offset * self.limit) == 0: @@ -107,6 +107,7 @@ def get_last_link(self): url = replace_query_param(url, self.limit_query_param, self.limit) return replace_query_param(url, self.offset_query_param, offset) + def get_previous_link(self): if self.offset <= 0: return None @@ -116,6 +117,7 @@ def get_previous_link(self): offset = self.offset - self.limit return replace_query_param(url, self.offset_query_param, offset) + def get_paginated_response(self, data): links = [] if self.get_next_link(): @@ -124,57 +126,48 @@ def get_paginated_response(self, data): links.append('<%s>; rel="last"' % self.get_last_link()) if self.get_previous_link(): links.append('<%s>; rel="prev"' % self.get_previous_link()) - headers = dict(Link=','.join(links)) + headers = dict(Link=",".join(links)) return Response(data, headers=headers) class BadgeConnectAssertionListView(ListCreateAPIView): model = BadgeInstance serializer_class = BadgeConnectAssertionsSerializer - permission_classes = (AuthenticatedWithVerifiedIdentifier, VerifiedEmailMatchesRecipientIdentifier, BadgrOAuthTokenHasScope) + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + VerifiedEmailMatchesRecipientIdentifier, + BadgrOAuthTokenHasScope, + ) valid_scopes = { - 'get': ['r:backpack', 'rw:backpack', 'https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly'], - 'post': ['rw:backpack', 'https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create'], + "get": [ + "r:backpack", + "rw:backpack", + "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly", + ], + "post": [ + "rw:backpack", + "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create", + ], } pagination_class = BadgeConnectPagination - http_method_names = ('get', 'post') + http_method_names = ("get", "post") def get_queryset(self): - qs = BadgeInstance.objects.filter(recipient_identifier__in=self.request.user.all_recipient_identifiers).order_by('-updated_at') - if self.request.query_params.get('since', None): - qs = qs.filter(updated_at__gte=parse_datetime(self.request.query_params.get('since'))) + qs = BadgeInstance.objects.filter( + recipient_identifier__in=self.request.user.all_recipient_identifiers + ).order_by("-updated_at") + if self.request.query_params.get("since", None): + qs = qs.filter( + updated_at__gte=parse_datetime(self.request.query_params.get("since")) + ) return qs def get_serializer_class(self): - if self.request.method == 'POST': + if self.request.method == "POST": return BadgeConnectImportSerializer return super(BadgeConnectAssertionListView, self).get_serializer_class() - @apispec_get_operation('BadgeConnectAssertions', - summary='Get a list of Assertions', - tags=['BadgeConnect'], - parameters=[ - { - "in": "query", - 'name': 'limit', - 'type': 'integer', - 'description': 'Indicate how many results should be retrieved in a single page.' - }, - { - "in": "query", - 'name': 'offset', - 'type': 'integer', - 'description': 'Indicate the index of the first record to return (zero indexed).' - }, - { - "in": "query", - 'name': 'limit', - 'type': 'string', - 'format': 'date-time', - 'description': 'Retrieve Assertions that were created or updated after the provided timestamp. Must be an ISO 8601 compatible timestamp with a time zone indicator.' - }, - ] - ) + @extend_schema(exclude=True) def get(self, request, **kwargs): queryset = self.get_queryset() page = self.paginate_queryset(queryset) @@ -185,16 +178,7 @@ def get(self, request, **kwargs): serializer = self.get_serializer(queryset) return Response(serializer.data) - @apispec_post_operation('BadgeConnectImport', - summary='Import a Badge Assertion', - tags=['BadgeConnect'], - responses=OrderedDict([ - ("201", { - 'schema': {'$ref': '#/definitions/BadgeConnectImportResult'}, - 'description': "Successfully created" - }), - ]) - ) + @extend_schema(exclude=True) def post(self, request, **kwargs): return super(BadgeConnectAssertionListView, self).post(request, **kwargs) @@ -203,15 +187,16 @@ class BadgeConnectProfileView(BaseEntityDetailView): model = BadgeUser bc_serializer_class = BackpackProfilesSerializerBC permission_classes = (AuthenticatedWithVerifiedIdentifier, BadgrOAuthTokenHasScope) - http_method_names = ('get',) + http_method_names = ("get",) valid_scopes = { - 'get': ['r:backpack', 'rw:backpack', 'https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly'], + "get": [ + "r:backpack", + "rw:backpack", + "https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly", + ], } - @apispec_get_operation('BadgeConnectProfiles', - summary='Get Badge Connect user profile', - tags=['BadgeConnect'] - ) + @extend_schema(exclude=True) def get(self, request, **kwargs): """ GET a single entity by its identifier @@ -225,10 +210,10 @@ def get(self, request, **kwargs): def get_context_data(self, **kwargs): return { - 'request': self.request, - 'kwargs': kwargs, + "request": self.request, + "kwargs": kwargs, } - + def get_object(self, request, **kwargs): self.object = request.user - return self.object \ No newline at end of file + return self.object diff --git a/apps/backpack/badge_connect_urls.py b/apps/backpack/badge_connect_urls.py index 13b5e1d95..2fe749f04 100644 --- a/apps/backpack/badge_connect_urls.py +++ b/apps/backpack/badge_connect_urls.py @@ -1,11 +1,18 @@ # encoding: utf-8 from __future__ import unicode_literals -from django.conf.urls import url +from django.urls import re_path -from backpack.badge_connect_api import BadgeConnectProfileView, BadgeConnectAssertionListView +from backpack.badge_connect_api import ( + BadgeConnectProfileView, + BadgeConnectAssertionListView, +) urlpatterns = [ - url(r'^assertions$', BadgeConnectAssertionListView.as_view(), name='bc_api_backpack_assertion_list'), - url(r'^profile$', BadgeConnectProfileView.as_view(), name='bc_api_profile'), -] \ No newline at end of file + re_path( + r"^assertions$", + BadgeConnectAssertionListView.as_view(), + name="bc_api_backpack_assertion_list", + ), + re_path(r"^profile$", BadgeConnectProfileView.as_view(), name="bc_api_profile"), +] diff --git a/apps/backpack/management/__init__.py b/apps/backpack/management/__init__.py index cc56a32c4..5d05efd04 100644 --- a/apps/backpack/management/__init__.py +++ b/apps/backpack/management/__init__.py @@ -1,4 +1,2 @@ # encoding: utf-8 from __future__ import unicode_literals - - diff --git a/apps/backpack/management/commands/__init__.py b/apps/backpack/management/commands/__init__.py index cc56a32c4..5d05efd04 100644 --- a/apps/backpack/management/commands/__init__.py +++ b/apps/backpack/management/commands/__init__.py @@ -1,4 +1,2 @@ # encoding: utf-8 from __future__ import unicode_literals - - diff --git a/apps/backpack/management/commands/emit_old_share_events.py b/apps/backpack/management/commands/emit_old_share_events.py index e643eed4a..e9fb18609 100644 --- a/apps/backpack/management/commands/emit_old_share_events.py +++ b/apps/backpack/management/commands/emit_old_share_events.py @@ -1,18 +1,19 @@ import datetime from django.core.management.base import BaseCommand - -import badgrlog - from backpack.models import BackpackBadgeShare +import logging -logger = badgrlog.BadgrLogger() +logger = logging.getLogger("Badgr.Events") class Command(BaseCommand): def handle(self, *args, **options): - self.stdout.write("Start emit old share events to badgr events log at %s" % datetime.datetime.now()) + logger.info( + "Start emit old share events to badgr events log at %s", + datetime.datetime.now(), + ) chunk_size = 5000 start_index = 0 @@ -20,15 +21,24 @@ def handle(self, *args, **options): while True: start = start_index - end = start_index+chunk_size + end = start_index + chunk_size - shares = BackpackBadgeShare.objects.order_by('id')[start:end] + shares = BackpackBadgeShare.objects.order_by("id")[start:end] for share in shares: self.stdout.write("Processing shares %s" % processing_index) - event = badgrlog.BadgeSharedEvent(share.badgeinstance, share.provider, share.created_at, share.source) - logger.event(event) + logger.info( + "Badge '%s' shared by '%s' at '%s' from '%s'", + share.badgeinstance.entity_id, + share.provider, + share.created_at, + share.source, + ) processing_index = processing_index + 1 - if len(shares) < chunk_size: break + if len(shares) < chunk_size: + break start_index += chunk_size - self.stdout.write("End emit old share events to badgr events log at %s" % datetime.datetime.now()) + self.stdout.write( + "End emit old share events to badgr events log at %s" + % datetime.datetime.now() + ) diff --git a/apps/backpack/migrations/0001_initial.py b/apps/backpack/migrations/0001_initial.py index fb2b1bd4d..bbfb86c81 100644 --- a/apps/backpack/migrations/0001_initial.py +++ b/apps/backpack/migrations/0001_initial.py @@ -7,65 +7,156 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('issuer', '0023_auto_20170531_1044'), + ("issuer", "0023_auto_20170531_1044"), ] operations = [ migrations.CreateModel( - name='BackpackCollection', + name="BackpackCollection", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('entity_version', models.PositiveIntegerField(default=1)), - ('entity_id', models.CharField(default=None, unique=True, max_length=254)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('name', models.CharField(max_length=128)), - ('description', models.CharField(max_length=255, blank=True)), - ('share_hash', models.CharField(max_length=255, blank=True)), - ('slug', models.CharField(default=None, max_length=254, null=True, blank=True)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, unique=True, max_length=254), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("name", models.CharField(max_length=128)), + ("description", models.CharField(max_length=255, blank=True)), + ("share_hash", models.CharField(max_length=255, blank=True)), + ( + "slug", + models.CharField( + default=None, max_length=254, null=True, blank=True + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='BackpackCollectionBadgeInstance', + name="BackpackCollectionBadgeInstance", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('badgeinstance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeInstance')), - ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='backpack.BackpackCollection')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "badgeinstance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeInstance", + ), + ), + ( + "collection", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="backpack.BackpackCollection", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='BackpackBadgeShare', + name="BackpackBadgeShare", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('provider', models.CharField(max_length=254, choices=[(b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn')])), - ('badgeinstance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeInstance', null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "provider", + models.CharField( + max_length=254, + choices=[ + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + ], + ), + ), + ( + "badgeinstance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeInstance", + null=True, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='BackpackCollectionShare', + name="BackpackCollectionShare", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('provider', models.CharField(max_length=254, choices=[(b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn')])), - ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='backpack.BackpackCollection')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "provider", + models.CharField( + max_length=254, + choices=[ + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + ], + ), + ), + ( + "collection", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="backpack.BackpackCollection", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/apps/backpack/migrations/0002_auto_20170531_1101.py b/apps/backpack/migrations/0002_auto_20170531_1101.py index d0406d530..f1986a624 100644 --- a/apps/backpack/migrations/0002_auto_20170531_1101.py +++ b/apps/backpack/migrations/0002_auto_20170531_1101.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from django.db import migrations, models +from django.db import migrations from mainsite.utils import generate_entity_uri @@ -11,13 +11,15 @@ def noop(apps, schema_editor): def migrate_localbadgeinstance_badgeinstance(apps, schema_editor): - LocalBadgeInstance = apps.get_model('composition', 'LocalBadgeInstance') - BadgeInstance = apps.get_model('issuer', 'BadgeInstance') - CompositionCollection = apps.get_model('composition', 'Collection') - BackpackCollection = apps.get_model('backpack', 'BackpackCollection') - BackpackCollectionBadgeInstance = apps.get_model('backpack', 'BackpackCollectionBadgeInstance') - BackpackBadgeShare = apps.get_model('backpack', 'BackpackBadgeShare') - BackpackCollectionShare = apps.get_model('backpack', 'BackpackCollectionShare') + LocalBadgeInstance = apps.get_model("composition", "LocalBadgeInstance") + BadgeInstance = apps.get_model("issuer", "BadgeInstance") + CompositionCollection = apps.get_model("composition", "Collection") + BackpackCollection = apps.get_model("backpack", "BackpackCollection") + BackpackCollectionBadgeInstance = apps.get_model( + "backpack", "BackpackCollectionBadgeInstance" + ) + BackpackBadgeShare = apps.get_model("backpack", "BackpackBadgeShare") + BackpackCollectionShare = apps.get_model("backpack", "BackpackCollectionShare") # index composition.localbageinstance.pk -> new issuer.badgeinstance _localbadgeinstance_idx = {} @@ -27,8 +29,8 @@ def migrate_localbadgeinstance_badgeinstance(apps, schema_editor): if not localbadgeinstance.issuer_badgeclass_id: continue - assertion_source = 'composition.localbadgeinstance' - assertion_original_url = localbadgeinstance.json.get('id') + assertion_source = "composition.localbadgeinstance" + assertion_original_url = localbadgeinstance.json.get("id") try: badgeinstance = BadgeInstance.objects.get( source=assertion_source, @@ -47,19 +49,21 @@ def migrate_localbadgeinstance_badgeinstance(apps, schema_editor): image=localbadgeinstance.image, slug=localbadgeinstance.slug, revoked=localbadgeinstance.revoked, - revocation_reason=localbadgeinstance.revocation_reason + revocation_reason=localbadgeinstance.revocation_reason, ) _localbadgeinstance_idx[localbadgeinstance.id] = badgeinstance # copy any badge shares over - for localbadgeinstance_share in localbadgeinstance.localbadgeinstanceshare_set.all(): + for ( + localbadgeinstance_share + ) in localbadgeinstance.localbadgeinstanceshare_set.all(): backpack_badge_share, created = BackpackBadgeShare.objects.get_or_create( badgeinstance=badgeinstance, provider=localbadgeinstance_share.provider, created_at=localbadgeinstance_share.created_at, defaults=dict( updated_at=localbadgeinstance_share.updated_at, - ) + ), ) # make new backpack collections @@ -72,17 +76,21 @@ def migrate_localbadgeinstance_badgeinstance(apps, schema_editor): name=composition_collection.name, description=composition_collection.description, share_hash=composition_collection.share_hash, - ) + ), ) # copy any collection shares over - for composition_collection_share in composition_collection.collectionshare_set.all(): - backpack_collection_share, created = BackpackCollectionShare.objects.get_or_create( - collection=backpack_collection, - provider=composition_collection_share.provider, - created_at=composition_collection_share.created_at, - defaults=dict( - updated_at=composition_collection_share.updated_at, + for ( + composition_collection_share + ) in composition_collection.collectionshare_set.all(): + backpack_collection_share, created = ( + BackpackCollectionShare.objects.get_or_create( + collection=backpack_collection, + provider=composition_collection_share.provider, + created_at=composition_collection_share.created_at, + defaults=dict( + updated_at=composition_collection_share.updated_at, + ), ) ) @@ -93,20 +101,22 @@ def migrate_localbadgeinstance_badgeinstance(apps, schema_editor): else: badgeinstance = _localbadgeinstance_idx[composition_collect.instance_id] - backpack_collect, created = BackpackCollectionBadgeInstance.objects.get_or_create( - collection=backpack_collection, - badgeinstance=badgeinstance + backpack_collect, created = ( + BackpackCollectionBadgeInstance.objects.get_or_create( + collection=backpack_collection, badgeinstance=badgeinstance + ) ) class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0001_initial'), - ('issuer', '0023_auto_20170531_1044'), - ('composition', '0015_auto_20170420_0649') + ("backpack", "0001_initial"), + ("issuer", "0023_auto_20170531_1044"), + ("composition", "0015_auto_20170420_0649"), ] operations = [ - migrations.RunPython(migrate_localbadgeinstance_badgeinstance, reverse_code=noop) + migrations.RunPython( + migrate_localbadgeinstance_badgeinstance, reverse_code=noop + ) ] diff --git a/apps/backpack/migrations/0003_backpackcollection_assertions.py b/apps/backpack/migrations/0003_backpackcollection_assertions.py index 18e6d342e..bbba093b4 100644 --- a/apps/backpack/migrations/0003_backpackcollection_assertions.py +++ b/apps/backpack/migrations/0003_backpackcollection_assertions.py @@ -5,16 +5,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0023_auto_20170531_1044'), - ('backpack', '0002_auto_20170531_1101'), + ("issuer", "0023_auto_20170531_1044"), + ("backpack", "0002_auto_20170531_1101"), ] operations = [ migrations.AddField( - model_name='backpackcollection', - name='assertions', - field=models.ManyToManyField(to='issuer.BadgeInstance', through='backpack.BackpackCollectionBadgeInstance', blank=True), + model_name="backpackcollection", + name="assertions", + field=models.ManyToManyField( + to="issuer.BadgeInstance", + through="backpack.BackpackCollectionBadgeInstance", + blank=True, + ), ), ] diff --git a/apps/backpack/migrations/0004_auto_20170711_1326.py b/apps/backpack/migrations/0004_auto_20170711_1326.py index 44122a544..f3152ce95 100644 --- a/apps/backpack/migrations/0004_auto_20170711_1326.py +++ b/apps/backpack/migrations/0004_auto_20170711_1326.py @@ -7,16 +7,15 @@ class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0003_backpackcollection_assertions'), + ("backpack", "0003_backpackcollection_assertions"), ] operations = [ migrations.AlterModelManagers( - name='backpackcollection', + name="backpackcollection", managers=[ - ('cached', django.db.models.manager.Manager()), + ("cached", django.db.models.manager.Manager()), ], ), ] diff --git a/apps/backpack/migrations/0005_auto_20171025_1020.py b/apps/backpack/migrations/0005_auto_20171025_1020.py index 21ac28d1a..2cff94890 100644 --- a/apps/backpack/migrations/0005_auto_20171025_1020.py +++ b/apps/backpack/migrations/0005_auto_20171025_1020.py @@ -8,21 +8,26 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('backpack', '0004_auto_20170711_1326'), + ("backpack", "0004_auto_20170711_1326"), ] operations = [ migrations.AddField( - model_name='backpackcollection', - name='updated_at', + model_name="backpackcollection", + name="updated_at", field=models.DateTimeField(auto_now=True), ), migrations.AddField( - model_name='backpackcollection', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="backpackcollection", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/apps/backpack/migrations/0006_backpackcollectionbadgeinstance_badgeuser.py b/apps/backpack/migrations/0006_backpackcollectionbadgeinstance_badgeuser.py index e60c769b0..d56b1c3f8 100644 --- a/apps/backpack/migrations/0006_backpackcollectionbadgeinstance_badgeuser.py +++ b/apps/backpack/migrations/0006_backpackcollectionbadgeinstance_badgeuser.py @@ -8,16 +8,20 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('backpack', '0005_auto_20171025_1020'), + ("backpack", "0005_auto_20171025_1020"), ] operations = [ migrations.AddField( - model_name='backpackcollectionbadgeinstance', - name='badgeuser', - field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="backpackcollectionbadgeinstance", + name="badgeuser", + field=models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/apps/backpack/migrations/0007_auto_20171027_0837.py b/apps/backpack/migrations/0007_auto_20171027_0837.py index 9213e5efe..79e9e72f2 100644 --- a/apps/backpack/migrations/0007_auto_20171027_0837.py +++ b/apps/backpack/migrations/0007_auto_20171027_0837.py @@ -6,7 +6,9 @@ def populate_backpackcollectionbadgeinstance_user(apps, schema_editor): - BackpackCollectionBadgeInstance = apps.get_model('backpack', 'BackpackCollectionBadgeInstance') + BackpackCollectionBadgeInstance = apps.get_model( + "backpack", "BackpackCollectionBadgeInstance" + ) for collect in BackpackCollectionBadgeInstance.objects.all(): collect.badgeuser_id = collect.collection.created_by_id collect.save() @@ -17,11 +19,12 @@ def noop(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0006_backpackcollectionbadgeinstance_badgeuser'), + ("backpack", "0006_backpackcollectionbadgeinstance_badgeuser"), ] operations = [ - migrations.RunPython(populate_backpackcollectionbadgeinstance_user, reverse_code=noop) + migrations.RunPython( + populate_backpackcollectionbadgeinstance_user, reverse_code=noop + ) ] diff --git a/apps/backpack/migrations/0008_auto_20171204_1236.py b/apps/backpack/migrations/0008_auto_20171204_1236.py index afdfac950..cf0a62113 100644 --- a/apps/backpack/migrations/0008_auto_20171204_1236.py +++ b/apps/backpack/migrations/0008_auto_20171204_1236.py @@ -6,20 +6,33 @@ class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0007_auto_20171027_0837'), + ("backpack", "0007_auto_20171027_0837"), ] operations = [ migrations.AlterField( - model_name='backpackbadgeshare', - name='provider', - field=models.CharField(choices=[(b'twitter', b'Twitter'), (b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn')], max_length=254), + model_name="backpackbadgeshare", + name="provider", + field=models.CharField( + choices=[ + (b"twitter", b"Twitter"), + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + ], + max_length=254, + ), ), migrations.AlterField( - model_name='backpackcollectionshare', - name='provider', - field=models.CharField(choices=[(b'twitter', b'Twitter'), (b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn')], max_length=254), + model_name="backpackcollectionshare", + name="provider", + field=models.CharField( + choices=[ + (b"twitter", b"Twitter"), + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + ], + max_length=254, + ), ), ] diff --git a/apps/backpack/migrations/0009_auto_20180119_0711.py b/apps/backpack/migrations/0009_auto_20180119_0711.py index 484301788..7f85b9207 100644 --- a/apps/backpack/migrations/0009_auto_20180119_0711.py +++ b/apps/backpack/migrations/0009_auto_20180119_0711.py @@ -6,30 +6,45 @@ class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0008_auto_20171204_1236'), + ("backpack", "0008_auto_20171204_1236"), ] operations = [ migrations.AddField( - model_name='backpackbadgeshare', - name='source', - field=models.CharField(default='unknown', max_length=254), + model_name="backpackbadgeshare", + name="source", + field=models.CharField(default="unknown", max_length=254), ), migrations.AddField( - model_name='backpackcollectionshare', - name='source', - field=models.CharField(default='unknown', max_length=254), + model_name="backpackcollectionshare", + name="source", + field=models.CharField(default="unknown", max_length=254), ), migrations.AlterField( - model_name='backpackbadgeshare', - name='provider', - field=models.CharField(choices=[(b'portfolium', b'Portfolium'), (b'twitter', b'Twitter'), (b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn')], max_length=254), + model_name="backpackbadgeshare", + name="provider", + field=models.CharField( + choices=[ + (b"portfolium", b"Portfolium"), + (b"twitter", b"Twitter"), + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + ], + max_length=254, + ), ), migrations.AlterField( - model_name='backpackcollectionshare', - name='provider', - field=models.CharField(choices=[(b'portfolium', b'Portfolium'), (b'twitter', b'Twitter'), (b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn')], max_length=254), + model_name="backpackcollectionshare", + name="provider", + field=models.CharField( + choices=[ + (b"portfolium", b"Portfolium"), + (b"twitter", b"Twitter"), + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + ], + max_length=254, + ), ), ] diff --git a/apps/backpack/migrations/0010_auto_20180802_1026.py b/apps/backpack/migrations/0010_auto_20180802_1026.py index bbec75faf..016de31eb 100644 --- a/apps/backpack/migrations/0010_auto_20180802_1026.py +++ b/apps/backpack/migrations/0010_auto_20180802_1026.py @@ -6,20 +6,35 @@ class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0009_auto_20180119_0711'), + ("backpack", "0009_auto_20180119_0711"), ] operations = [ migrations.AlterField( - model_name='backpackbadgeshare', - name='provider', - field=models.CharField(choices=[(b'twitter', b'Twitter'), (b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn'), (b'pinterest', b'Pinterest')], max_length=254), + model_name="backpackbadgeshare", + name="provider", + field=models.CharField( + choices=[ + (b"twitter", b"Twitter"), + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + (b"pinterest", b"Pinterest"), + ], + max_length=254, + ), ), migrations.AlterField( - model_name='backpackcollectionshare', - name='provider', - field=models.CharField(choices=[(b'twitter', b'Twitter'), (b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn'), (b'pinterest', b'Pinterest')], max_length=254), + model_name="backpackcollectionshare", + name="provider", + field=models.CharField( + choices=[ + (b"twitter", b"Twitter"), + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + (b"pinterest", b"Pinterest"), + ], + max_length=254, + ), ), ] diff --git a/apps/backpack/migrations/0011_auto_20181102_1438.py b/apps/backpack/migrations/0011_auto_20181102_1438.py index 746a4282a..ad85a1b2f 100644 --- a/apps/backpack/migrations/0011_auto_20181102_1438.py +++ b/apps/backpack/migrations/0011_auto_20181102_1438.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0010_auto_20180802_1026'), + ("backpack", "0010_auto_20180802_1026"), ] operations = [ migrations.AlterField( - model_name='backpackcollection', - name='created_at', + model_name="backpackcollection", + name="created_at", field=models.DateTimeField(auto_now_add=True, db_index=True), ), ] diff --git a/apps/backpack/migrations/0012_auto_20200106_1621.py b/apps/backpack/migrations/0012_auto_20200106_1621.py index d8e4b0a94..9e4e0bdf9 100644 --- a/apps/backpack/migrations/0012_auto_20200106_1621.py +++ b/apps/backpack/migrations/0012_auto_20200106_1621.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0011_auto_20181102_1438'), + ("backpack", "0011_auto_20181102_1438"), ] operations = [ migrations.AlterField( - model_name='backpackcollection', - name='updated_at', + model_name="backpackcollection", + name="updated_at", field=models.DateTimeField(auto_now=True, db_index=True), ), ] diff --git a/apps/backpack/migrations/0013_auto_20200608_0452.py b/apps/backpack/migrations/0013_auto_20200608_0452.py index ebae4d44c..bd9979bce 100644 --- a/apps/backpack/migrations/0013_auto_20200608_0452.py +++ b/apps/backpack/migrations/0013_auto_20200608_0452.py @@ -4,20 +4,35 @@ class Migration(migrations.Migration): - dependencies = [ - ('backpack', '0012_auto_20200106_1621'), + ("backpack", "0012_auto_20200106_1621"), ] operations = [ migrations.AlterField( - model_name='backpackbadgeshare', - name='provider', - field=models.CharField(choices=[('facebook', 'Facebook'), ('linkedin', 'LinkedIn'), ('twitter', 'Twitter'), ('pinterest', 'Pinterest')], max_length=254), + model_name="backpackbadgeshare", + name="provider", + field=models.CharField( + choices=[ + ("facebook", "Facebook"), + ("linkedin", "LinkedIn"), + ("twitter", "Twitter"), + ("pinterest", "Pinterest"), + ], + max_length=254, + ), ), migrations.AlterField( - model_name='backpackcollectionshare', - name='provider', - field=models.CharField(choices=[('facebook', 'Facebook'), ('linkedin', 'LinkedIn'), ('twitter', 'Twitter'), ('pinterest', 'Pinterest')], max_length=254), + model_name="backpackcollectionshare", + name="provider", + field=models.CharField( + choices=[ + ("facebook", "Facebook"), + ("linkedin", "LinkedIn"), + ("twitter", "Twitter"), + ("pinterest", "Pinterest"), + ], + max_length=254, + ), ), ] diff --git a/apps/backpack/models.py b/apps/backpack/models.py index 998e3351b..bf45c652d 100644 --- a/apps/backpack/models.py +++ b/apps/backpack/models.py @@ -20,7 +20,7 @@ class BackpackCollection(BaseAuditedModelDeletedWithUser, BaseVersionedEntity): - entity_class_name = 'BackpackCollection' + entity_class_name = "BackpackCollection" name = models.CharField(max_length=128) description = models.CharField(max_length=255, blank=True) share_hash = models.CharField(max_length=255, null=False, blank=True) @@ -28,24 +28,31 @@ class BackpackCollection(BaseAuditedModelDeletedWithUser, BaseVersionedEntity): # slug has been deprecated, but keep for legacy collections redirects slug = models.CharField(max_length=254, blank=True, null=True, default=None) - assertions = models.ManyToManyField('issuer.BadgeInstance', blank=True, through='backpack.BackpackCollectionBadgeInstance') + assertions = models.ManyToManyField( + "issuer.BadgeInstance", + blank=True, + through="backpack.BackpackCollectionBadgeInstance", + ) - cached = SlugOrJsonIdCacheModelManager(slug_kwarg_name='entity_id', slug_field_name='entity_id') + cached = SlugOrJsonIdCacheModelManager( + slug_kwarg_name="entity_id", slug_field_name="entity_id" + ) def publish(self): super(BackpackCollection, self).publish() - self.publish_by('share_hash') + self.publish_by("share_hash") self.created_by.publish() def delete(self, *args, **kwargs): super(BackpackCollection, self).delete(*args, **kwargs) - self.publish_delete('share_hash') + self.publish_delete("share_hash") self.created_by.publish() def save(self, **kwargs): if self.pk: BackpackCollectionBadgeInstance.objects.filter( - Q(badgeinstance__acceptance=BadgeInstance.ACCEPTANCE_REJECTED) | Q(badgeinstance__revoked=True) + Q(badgeinstance__acceptance=BadgeInstance.ACCEPTANCE_REJECTED) + | Q(badgeinstance__revoked=True) ).delete() super(BackpackCollection, self).save(**kwargs) @@ -53,21 +60,34 @@ def save(self, **kwargs): def cached_badgeinstances(self): return self.assertions.filter( revoked=False, - acceptance__in=(BadgeInstance.ACCEPTANCE_ACCEPTED, BadgeInstance.ACCEPTANCE_UNACCEPTED) + acceptance__in=( + BadgeInstance.ACCEPTANCE_ACCEPTED, + BadgeInstance.ACCEPTANCE_UNACCEPTED, + ), ) @cachemodel.cached_method(auto_publish=True) def cached_collects(self): return self.backpackcollectionbadgeinstance_set.filter( badgeinstance__revoked=False, - badgeinstance__acceptance__in=(BadgeInstance.ACCEPTANCE_ACCEPTED,BadgeInstance.ACCEPTANCE_UNACCEPTED) + badgeinstance__acceptance__in=( + BadgeInstance.ACCEPTANCE_ACCEPTED, + BadgeInstance.ACCEPTANCE_UNACCEPTED, + ), ) @property def owner(self): from badgeuser.models import BadgeUser + return BadgeUser.cached.get(id=self.created_by_id) + @property + def permanent_url(self): + return OriginSetting.HTTP + reverse( + "collection_json", kwargs={"entity_id": self.entity_id} + ) + # Convenience methods for toggling published state @property def published(self): @@ -76,15 +96,17 @@ def published(self): @published.setter def published(self, value): if value and not self.share_hash: - self.share_hash = str(binascii.hexlify(os.urandom(16)), 'utf-8') + self.share_hash = str(binascii.hexlify(os.urandom(16)), "utf-8") elif not value and self.share_hash: - self.publish_delete('share_hash') - self.share_hash = '' + self.publish_delete("share_hash") + self.share_hash = "" @property def share_url(self): if self.published: - return OriginSetting.HTTP+reverse('collection_json', kwargs={'entity_id': self.share_hash}) + return OriginSetting.HTTP + reverse( + "collection_json", kwargs={"entity_id": self.share_hash} + ) def get_share_url(self, **kwargs): return self.share_url @@ -96,9 +118,12 @@ def badge_items(self): @badge_items.setter def badge_items(self, value): """ - Update this collection's list of BackpackCollectionBadgeInstance from a list of BadgeInstance EntityRelatedFieldV2 serializer data - :param value: list of BadgeInstance instances or list of BadgeInstance entity_id strings. + Update this collection's list of BackpackCollectionBadgeInstance + from a list of BadgeInstance EntityRelatedFieldV2 serializer data + :param value: list of BadgeInstance instances or list of + BadgeInstance entity_id strings. """ + def _is_in_requested_badges(entity_id): if entity_id in value: return True @@ -117,43 +142,61 @@ def _is_in_requested_badges(entity_id): if isinstance(badge_reference, BadgeInstance): badgeinstance = badge_reference else: - badgeinstance = BadgeInstance.cached.get(entity_id=badge_reference) + badgeinstance = BadgeInstance.cached.get( + entity_id=badge_reference + ) except BadgeInstance.DoesNotExist: pass else: if badgeinstance.entity_id not in list(existing_badges.keys()): BackpackCollectionBadgeInstance.cached.get_or_create( - collection=self, - badgeinstance=badgeinstance + collection=self, badgeinstance=badgeinstance ) # remove badges no longer in collection for badge_entity_id, badgeinstance in list(existing_badges.items()): if not _is_in_requested_badges(badge_entity_id): BackpackCollectionBadgeInstance.objects.filter( - collection=self, - badgeinstance=badgeinstance + collection=self, badgeinstance=badgeinstance ).delete() - def get_json(self, obi_version=CURRENT_OBI_VERSION, expand_badgeclass=False, expand_issuer=False, include_extra=True): + def get_json( + self, + obi_version=CURRENT_OBI_VERSION, + expand_badgeclass=False, + expand_issuer=False, + include_extra=True, + ): obi_version, context_iri = get_obi_context(obi_version) - json = OrderedDict([ - ('@context', context_iri), - ('type', 'Collection'), - ('id', add_obi_version_ifneeded(self.share_url, obi_version)), - ('name', self.name), - ('description', self.description), - ('entityId', self.entity_id), - ('owner', OrderedDict([ - ('firstName', self.cached_creator.first_name), - ('lastName', self.cached_creator.last_name), - ])) - ]) - json['badges'] = [b.get_json(obi_version=obi_version, - expand_badgeclass=expand_badgeclass, - expand_issuer=expand_issuer, - include_extra=include_extra) for b in self.cached_badgeinstances()] + json = OrderedDict( + [ + ("@context", context_iri), + ("type", "Collection"), + ("id", add_obi_version_ifneeded(self.share_url, obi_version)), + ("name", self.name), + ("description", self.description), + ("entityId", self.entity_id), + ( + "owner", + OrderedDict( + [ + ("firstName", self.cached_creator.first_name), + ("lastName", self.cached_creator.last_name), + ] + ), + ), + ] + ) + json["badges"] = [ + b.get_json( + obi_version=obi_version, + expand_badgeclass=expand_badgeclass, + expand_issuer=expand_issuer, + include_extra=include_extra, + ) + for b in self.cached_badgeinstances() + ] return json @@ -166,12 +209,13 @@ def cached_badgrapp(self): class BackpackCollectionBadgeInstance(cachemodel.CacheModel): - collection = models.ForeignKey('backpack.BackpackCollection', - on_delete=models.CASCADE) - badgeuser = models.ForeignKey('badgeuser.BadgeUser', null=True, default=None, - on_delete=models.CASCADE) - badgeinstance = models.ForeignKey('issuer.BadgeInstance', - on_delete=models.CASCADE) + collection = models.ForeignKey( + "backpack.BackpackCollection", on_delete=models.CASCADE + ) + badgeuser = models.ForeignKey( + "badgeuser.BadgeUser", null=True, default=None, on_delete=models.CASCADE + ) + badgeinstance = models.ForeignKey("issuer.BadgeInstance", on_delete=models.CASCADE) def publish(self): super(BackpackCollectionBadgeInstance, self).publish() @@ -191,7 +235,10 @@ def cached_collection(self): class BaseSharedModel(cachemodel.CacheModel, CreatedUpdatedAt): - SHARE_PROVIDERS = [(p.provider_code, p.provider_name) for code,p in list(SharingManager.ManagerProviders.items())] + SHARE_PROVIDERS = [ + (p.provider_code, p.provider_name) + for code, p in list(SharingManager.ManagerProviders.items()) + ] provider = models.CharField(max_length=254, choices=SHARE_PROVIDERS) source = models.CharField(max_length=254, default="unknown") @@ -203,16 +250,18 @@ def get_share_url(self, provider, **kwargs): class BackpackBadgeShare(BaseSharedModel): - badgeinstance = models.ForeignKey("issuer.BadgeInstance", null=True, - on_delete=models.CASCADE) + badgeinstance = models.ForeignKey( + "issuer.BadgeInstance", null=True, on_delete=models.CASCADE + ) def get_share_url(self, provider, **kwargs): return SharingManager.share_url(provider, self.badgeinstance, **kwargs) class BackpackCollectionShare(BaseSharedModel): - collection = models.ForeignKey('backpack.BackpackCollection', null=False, - on_delete=models.CASCADE) + collection = models.ForeignKey( + "backpack.BackpackCollection", null=False, on_delete=models.CASCADE + ) def get_share_url(self, provider, **kwargs): return SharingManager.share_url(provider, self.collection, **kwargs) diff --git a/apps/backpack/serializers_bcv1.py b/apps/backpack/serializers_bcv1.py index 506411fbb..9186a36cd 100644 --- a/apps/backpack/serializers_bcv1.py +++ b/apps/backpack/serializers_bcv1.py @@ -1,8 +1,6 @@ # encoding: utf-8 from __future__ import unicode_literals -from collections import OrderedDict - from rest_framework import serializers from django.core.exceptions import ValidationError as DjangoValidationError from rest_framework.exceptions import ValidationError as RestframeworkValidationError @@ -12,9 +10,10 @@ from issuer.models import BadgeInstance from issuer.serializers_v2 import BadgeRecipientSerializerV2, EvidenceItemSerializerV2 from mainsite.serializers import MarkdownCharField, HumanReadableBooleanField +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes - -CONTEXT_URI = 'https://w3id.org/openbadges/v2' +CONTEXT_URI = "https://w3id.org/openbadges/v2" class BadgeConnectApiInfoSerializer(serializers.Serializer): @@ -25,240 +24,103 @@ class BadgeConnectApiInfoSerializer(serializers.Serializer): termsOfServiceUrl = serializers.URLField(read_only=True) privacyPolicyUrl = serializers.URLField(read_only=True) scopesOffered = serializers.ListField(read_only=True, child=serializers.URLField()) - scopesRequested = serializers.ListField(read_only=True, child=serializers.URLField()) + scopesRequested = serializers.ListField( + read_only=True, child=serializers.URLField() + ) registrationUrl = serializers.URLField(read_only=True) authorizationUrl = serializers.URLField(read_only=True) tokenUrl = serializers.URLField(read_only=True) - class Meta: - apispec_definition = ('BadgeConnectApiInfo', { - 'properties': OrderedDict([]) - }) - class BadgeConnectManifestSerializer(serializers.Serializer): id = serializers.URLField(read_only=True) badgeConnectAPI = BadgeConnectApiInfoSerializer(read_only=True, many=True) - class Meta: - apispec_definition = ('BadgeConnectManifest', { - 'properties': OrderedDict([ - ('badgeConnectAPI', { - 'type': 'object', - '$ref': '#/definitions/BadgeConnectApiInfo' - }), - ]) - }) - def to_representation(self, instance): data = super(BadgeConnectManifestSerializer, self).to_representation(instance) - data['@context'] = 'https://w3id.org/openbadges/badgeconnect/v1' + data["@context"] = "https://w3id.org/openbadges/badgeconnect/v1" return data class BadgeConnectStatusSerializer(serializers.Serializer): error = serializers.CharField(default=None) statusCode = serializers.IntegerField(default=200) - statusText = serializers.CharField(default='OK') - - class Meta: - apispec_definition = ('BadgeConnectStatus', { - 'properties': OrderedDict([ - ('error', { - 'type': "string", - 'readOnly': True, - 'example': None, - 'description': "Error text, if any", - }), - ('statusCode', { - 'type': "integer", - 'example': 200, - 'readOnly': True, - 'description': "Status code of request", - }), - ('statusText', { - 'type': "string", - 'readOnly': True, - 'example': 'OK', - 'description': "Status text of request", - }), - ]) - }) + statusText = serializers.CharField(default="OK") class BadgeConnectErrorSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): - self.error = kwargs.pop('error', None) - self.status_text = kwargs.pop('status_text', 'BAD_REQUEST') - self.status_code = kwargs.pop('status_code', 400) + self.error = kwargs.pop("error", None) + self.status_text = kwargs.pop("status_text", "BAD_REQUEST") + self.status_code = kwargs.pop("status_code", 400) super(BadgeConnectErrorSerializer, self).__init__(*args, **kwargs) - + def to_representation(self, instance): return { "status": { "error": self.error, "statusCode": self.status_code, - "statusText": self.status_text + "statusText": self.status_text, } } class BadgeConnectBaseEntitySerializer(serializers.Serializer): def to_representation(self, instance): - representation = super(BadgeConnectBaseEntitySerializer, self).to_representation(instance) - representation['@context'] = CONTEXT_URI + representation = super( + BadgeConnectBaseEntitySerializer, self + ).to_representation(instance) + representation["@context"] = CONTEXT_URI return representation class BadgeConnectAssertionSerializer(BadgeConnectBaseEntitySerializer): - id = serializers.URLField(source='jsonld_id', read_only=True) - badge = serializers.URLField(source='badgeclass_jsonld_id', read_only=True) + id = serializers.URLField(source="jsonld_id", read_only=True) + badge = serializers.URLField(source="badgeclass_jsonld_id", read_only=True) image = serializers.FileField(read_only=True) - recipient = BadgeRecipientSerializerV2(source='*') - issuedOn = serializers.DateTimeField(source='issued_on', read_only=True) + recipient = BadgeRecipientSerializerV2(source="*") + issuedOn = serializers.DateTimeField(source="issued_on", read_only=True) narrative = MarkdownCharField(required=False) evidence = EvidenceItemSerializerV2(many=True, required=False) revoked = HumanReadableBooleanField(read_only=True) - revocationReason = serializers.CharField(source='revocation_reason', read_only=True) - expires = serializers.DateTimeField(source='expires_at', required=False) - type = serializers.CharField(read_only=True, default='Assertion') + revocationReason = serializers.CharField(source="revocation_reason", read_only=True) + expires = serializers.DateTimeField(source="expires_at", required=False) + type = serializers.CharField(read_only=True, default="Assertion") class Meta: - SCHEMA_TYPE = 'Assertion' + SCHEMA_TYPE = "Assertion" model = BadgeInstance - apispec_definition = ('BadgeConnectAssertion', { - 'properties': OrderedDict([ - ('id', { - 'type': "string", - 'format': "url", - 'readOnly': True, - 'description': "URL of the BadgeInstance", - }), - ('badge', { - 'type': "string", - 'format': "url", - 'readOnly': True, - 'description': "URL of the BadgeClass", - }), - ('image', { - 'type': "string", - 'format': "string", - 'readOnly': True, - 'description': "Badge Image", - }), - ('recipient', { - 'type': 'object', - 'properties': BadgeRecipientSerializerV2.Meta.apispec_definition[1]['properties'], - 'readOnly': True, - 'description': "Recipient that was issued the Assertion" - }), - ('issuedOn', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'readOnly': True, - 'description': "Timestamp when the Assertion was issued", - }), - ('narrative', { - 'type': 'string', - 'format': 'markdown', - 'description': "Markdown narrative of the achievement", - }), - ('evidence', { - 'type': "string", - 'format': "string", - 'description': "Unique identifier for this Assertion", - }), - ('revoked', { - 'type': 'boolean', - 'readOnly': True, - 'description': "True if this Assertion has been revoked", - }), - ('revocationReason', { - 'type': 'string', - 'format': "string", - 'readOnly': True, - 'description': "Short description of why the Assertion was revoked", - }), - ('expires', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the Assertion expires", - }), - ('@context', { - 'type': 'string', - 'format': 'url', - 'default': CONTEXT_URI, - 'example': CONTEXT_URI, - }), - ('type', { - 'type': 'string', - 'default': SCHEMA_TYPE, - 'example': SCHEMA_TYPE - }) - ]) - }) - class BadgeConnectAssertionsSerializer(serializers.Serializer): status = BadgeConnectStatusSerializer(read_only=True, default={}) - results = BadgeConnectAssertionSerializer(many=True, source='*') + results = BadgeConnectAssertionSerializer(many=True, source="*") - class Meta: - apispec_definition = ('BadgeConnectAssertions', { - 'properties': OrderedDict([ - ('status', { - 'type': 'object', - '$ref': '#/definitions/BadgeConnectStatus' - }), - ('results', { - 'type': 'array', - 'items': { - 'type': 'object', - '$ref': '#/definitions/BadgeConnectAssertion' - } - }) - ]) - }) class BackpackImportResultSerializerBC(serializers.Serializer): status = BadgeConnectStatusSerializer(read_only=True, default={}) - class Meta: - apispec_definition = ('BadgeConnectImportResult', { - 'properties': OrderedDict([ - ('status', { - 'type': 'object', - '$ref': '#/definitions/BadgeConnectStatus' - }), - ]) - }) class BadgeConnectImportSerializer(serializers.Serializer): assertion = serializers.DictField() - class Meta: - apispec_definition = ('BadgeConnectImport', { - 'properties': OrderedDict([ - ('assertion', { - 'type': 'object', - 'properties': { - 'id': { - 'format': "url", - 'description': "URL of the Badge to import" - } - }}) - ]) - }) - def create(self, validated_data): - url = validated_data['assertion']['id'] + url = validated_data["assertion"]["id"] try: - instance, created = BadgeCheckHelper.get_or_create_assertion(url=url, created_by=self.context['request'].user) + instance, created = BadgeCheckHelper.get_or_create_assertion( + url=url, created_by=self.context["request"].user + ) if not created: instance.acceptance = BadgeInstance.ACCEPTANCE_ACCEPTED instance.save() - raise RestframeworkValidationError([{'name': "DUPLICATE_BADGE", 'description': "You already have this badge in your backpack"}]) + raise RestframeworkValidationError( + [ + { + "name": "DUPLICATE_BADGE", + "description": "You already have this badge in your backpack", + } + ] + ) except DjangoValidationError as e: raise RestframeworkValidationError(e.messages) return instance @@ -269,74 +131,38 @@ def to_representation(self, instance): class BaseSerializerBC(serializers.Serializer): - @staticmethod def response_envelope(result=None): envelope = { "status": { "error": None, "statusCode": 200, - "statusText": 'OK', + "statusText": "OK", }, } if result is not None: - envelope['results'] = result + envelope["results"] = result return envelope - class BadgeConnectProfile(BadgeConnectBaseEntitySerializer): name = serializers.SerializerMethodField() email = serializers.EmailField() class Meta: model = BadgeUser - apispec_definition = ('BadgeConnectProfile', { - 'properties': OrderedDict([ - ('name', { - 'type': "string", - 'format': "string", - 'description': "Name on the profile", - }), - ('email', { - 'type': "string", - 'format': "email", - 'description': "Email on the profile", - }), - ('@context', { - 'type': 'string', - 'format': 'url', - 'default': CONTEXT_URI, - 'example': CONTEXT_URI, - }) - ]) - }) + @extend_schema_field(OpenApiTypes.STR) def get_name(self, instance): - return '%s %s' % (instance.first_name, instance.last_name) + return "%s %s" % (instance.first_name, instance.last_name) class BackpackProfilesSerializerBC(serializers.Serializer): # This class is pluralized to be consistent with the shape of the data # it returns, however it should always contain 1 profile. status = BadgeConnectStatusSerializer(read_only=True, default={}) - results = BadgeConnectProfile(many=True, source='*') + results = BadgeConnectProfile(many=True, source="*") class Meta: model = BadgeUser - apispec_definition = ('BadgeConnectProfiles', { - 'properties': OrderedDict([ - ('status', { - 'type': 'object', - '$ref': '#/definitions/BadgeConnectStatus' - }), - ('results', { - 'type': 'array', - 'items': { - 'type': 'object', - '$ref': '#/definitions/BadgeConnectProfile' - } - }) - ]) - }) \ No newline at end of file diff --git a/apps/backpack/serializers_v1.py b/apps/backpack/serializers_v1.py index cdd91b1d9..8fda3e823 100644 --- a/apps/backpack/serializers_v1.py +++ b/apps/backpack/serializers_v1.py @@ -5,19 +5,127 @@ from django.urls import reverse from django.utils.dateparse import parse_datetime, parse_date from rest_framework import serializers +from rest_framework.fields import JSONField from rest_framework.exceptions import ValidationError as RestframeworkValidationError from rest_framework.fields import SkipField -import badgrlog from backpack.models import BackpackCollection, BackpackCollectionBadgeInstance -from issuer.helpers import BadgeCheckHelper -from issuer.models import BadgeInstance +from issuer.helpers import BadgeCheckHelper, ImportedBadgeHelper +from issuer.models import BadgeInstance, ImportedBadgeAssertion from issuer.serializers_v1 import EvidenceItemSerializer from mainsite.drf_fields import Base64FileField from mainsite.serializers import StripTagsCharField, MarkdownCharField from mainsite.utils import OriginSetting -logger = badgrlog.BadgrLogger() + +class ImportedBadgeAssertionSerializer(serializers.Serializer): + """ + Serializer for importing and retrieving imported badge assertions. + """ + + image = Base64FileField(required=False, write_only=True) + url = serializers.URLField(required=False, write_only=True) + assertion = serializers.CharField(required=False, write_only=True) + id = serializers.CharField(source="entity_id", read_only=True) + badge_name = serializers.CharField(read_only=True) + badge_description = serializers.CharField(read_only=True) + issuer_name = serializers.CharField(read_only=True) + issuer_url = serializers.URLField(read_only=True) + issued_on = serializers.DateTimeField(read_only=True) + recipient_identifier = serializers.CharField(read_only=True) + recipient_type = serializers.CharField(read_only=True) + acceptance = serializers.CharField(default="Accepted") + narrative = MarkdownCharField(read_only=True) + + original_json = serializers.JSONField(read_only=True) + extensions = serializers.DictField(source="extension_items", read_only=True) + + def to_representation(self, obj): + representation = super().to_representation(obj) + + if obj.image: + representation["image"] = obj.image.url + elif obj.badge_image_url: + representation["image"] = obj.badge_image_url + + representation["imagePreview"] = {"type": "image", "id": obj.image_url()} + + if obj.issuer_image_url: + representation["issuerImagePreview"] = { + "type": "image", + "id": obj.issuer_image_url, + } + + representation["verification"] = { + "type": "HostedBadge", + "url": obj.verification_url, + } + + representation["slug"] = obj.entity_id + + representation["json"] = { + "id": obj.original_json.get("assertion", {}).get("id"), + "type": "Assertion", + "badge": { + "name": obj.badge_name, + "description": obj.badge_description, + "image": obj.badge_image_url, + "issuer": { + "name": obj.issuer_name, + "url": obj.issuer_url, + "email": obj.issuer_email, + "image": obj.issuer_image_url, + }, + }, + "issuedOn": obj.issued_on if obj.issued_on else None, + "recipient": { + "type": obj.recipient_type, + "identity": obj.recipient_identifier, + }, + "verification": {"type": "HostedBadge", "url": obj.verification_url}, + } + + return representation + + def validate(self, data): + """ + Ensure only one assertion input field given. + """ + fields_present = [ + "image" in data, + "url" in data, + "assertion" in data and data.get("assertion"), + ] + if fields_present.count(True) > 1: + raise serializers.ValidationError("Only one instance input field allowed.") + + return data + + def create(self, validated_data): + owner = validated_data.get("user") + + try: + instance, created = ImportedBadgeHelper.get_or_create_imported_badge( + url=validated_data.get("url", None), + imagefile=validated_data.get("image", None), + assertion=validated_data.get("assertion", None), + user=owner, + ) + if not created: + if instance.acceptance == ImportedBadgeAssertion.ACCEPTANCE_ACCEPTED: + raise RestframeworkValidationError( + [ + { + "name": "DUPLICATE_BADGE", + "description": "You already have this badge in your backpack", + } + ] + ) + instance.acceptance = ImportedBadgeAssertion.ACCEPTANCE_ACCEPTED + instance.save() + except DjangoValidationError as e: + raise RestframeworkValidationError(e.args[0]) + return instance class LocalBadgeInstanceUploadSerializerV1(serializers.Serializer): @@ -25,12 +133,12 @@ class LocalBadgeInstanceUploadSerializerV1(serializers.Serializer): url = serializers.URLField(required=False, write_only=True) assertion = serializers.CharField(required=False, write_only=True) recipient_identifier = serializers.CharField(required=False, read_only=True) - acceptance = serializers.CharField(default='Accepted') + acceptance = serializers.CharField(default="Accepted") narrative = MarkdownCharField(required=False, read_only=True) evidence_items = EvidenceItemSerializer(many=True, required=False, read_only=True) - pending = serializers.ReadOnlyField() + pending = serializers.BooleanField(read_only=True) - extensions = serializers.DictField(source='extension_items', read_only=True) + extensions = serializers.DictField(source="extension_items", read_only=True) # Reinstantiation using fields from badge instance when returned by .create # id = serializers.IntegerField(read_only=True) @@ -42,73 +150,123 @@ def to_representation(self, obj): variable 'format' from a query param in the GET request with the value "plain", make the `json` field for this instance read_only. """ - if self.context.get('format', 'v1') == 'plain': + if self.context.get("format", "v1") == "plain": self.fields.json = serializers.DictField(read_only=True) - representation = super(LocalBadgeInstanceUploadSerializerV1, self).to_representation(obj) - - # if isinstance(obj, LocalBadgeInstance): - # representation['json'] = V1InstanceSerializer(obj.json, context=self.context).data - # representation['imagePreview'] = { - # "type": "image", - # "id": "{}{}?type=png".format(OriginSetting.HTTP, reverse('localbadgeinstance_image', kwargs={'slug': obj.slug})) - # } - # if isinstance(obj, BadgeInstance): - - representation['id'] = obj.entity_id - representation['json'] = V1BadgeInstanceSerializer(obj, context=self.context).data - representation['imagePreview'] = { + representation = super( + LocalBadgeInstanceUploadSerializerV1, self + ).to_representation(obj) + + # supress errors for badges without extensions (i.e. imported) + try: + representation["extensions"]["extensions:CompetencyExtension"] = ( + obj.badgeclass.json["extensions:CompetencyExtension"] + ) + representation["extensions"]["extensions:CategoryExtension"] = ( + obj.badgeclass.json["extensions:CategoryExtension"] + ) + representation["extensions"]["extensions:StudyLoadExtension"] = ( + obj.badgeclass.json["extensions:StudyLoadExtension"] + ) + except KeyError: + pass + + representation["id"] = obj.entity_id + representation["json"] = V1BadgeInstanceSerializer( + obj, context=self.context + ).data + representation["imagePreview"] = { "type": "image", - "id": "{}{}?type=png".format(OriginSetting.HTTP, reverse('badgeclass_image', kwargs={'entity_id': obj.cached_badgeclass.entity_id})) + "id": "{}{}?type=png".format( + OriginSetting.HTTP, + reverse( + "badgeclass_image", + kwargs={"entity_id": obj.cached_badgeclass.entity_id}, + ), + ), } if obj.cached_issuer.image: - representation['issuerImagePreview'] = { + representation["issuerImagePreview"] = { "type": "image", - "id": "{}{}?type=png".format(OriginSetting.HTTP, reverse('issuer_image', kwargs={'entity_id': obj.cached_issuer.entity_id})) + "id": "{}{}?type=png".format( + OriginSetting.HTTP, + reverse( + "issuer_image", + kwargs={"entity_id": obj.cached_issuer.entity_id}, + ), + ), } if obj.image: - representation['image'] = obj.image_url() + representation["image"] = obj.image_url() + + representation["shareUrl"] = obj.share_url + representation["courseUrl"] = obj.course_url + + networkShare = obj.cached_badgeclass.network_shares.filter( + is_active=True + ).first() + if networkShare: + network = networkShare.network + representation["sharedOnNetwork"] = { + "slug": network.entity_id, + "name": network.name, + "image": network.image.url, + "description": network.description, + } + else: + representation["sharedOnNetwork"] = None - representation['shareUrl'] = obj.share_url + representation["isNetworkBadge"] = ( + obj.cached_badgeclass.cached_issuer.is_network + and representation["sharedOnNetwork"] is None + ) + + if representation["isNetworkBadge"]: + representation["networkName"] = obj.cached_badgeclass.cached_issuer.name + representation["networkImage"] = ( + obj.cached_badgeclass.cached_issuer.image.url + ) + else: + representation["networkImage"] = None + representation["networkName"] = None return representation - # def validate_recipient_identifier(self, data): - # user = self.context.get('request').user - # current_emails = [e.email for e in user.cached_emails()] + [e.email for e in user.cached_email_variants()] - # - # if data in current_emails: - # return None - # if user.can_add_variant(data): - # return data - # raise serializers.ValidationError("Requested recipient ID {} is not one of your verified email addresses.") - # def validate(self, data): """ Ensure only one assertion input field given. """ - fields_present = ['image' in data, 'url' in data, - 'assertion' in data and data.get('assertion')] - if (fields_present.count(True) > 1): - raise serializers.ValidationError( - "Only one instance input field allowed.") + fields_present = [ + "image" in data, + "url" in data, + "assertion" in data and data.get("assertion"), + ] + if fields_present.count(True) > 1: + raise serializers.ValidationError("Only one instance input field allowed.") return data def create(self, validated_data): - owner = validated_data.get('created_by') + owner = validated_data.get("created_by") + try: - instance, created = BadgeCheckHelper.get_or_create_assertion( - url=validated_data.get('url', None), - imagefile=validated_data.get('image', None), - assertion=validated_data.get('assertion', None), + instance, created = BadgeCheckHelper.get_or_create_imported_badge( + url=validated_data.get("url", None), + imagefile=validated_data.get("image", None), + assertion=validated_data.get("assertion", None), created_by=owner, ) if not created: if instance.acceptance == BadgeInstance.ACCEPTANCE_ACCEPTED: raise RestframeworkValidationError( - [{'name': "DUPLICATE_BADGE", 'description': "You already have this badge in your backpack"}]) + [ + { + "name": "DUPLICATE_BADGE", + "description": "You already have this badge in your backpack", + } + ] + ) instance.acceptance = BadgeInstance.ACCEPTANCE_ACCEPTED instance.save() owner.publish() # update BadgeUser.cached_badgeinstances() @@ -117,10 +275,13 @@ def create(self, validated_data): return instance def update(self, instance, validated_data): - """ Only updating acceptance status (to 'Accepted') is permitted for now. """ + """Only updating acceptance status (to 'Accepted') is permitted for now.""" # Only locally issued badges will ever have an acceptance status other than 'Accepted' - if instance.acceptance == 'Unaccepted' and validated_data.get('acceptance') == 'Accepted': - instance.acceptance = 'Accepted' + if ( + instance.acceptance == "Unaccepted" + and validated_data.get("acceptance") == "Accepted" + ): + instance.acceptance = "Accepted" instance.save() owner = instance.user @@ -131,25 +292,41 @@ def update(self, instance, validated_data): class CollectionBadgesSerializerV1(serializers.ListSerializer): - def to_representation(self, data): - filtered_data = [b for b in data if b.cached_badgeinstance.acceptance is not BadgeInstance.ACCEPTANCE_REJECTED and b.cached_badgeinstance.revoked is False] - filtered_data = [c for c in filtered_data if c.cached_badgeinstance.recipient_identifier in c.cached_collection.owner.all_verified_recipient_identifiers] + filtered_data = [ + b + for b in data + if ( + b.cached_badgeinstance.acceptance + is not BadgeInstance.ACCEPTANCE_REJECTED + and b.cached_badgeinstance.revoked is False + ) + ] + filtered_data = [ + c + for c in filtered_data + if ( + c.cached_badgeinstance.recipient_identifier + in c.cached_collection.owner.all_verified_recipient_identifiers + ) + ] - representation = super(CollectionBadgesSerializerV1, self).to_representation(filtered_data) + representation = super(CollectionBadgesSerializerV1, self).to_representation( + filtered_data + ) return representation def save(self, **kwargs): - collection = self.context.get('collection') + collection = self.context.get("collection") updated_ids = set() # get all referenced badges in validated_data for entry in self.validated_data: - if not entry.pk or getattr(entry, '_dirty', False): + if not entry.pk or getattr(entry, "_dirty", False): entry.save() updated_ids.add(entry.pk) - if not self.context.get('add_only', False): + if not self.context.get("add_only", False): for old_entry in collection.cached_collects(): if old_entry.pk not in updated_ids: old_entry.delete() @@ -160,85 +337,97 @@ def save(self, **kwargs): class CollectionBadgeSerializerV1(serializers.ModelSerializer): - id = serializers.RelatedField(queryset=BadgeInstance.objects.all()) - collection = serializers.RelatedField(queryset=BackpackCollection.objects.all(), write_only=True, required=False) + id = serializers.SlugRelatedField( + slug_field="entity_id", + queryset=BadgeInstance.objects.all(), + ) + collection = serializers.SlugRelatedField( + slug_field="entity_id", + queryset=BackpackCollection.objects.all(), + write_only=True, + required=False, + ) class Meta: model = BackpackCollectionBadgeInstance list_serializer_class = CollectionBadgesSerializerV1 - fields = ('id', 'collection', 'badgeinstance') - apispec_definition = ('CollectionBadgeSerializerV1', {}) + fields = ("id", "collection", "badgeinstance") def get_validators(self): return [] def to_internal_value(self, data): # populate collection from various methods - collection = data.get('collection') + collection = data.get("collection") if not collection: - collection = self.context.get('collection') + collection = self.context.get("collection") if not collection and self.parent.parent: collection = self.parent.parent.instance elif not collection and self.parent.instance: collection = self.parent.instance if not collection: return BackpackCollectionBadgeInstance( - badgeinstance=BadgeInstance.cached.get(entity_id=data.get('id')) + badgeinstance=BadgeInstance.cached.get(entity_id=data.get("id")) ) try: - badgeinstance = BadgeInstance.cached.get(entity_id=data.get('id')) + badgeinstance = BadgeInstance.cached.get(entity_id=data.get("id")) except BadgeInstance.DoesNotExist: raise RestframeworkValidationError("Assertion not found") - if badgeinstance.recipient_identifier not in collection.owner.all_verified_recipient_identifiers: - raise serializers.ValidationError("Cannot add badge to a collection created by a different recipient.") + if ( + badgeinstance.recipient_identifier + not in collection.owner.all_verified_recipient_identifiers + ): + raise serializers.ValidationError( + "Cannot add badge to a collection created by a different recipient." + ) collect, created = BackpackCollectionBadgeInstance.objects.get_or_create( - collection=collection, - badgeinstance=badgeinstance) + collection=collection, badgeinstance=badgeinstance + ) return collect def to_representation(self, instance): ret = OrderedDict() - ret['id'] = instance.cached_badgeinstance.entity_id - ret['description'] = "" + ret["id"] = instance.cached_badgeinstance.entity_id + ret["description"] = "" return ret class CollectionSerializerV1(serializers.Serializer): name = StripTagsCharField(required=True, max_length=128) - slug = StripTagsCharField(required=False, max_length=128, source='entity_id') - description = StripTagsCharField(required=False, allow_blank=True, allow_null=True, max_length=255) + slug = StripTagsCharField(required=False, max_length=128, source="entity_id") + description = StripTagsCharField( + required=False, allow_blank=True, allow_null=True, max_length=255 + ) + permanent_hash = serializers.CharField(read_only=True, source="permanent_url") share_hash = serializers.CharField(read_only=True) share_url = serializers.CharField(read_only=True, max_length=1024) badges = CollectionBadgeSerializerV1( - read_only=False, many=True, required=False, source='cached_collects' + read_only=False, many=True, required=False, source="cached_collects" ) published = serializers.BooleanField(required=False) - class Meta: - apispec_definition = ('Collection', {}) - def to_representation(self, instance): representation = super(CollectionSerializerV1, self).to_representation(instance) - if representation.get('share_url', None) is None: + if representation.get("share_url", None) is None: # V1 api expects share_url to be an empty string and not null if unpublished - representation['share_url'] = "" + representation["share_url"] = "" return representation def create(self, validated_data): - owner = validated_data.get('created_by', self.context.get('user', None)) + owner = validated_data.get("created_by", self.context.get("user", None)) new_collection = BackpackCollection.objects.create( - name=validated_data.get('name'), - description=validated_data.get('description', ''), - created_by=owner + name=validated_data.get("name"), + description=validated_data.get("description", ""), + created_by=owner, ) - published = validated_data.get('published', False) + published = validated_data.get("published", False) if published: new_collection.published = published new_collection.save() - for collect in validated_data.get('cached_collects', []): + for collect in validated_data.get("cached_collects", []): collect.collection = new_collection collect.badgeuser = new_collection.created_by collect.save() @@ -246,17 +435,18 @@ def create(self, validated_data): return new_collection def update(self, instance, validated_data): - instance.name = validated_data.get('name', instance.name) - instance.description = validated_data.get('description', instance.description) - instance.published = validated_data.get('published', instance.published) - - if 'cached_collects' in validated_data\ - and validated_data['cached_collects'] is not None: - + instance.name = validated_data.get("name", instance.name) + instance.description = validated_data.get("description", instance.description) + instance.published = validated_data.get("published", instance.published) + + if ( + "cached_collects" in validated_data + and validated_data["cached_collects"] is not None + ): existing_entries = list(instance.cached_collects()) updated_ids = set() - for entry in validated_data['cached_collects']: + for entry in validated_data["cached_collects"]: if not entry.pk: entry.save() updated_ids.add(entry.pk) @@ -272,7 +462,8 @@ def update(self, instance, validated_data): ## # # the below exists to prop up V1BadgeInstanceSerializer for /v1/ backwards compatability -# in LocalBadgeInstanceUploadSerializer. it was taken from composition/format.py and verifier/serializers/fields.py +# in LocalBadgeInstanceUploadSerializer. +# It was taken from composition/format.py and verifier/serializers/fields.py # before those apps were deprecated # # [Wiggins June 2017] @@ -283,7 +474,7 @@ class BadgePotentiallyEmptyField(serializers.Field): def get_attribute(self, instance): value = serializers.Field.get_attribute(self, instance) - if value == '' or value is None or value == {}: + if value == "" or value is None or value == {}: if not self.required or not self.allow_blank: raise SkipField() return value @@ -293,23 +484,21 @@ def validate_empty_values(self, data): If an empty value (empty string, null) exists in an optional field, SkipField. """ - (is_empty_value, data) = serializers.Field.validate_empty_values(self, - data) + (is_empty_value, data) = serializers.Field.validate_empty_values(self, data) - if is_empty_value or data == '': + if is_empty_value or data == "": if self.required: - self.fail('required') + self.fail("required") raise SkipField() return (False, data) class VerifierBadgeDateTimeField(BadgePotentiallyEmptyField, serializers.Field): - default_error_messages = { - 'not_int_or_str': 'Invalid format. Expected an int or str.', - 'bad_str': 'Invalid format. String is not ISO 8601 or unix timestamp.', - 'bad_int': 'Invalid format. Unix timestamp is out of range.', + "not_int_or_str": "Invalid format. Expected an int or str.", + "bad_str": "Invalid format. String is not ISO 8601 or unix timestamp.", + "bad_int": "Invalid format. Unix timestamp is out of range.", } def to_internal_value(self, value): @@ -326,15 +515,15 @@ def to_internal_value(self, value): parse_date(value), datetime.datetime.min.time() ) except (TypeError, ValueError): - self.fail('bad_str') + self.fail("bad_str") return result elif isinstance(value, (int, float)): try: return datetime.datetime.utcfromtimestamp(value) except ValueError: - self.fail('bad_int') + self.fail("bad_int") else: - self.fail('not_int_or_str') + self.fail("not_int_or_str") def to_representation(self, string_value): if isinstance(string_value, (str, int, float)): @@ -347,13 +536,10 @@ def to_representation(self, string_value): class BadgeURLField(serializers.URLField): def to_representation(self, value): - if self.context.get('format', 'v1') == 'v1': - result = { - 'type': '@id', - 'id': value - } - if self.context.get('name') is not None: - result['name'] = self.context.get('name') + if self.context.get("format", "v1") == "v1": + result = {"type": "@id", "id": value} + if self.context.get("name") is not None: + result["name"] = self.context.get("name") return result else: return value @@ -361,13 +547,10 @@ def to_representation(self, value): class BadgeImageURLField(serializers.URLField): def to_representation(self, value): - if self.context.get('format', 'v1') == 'v1': - result = { - 'type': 'image', - 'id': value - } - if self.context.get('name') is not None: - result['name'] = self.context.get('name') + if self.context.get("format", "v1") == "v1": + result = {"type": "image", "id": value} + if self.context.get("name") is not None: + result["name"] = self.context.get("name") return result else: return value @@ -375,22 +558,16 @@ def to_representation(self, value): class BadgeStringField(serializers.CharField): def to_representation(self, value): - if self.context.get('format', 'v1') == 'v1': - return { - 'type': 'xsd:string', - '@value': value - } + if self.context.get("format", "v1") == "v1": + return {"type": "xsd:string", "@value": value} else: return value class BadgeEmailField(serializers.EmailField): def to_representation(self, value): - if self.context.get('format', 'v1') == 'v1': - return { - 'type': 'email', - '@value': value - } + if self.context.get("format", "v1") == "v1": + return {"type": "email", "@value": value} else: return value @@ -398,11 +575,8 @@ def to_representation(self, value): class BadgeDateTimeField(VerifierBadgeDateTimeField): def to_representation(self, string_value): value = super(BadgeDateTimeField, self).to_representation(string_value) - if self.context.get('format', 'v1') == 'v1': - return { - 'type': 'xsd:dateTime', - '@value': value - } + if self.context.get("format", "v1") == "v1": + return {"type": "xsd:dateTime", "@value": value} else: return value @@ -415,7 +589,7 @@ class V1IssuerSerializer(serializers.Serializer): description = BadgeStringField(required=False) image = BadgeImageURLField(required=False) email = BadgeEmailField(required=False) - slug = serializers.CharField(required=False) + slug = serializers.CharField(required=False) class V1BadgeClassSerializer(serializers.Serializer): @@ -424,16 +598,17 @@ class V1BadgeClassSerializer(serializers.Serializer): name = BadgeStringField() description = BadgeStringField() image = BadgeImageURLField() - criteria = BadgeURLField() + criteria = JSONField(required=False) criteria_text = BadgeStringField(required=False) criteria_url = BadgeURLField(required=False) issuer = V1IssuerSerializer() tags = serializers.ListField(child=BadgeStringField(), required=False) + slug = BadgeStringField() def to_representation(self, instance): representation = super(V1BadgeClassSerializer, self).to_representation(instance) - if 'alignment' in instance: - representation['alignment'] = instance['alignment'] + if "alignment" in instance: + representation["alignment"] = instance["alignment"] return representation @@ -444,33 +619,67 @@ class V1InstanceSerializer(serializers.Serializer): recipient = BadgeEmailField() # TODO: improve for richer types badge = V1BadgeClassSerializer() issuedOn = BadgeDateTimeField(required=False) # missing in some translated v0.5.0 + validFrom = BadgeDateTimeField(required=False) # for ob3.0 expires = BadgeDateTimeField(required=False) image = BadgeImageURLField(required=False) evidence = BadgeURLField(required=False) + credentialSubject = serializers.DictField(required=False) + + def to_representation(self, instance): + data = super().to_representation(instance) + + # add context for checking of ob version + data["@context"] = instance["@context"] + + return data class V1BadgeInstanceSerializer(V1InstanceSerializer): """ used to serialize a issuer.BadgeInstance like a composition.LocalBadgeInstance """ + pending = serializers.ReadOnlyField() def to_representation(self, instance): localbadgeinstance_json = instance.json - if 'evidence' in localbadgeinstance_json: - localbadgeinstance_json['evidence'] = instance.evidence_url - localbadgeinstance_json['uid'] = instance.entity_id - localbadgeinstance_json['badge'] = instance.cached_badgeclass.json - localbadgeinstance_json['badge']['criteria'] = instance.cached_badgeclass.get_criteria_url() - if instance.cached_badgeclass.criteria_text: - localbadgeinstance_json['badge']['criteria_text'] = instance.cached_badgeclass.criteria_text - if instance.cached_badgeclass.criteria_url: - localbadgeinstance_json['badge']['criteria_url'] = instance.cached_badgeclass.criteria_url - localbadgeinstance_json['badge']['issuer'] = instance.cached_issuer.json + if "evidence" in localbadgeinstance_json: + localbadgeinstance_json["evidence"] = instance.evidence_url + localbadgeinstance_json["uid"] = instance.entity_id + if instance.expires_at: + localbadgeinstance_json["expires"] = instance.expires_at.isoformat() + # TODO: check if badge can be removed as now the badge metadata is also included under credentialSubject.achievement + localbadgeinstance_json["badge"] = instance.cached_badgeclass.json + localbadgeinstance_json["badge"]["slug"] = instance.cached_badgeclass.entity_id + localbadgeinstance_json["badge"]["criteria"] = ( + instance.cached_badgeclass.get_criteria() + ) + localbadgeinstance_json["badge"]["issuer"] = instance.cached_issuer.json # clean up recipient to match V1InstanceSerializer - localbadgeinstance_json['recipient'] = { + localbadgeinstance_json["recipient"] = { "type": "email", "recipient": instance.recipient_identifier, } - return super(V1BadgeInstanceSerializer, self).to_representation(localbadgeinstance_json) + + credential_subject = localbadgeinstance_json.get("credentialSubject", {}) + + if instance.activity_start_date: + credential_subject["activityStartDate"] = ( + instance.activity_start_date.isoformat() + ) + if instance.activity_end_date: + credential_subject["activityEndDate"] = ( + instance.activity_end_date.isoformat() + ) + + if instance.activity_city: + credential_subject["activityCity"] = instance.activity_city + + if instance.activity_online: + credential_subject["activityOnline"] = instance.activity_online + + localbadgeinstance_json["credentialSubject"] = credential_subject + return super(V1BadgeInstanceSerializer, self).to_representation( + localbadgeinstance_json + ) diff --git a/apps/backpack/serializers_v2.py b/apps/backpack/serializers_v2.py index d776cd87d..04b130682 100644 --- a/apps/backpack/serializers_v2.py +++ b/apps/backpack/serializers_v2.py @@ -1,178 +1,89 @@ # encoding: utf-8 - - -from collections import OrderedDict - from django.core.exceptions import ValidationError as DjangoValidationError from rest_framework import serializers from rest_framework.exceptions import ValidationError as RestframeworkValidationError from backpack.models import BackpackCollection -from badgeuser.models import BadgeUser from entity.serializers import DetailSerializerV2, EntityRelatedFieldV2 from issuer.helpers import BadgeCheckHelper from issuer.models import BadgeInstance, BadgeClass, Issuer from issuer.serializers_v2 import BadgeRecipientSerializerV2, EvidenceItemSerializerV2 from mainsite.drf_fields import ValidImageField -from mainsite.serializers import DateTimeWithUtcZAtEndField, MarkdownCharField, HumanReadableBooleanField, OriginalJsonSerializerMixin -from issuer.utils import generate_sha256_hashstring, CURRENT_OBI_VERSION +from mainsite.serializers import DateTimeWithUtcZAtEndField, MarkdownCharField +from mainsite.serializers import HumanReadableBooleanField, OriginalJsonSerializerMixin class BackpackAssertionSerializerV2(DetailSerializerV2, OriginalJsonSerializerMixin): - acceptance = serializers.ChoiceField(choices=BadgeInstance.ACCEPTANCE_CHOICES, default=BadgeInstance.ACCEPTANCE_ACCEPTED) + acceptance = serializers.ChoiceField( + choices=BadgeInstance.ACCEPTANCE_CHOICES, + default=BadgeInstance.ACCEPTANCE_ACCEPTED, + ) # badgeinstance - openBadgeId = serializers.URLField(source='jsonld_id', read_only=True) - badgeclass = EntityRelatedFieldV2(source='cached_badgeclass', required=False, queryset=BadgeClass.cached) - badgeclassOpenBadgeId = serializers.URLField(source='badgeclass_jsonld_id', read_only=True) - issuer = EntityRelatedFieldV2(source='cached_issuer', required=False, queryset=Issuer.cached) - issuerOpenBadgeId = serializers.URLField(source='issuer_jsonld_id', read_only=True) - - name = serializers.CharField(source='cached_badgeclass.name', read_only=True) + openBadgeId = serializers.URLField(source="jsonld_id", read_only=True) + badgeclass = EntityRelatedFieldV2( + source="cached_badgeclass", required=False, queryset=BadgeClass.cached + ) + badgeclassOpenBadgeId = serializers.URLField( + source="badgeclass_jsonld_id", read_only=True + ) + issuer = EntityRelatedFieldV2( + source="cached_issuer", required=False, queryset=Issuer.cached + ) + issuerOpenBadgeId = serializers.URLField(source="issuer_jsonld_id", read_only=True) + + name = serializers.CharField(source="cached_badgeclass.name", read_only=True) image = serializers.FileField(read_only=True) - recipient = BadgeRecipientSerializerV2(source='*') - issuedOn = DateTimeWithUtcZAtEndField(source='issued_on', read_only=True) + recipient = BadgeRecipientSerializerV2(source="*") + issuedOn = DateTimeWithUtcZAtEndField(source="issued_on", read_only=True) narrative = MarkdownCharField(required=False) evidence = EvidenceItemSerializerV2(many=True, required=False) revoked = HumanReadableBooleanField(read_only=True) - revocationReason = serializers.CharField(source='revocation_reason', read_only=True) - expires = DateTimeWithUtcZAtEndField(source='expires_at', required=False) - pending = serializers.ReadOnlyField() + revocationReason = serializers.CharField(source="revocation_reason", read_only=True) + expires = DateTimeWithUtcZAtEndField(source="expires_at", required=False) + pending = serializers.BooleanField(read_only=True) class Meta(DetailSerializerV2.Meta): model = BadgeInstance - apispec_definition = ('BackpackAssertion', { - 'properties': OrderedDict([ - ('entityId', { - 'type': "string", - 'format': "string", - 'description': "Unique identifier for this Issuer", - 'readOnly': True, - }), - ('entityType', { - 'type': "string", - 'format': "string", - 'description': "\"Issuer\"", - 'readOnly': True, - }), - ('openBadgeId', { - 'type': "string", - 'format': "url", - 'description': "URL of the OpenBadge compliant json", - 'readOnly': True, - }), - ('badgeclass', { - 'type': 'string', - 'format': 'entityId', - 'description': "BadgeClass that issued this Assertion", - 'required': False, - }), - ('badgeclassOpenBadgeId', { - 'type': 'string', - 'format': 'url', - 'description': "URL of the BadgeClass to award", - 'readOnly': True, - }), - ('image', { - 'type': 'string', - 'format': 'url', - 'description': "URL to the baked assertion image", - 'readOnly': True, - }), - ('recipient', { - 'type': 'object', - 'properties': OrderedDict([ - ('identity', { - 'type': 'string', - 'format': 'string', - 'description': 'Either the hash of the identity or the plaintext value', - 'required': True, - }), - ('type', { - 'type': 'string', - 'enum': [c[0] for c in BadgeInstance.RECIPIENT_TYPE_CHOICES], - 'description': "Type of identifier used to identify recipient", - 'required': False, - }), - ('hashed', { - 'type': 'boolean', - 'description': "Whether or not the identity value is hashed.", - 'required': False, - }), - ('plaintextIdentity', { - 'type': 'string', - 'description': "The plaintext identity", - 'required': False, - }), - ]), - 'description': "Recipient that was issued the Assertion", - 'required': True, - }), - ('issuedOn', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the Assertion was issued", - 'required': True, - }), - ('narrative', { - 'type': 'string', - 'format': 'markdown', - 'description': "Markdown narrative of the achievement", - 'required': False, - }), - ('evidence', { - 'type': 'array', - 'items': { - '$ref': '#/definitions/AssertionEvidence' - }, - 'description': "List of evidence associated with the achievement", - 'required': False, - }), - ('revoked', { - 'type': 'boolean', - 'description': "True if this Assertion has been revoked", - 'readOnly': True, - }), - ('revocationReason', { - 'type': 'string', - 'format': "string", - 'description': "Short description of why the Assertion was revoked", - 'readOnly': True, - }), - ('expires', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the Assertion expires", - 'required': False, - }), - - ]) - }) def to_representation(self, instance): - representation = super(BackpackAssertionSerializerV2, self).to_representation(instance) - request_kwargs = self.context['kwargs'] - expands = request_kwargs.get('expands', []) + representation = super(BackpackAssertionSerializerV2, self).to_representation( + instance + ) + + try: + request_kwargs = self.context["kwargs"] + expands = request_kwargs.get("expands", []) + except Exception: + expands = [] if self.parent is not None: # we'll have a bare representation instance_data_pointer = representation else: - instance_data_pointer = representation['result'][0] - - if 'badgeclass' in expands: - instance_data_pointer['badgeclass'] = instance.cached_badgeclass.get_json(include_extra=True, use_canonical_id=True) - if 'issuer' in expands: - instance_data_pointer['badgeclass']['issuer'] = instance.cached_issuer.get_json(include_extra=True, use_canonical_id=True) + instance_data_pointer = representation["result"][0] + + if "badgeclass" in expands: + instance_data_pointer["badgeclass"] = instance.cached_badgeclass.get_json( + include_extra=True, use_canonical_id=True + ) + if "issuer" in expands: + instance_data_pointer["badgeclass"]["issuer"] = ( + instance.cached_issuer.get_json( + include_extra=True, use_canonical_id=True + ) + ) return representation class BackpackAssertionAcceptanceSerializerV2(serializers.Serializer): - acceptance = serializers.ChoiceField(choices=[BadgeInstance.ACCEPTANCE_ACCEPTED], write_only=True) + acceptance = serializers.ChoiceField( + choices=[BadgeInstance.ACCEPTANCE_ACCEPTED], write_only=True + ) def update(self, instance, validated_data): - instance.acceptance = 'Accepted' + instance.acceptance = "Accepted" instance.save() owner = instance.user @@ -185,71 +96,17 @@ def update(self, instance, validated_data): class BackpackCollectionSerializerV2(DetailSerializerV2): name = serializers.CharField() description = MarkdownCharField(required=False) - owner = EntityRelatedFieldV2(read_only=True, source='created_by') + owner = EntityRelatedFieldV2(read_only=True, source="created_by") share_url = serializers.URLField(read_only=True) - shareHash = serializers.CharField(read_only=True, source='share_hash') + shareHash = serializers.CharField(read_only=True, source="share_hash") published = serializers.BooleanField(required=False) - assertions = EntityRelatedFieldV2(many=True, source='badge_items', required=False, queryset=BadgeInstance.cached) + assertions = EntityRelatedFieldV2( + many=True, source="badge_items", required=False, queryset=BadgeInstance.cached + ) class Meta(DetailSerializerV2.Meta): model = BackpackCollection - apispec_definition = ('Collection', { - 'properties': OrderedDict([ - ('entityId', { - 'type': "string", - 'format': "string", - 'description': "Unique identifier for this Collection", - }), - ('entityType', { - 'type': "string", - 'format': "string", - 'description': "\"Collection\"", - }), - ('createdAt', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the Collection was created", - }), - ('createdBy', { - 'type': 'string', - 'format': 'entityId', - 'description': "BadgeUser who created this Collection", - }), - - ('name', { - 'type': "string", - 'format': "string", - 'description': "Name of the Collection", - }), - ('description', { - 'type': "string", - 'format': "text", - 'description': "Short description of the Collection", - }), - ('share_url', { - 'type': "string", - 'format': "url", - 'description': "A public URL for sharing the Collection. Read only.", - }), - ('shareHash', { - 'type': "string", - 'format': "url", - 'description': "The share hash that allows construction of a public sharing URL. Read only.", - }), - ('published', { - 'type': "boolean", - 'description': "True if the Collection has a public share URL", - }), - ('assertions', { - 'type': "array", - 'items': { - '$ref': '#/definitions/Assertion' - }, - 'description': "List of Assertions in the collection", - }), - ]) - }) class BackpackImportSerializerV2(DetailSerializerV2): @@ -260,17 +117,27 @@ class BackpackImportSerializerV2(DetailSerializerV2): def validate(self, attrs): # TODO: when test is run, why is assertion field blank??? if sum(1 if v else 0 for v in list(attrs.values())) != 1: - raise serializers.ValidationError("Must provide only one of 'url', 'image' or 'assertion'.") + raise serializers.ValidationError( + "Must provide only one of 'url', 'image' or 'assertion'." + ) return attrs def create(self, validated_data): try: - validated_data['imagefile'] = validated_data.pop('image', None) - instance, created = BadgeCheckHelper.get_or_create_assertion(**validated_data) + validated_data["imagefile"] = validated_data.pop("image", None) + instance, created = BadgeCheckHelper.get_or_create_assertion( + **validated_data + ) if not created: if instance.acceptance == BadgeInstance.ACCEPTANCE_ACCEPTED: raise RestframeworkValidationError( - [{'name': "DUPLICATE_BADGE", 'description': "You already have this badge in your backpack"}]) + [ + { + "name": "DUPLICATE_BADGE", + "description": "You already have this badge in your backpack", + } + ] + ) instance.acceptance = BadgeInstance.ACCEPTANCE_ACCEPTED instance.save() except DjangoValidationError as e: diff --git a/apps/backpack/share_urls.py b/apps/backpack/share_urls.py index 04c1e31de..c3edea9d9 100644 --- a/apps/backpack/share_urls.py +++ b/apps/backpack/share_urls.py @@ -1,14 +1,31 @@ -from django.conf.urls import url +from django.urls import re_path -from backpack.views import LegacyBadgeShareRedirectView, RedirectSharedCollectionView, LegacyCollectionShareRedirectView +from backpack.views import ( + LegacyBadgeShareRedirectView, + RedirectSharedCollectionView, + LegacyCollectionShareRedirectView, +) urlpatterns = [ # legacy redirects - - url(r'^share/?collection/(?P[^/]+)(/embed)?$', RedirectSharedCollectionView.as_view(), name='redirect_backpack_shared_collection'), - url(r'^share/?badge/(?P[^/]+)$', LegacyBadgeShareRedirectView.as_view(), name='legacy_redirect_backpack_shared_badge'), - - url(r'^earner/collections/(?P[^/]+)/(?P[^/]+)$', LegacyCollectionShareRedirectView.as_view(), name='legacy_shared_collection'), - url(r'^earner/collections/(?P[^/]+)/(?P[^/]+)/embed$', LegacyCollectionShareRedirectView.as_view(), name='legacy_shared_collection_embed'), + re_path( + r"^share/?collection/(?P[^/]+)(/embed)?$", + RedirectSharedCollectionView.as_view(), + name="redirect_backpack_shared_collection", + ), + re_path( + r"^share/?badge/(?P[^/]+)$", + LegacyBadgeShareRedirectView.as_view(), + name="legacy_redirect_backpack_shared_badge", + ), + re_path( + r"^earner/collections/(?P[^/]+)/(?P[^/]+)$", + LegacyCollectionShareRedirectView.as_view(), + name="legacy_shared_collection", + ), + re_path( + r"^earner/collections/(?P[^/]+)/(?P[^/]+)/embed$", + LegacyCollectionShareRedirectView.as_view(), + name="legacy_shared_collection_embed", + ), ] - diff --git a/apps/backpack/sharing.py b/apps/backpack/sharing.py index e7e74b1a3..ff025e046 100644 --- a/apps/backpack/sharing.py +++ b/apps/backpack/sharing.py @@ -1,4 +1,6 @@ -import urllib.request, urllib.parse, urllib.error +import urllib.request +import urllib.parse +import urllib.error from django.conf import settings from django.http import Http404 @@ -14,14 +16,13 @@ def __init__(self, provider): class TwitterShareProvider(ShareProvider): - provider_code = 'twitter' - provider_name = 'Twitter' + provider_code = "twitter" + provider_name = "Twitter" def share_url(self, obj, **kwargs): if isinstance(obj, BadgeInstance): text = "I earned a badge from {issuer}! {url}".format( - issuer=obj.cached_issuer.name, - url=obj.get_share_url(**kwargs) + issuer=obj.cached_issuer.name, url=obj.get_share_url(**kwargs) ) else: text = obj.share_url @@ -31,8 +32,8 @@ def share_url(self, obj, **kwargs): class FacebookShareProvider(ShareProvider): - provider_code = 'facebook' - provider_name = 'Facebook' + provider_code = "facebook" + provider_name = "Facebook" def share_url(self, badge_instance, **kwargs): return "https://www.facebook.com/sharer/sharer.php?u={url}".format( @@ -41,21 +42,24 @@ def share_url(self, badge_instance, **kwargs): class PinterestShareProvider(ShareProvider): - provider_code = 'pinterest' - provider_name = 'Pinterest' + provider_code = "pinterest" + provider_name = "Pinterest" def share_url(self, badge_instance, **kwargs): summary = badge_instance.cached_badgeclass.name - return "http://www.pinterest.com/pin/create/button/?url={url}&media={image}&description={summary}".format( - url=urllib.parse.quote(badge_instance.get_share_url(**kwargs)), - image=badge_instance.image_url(), - summary=summary + return ( + "http://www.pinterest.com/pin/create/button/" + "?url={url}&media={image}&description={summary}".format( + url=urllib.parse.quote(badge_instance.get_share_url(**kwargs)), + image=badge_instance.image_url(), + summary=summary, + ) ) class LinkedinShareProvider(ShareProvider): - provider_code = 'linkedin' - provider_name = 'LinkedIn' + provider_code = "linkedin" + provider_name = "LinkedIn" def share_url(self, instance, **kwargs): url = None @@ -76,29 +80,38 @@ def feed_share_url(self, badge_instance, title=None, summary=None, **kwargs): if summary is None: summary = badge_instance.cached_badgeclass.name - return "https://www.linkedin.com/shareArticle?mini=true&url={url}&title={title}&summary={summary}".format( - url=urllib.parse.quote(badge_instance.get_share_url(**kwargs)), - title=urllib.parse.quote(title), - summary=urllib.parse.quote(summary), + return ( + "https://www.linkedin.com/shareArticle" + "?mini=true&url={url}&title={title}&summary={summary}".format( + url=urllib.parse.quote(badge_instance.get_share_url(**kwargs)), + title=urllib.parse.quote(title), + summary=urllib.parse.quote(summary), + ) ) def collection_share_url(self, collection, **kwargs): title = collection.name summary = collection.description - return "https://www.linkedin.com/shareArticle?mini=true&url={url}&title={title}&summary={summary}".format( - url=urllib.parse.quote(collection.get_share_url(**kwargs)), - title=urllib.parse.quote(title), - summary=urllib.parse.quote(summary), + return ( + "https://www.linkedin.com/shareArticle" + "?mini=true&url={url}&title={title}&summary={summary}".format( + url=urllib.parse.quote(collection.get_share_url(**kwargs)), + title=urllib.parse.quote(title), + summary=urllib.parse.quote(summary), + ) ) def certification_share_url(self, badge_instance, **kwargs): - cert_issuer_id = getattr(settings, 'LINKEDIN_CERTIFICATION_ISSUER_ID', None) + cert_issuer_id = getattr(settings, "LINKEDIN_CERTIFICATION_ISSUER_ID", None) if cert_issuer_id is None: return None - return "https://www.linkedin.com/profile/add?_ed={certIssuerId}&pfCertificationName={name}&pfCertificationUrl={url}".format( - certIssuerId=cert_issuer_id, - name=urllib.parse.quote(badge_instance.cached_badgeclass.name), - url=urllib.parse.quote(badge_instance.share_url(**kwargs)) + return ( + "https://www.linkedin.com/profile/add" + "?_ed={certIssuerId}&pfCertificationName={name}&pfCertificationUrl={url}".format( + certIssuerId=cert_issuer_id, + name=urllib.parse.quote(badge_instance.cached_badgeclass.name), + url=urllib.parse.quote(badge_instance.share_url(**kwargs)), + ) ) @@ -115,7 +128,7 @@ class SharingManager(object): def share_url(cls, provider, badge_instance, include_identifier=False, **kwargs): manager_cls = SharingManager.ManagerProviders.get(provider.lower(), None) if manager_cls is None: - raise Http404(u"Provider not supported: {}".format(provider)) + raise Http404("Provider not supported: {}".format(provider)) manager = manager_cls(provider) url = manager.share_url(badge_instance, include_identifier=include_identifier) return url diff --git a/apps/backpack/tests/test_assertions.py b/apps/backpack/tests/test_assertions.py deleted file mode 100644 index 3cb653f36..000000000 --- a/apps/backpack/tests/test_assertions.py +++ /dev/null @@ -1,1250 +0,0 @@ -import base64 -import collections -import datetime -import json -import os - -import dateutil.parser -import responses -import mock -from django.db import IntegrityError -from django.urls import reverse -from openbadges.verifier.openbadges_context import (OPENBADGES_CONTEXT_V2_URI, OPENBADGES_CONTEXT_V1_URI, - OPENBADGES_CONTEXT_V2_DICT) -from openbadges_bakery import bake, unbake - -from backpack.models import BackpackBadgeShare -from badgeuser.models import CachedEmailAddress, UserRecipientIdentifier -from issuer.models import BadgeClass, Issuer, BadgeInstance -from mainsite.tests.base import BadgrTestCase, SetupIssuerHelper -from mainsite.utils import first_node_match, OriginSetting -from .utils import setup_basic_0_5_0, setup_basic_1_0, setup_basic_1_0_bad_image, setup_resources, CURRENT_DIRECTORY - - -class TestShareProviders(SetupIssuerHelper, BadgrTestCase): - # issuer name with ascii - issuer_name_non_ascii = base64.b64decode('w45zc8O8w6ly').decode('utf-8') - # name with ascii - badge_class_name_non_ascii = base64.b64decode('w45zc8O8w6lycyBDb3Jw').decode('utf-8') - - def test_twitter_share_with_ascii_issuer(self): - provider = 'twitter' - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user, name=self.issuer_name_non_ascii) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - share = BackpackBadgeShare(provider=provider, badgeinstance=test_assertion, source='unknown') - share_url = share.get_share_url(provider, include_identifier=True) - - def test_pintrest_share_with_ascii_summary(self): - provider = 'pinterest' - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer, name=self.badge_class_name_non_ascii) - test_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - share = BackpackBadgeShare(provider=provider, badgeinstance=test_assertion, source='unknown') - share_url = share.get_share_url(provider, include_identifier=True) - - def test_linked_in_share_with_ascii_summary_and_issuer(self): - provider = 'linkedin' - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user, name=self.issuer_name_non_ascii) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer, name=self.badge_class_name_non_ascii) - test_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - share = BackpackBadgeShare(provider=provider, badgeinstance=test_assertion, source='unknown') - share_url = share.get_share_url(provider, include_identifier=True) - - def test_unsupported_share_provider_returns_404(self): - provider = 'unsupported_share_provider' - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_assertion = test_badgeclass.issue(recipient_id="nobody@example.com") - get_response = self.client.get('/v1/earner/share/badge/{badge_id}?provider={provider}'.format( - badge_id=test_assertion.entity_id, - provider=provider - )) - self.assertEqual(get_response.status_code, 404) - - -class TestBadgeUploads(BadgrTestCase): - def test_uniqueness(self): - ditto = "http://example.com" - with self.assertRaises(IntegrityError): - Issuer.objects.create(name="test1", source_url=ditto) - Issuer.objects.create(name="test2", source_url=ditto) - - @responses.activate - def test_submit_basic_1_0_badge_via_url(self): - setup_basic_1_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', token_scope='rw:backpack') - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual( - get_response.data[0].get('json', {}).get('id'), 'http://a.com/instance', - "The badge in our backpack should report its JSON-LD id as its original OpenBadgeId" - ) - - new_instance = BadgeInstance.objects.first() - expected_url = "{}{}".format(OriginSetting.HTTP, reverse('badgeinstance_image', kwargs=dict(entity_id=new_instance.entity_id))) - self.assertEqual(get_response.data[0].get('json', {}).get('image', {}).get('id'), expected_url) - - @responses.activate - def test_submit_basic_1_1_badge_via_url(self): - assertion_data = { - '@context': 'https://w3id.org/openbadges/v1', - 'id': 'http://a.com/instance', - 'type': 'Assertion', - "recipient": {"identity": "test@example.com", "hashed": False, "type": "email"}, - "badge": "http://a.com/badgeclass", - "issuedOn": "2015-04-30", - "verify": {"type": "hosted", "url": "http://a.com/instance"} - } - badgeclass_data = { - '@context': 'https://w3id.org/openbadges/v1', - 'type': 'BadgeClass', - 'id': 'http://a.com/badgeclass', - "name": "Basic Badge", - "description": "Basic as it gets. v1.0", - "image": "http://a.com/badgeclass_image", - "criteria": "http://a.com/badgeclass_criteria", - "issuer": "http://a.com/issuer" - } - issuer_data = { - '@context': 'https://w3id.org/openbadges/v1', - 'type': 'Issuer', - 'id': 'http://a.com/issuer', - "name": "Basic Issuer", - "url": "http://a.com/issuer/website" - } - for d in [assertion_data, badgeclass_data, issuer_data]: - responses.add( - responses.GET, d['id'], json=d - ) - - responses.add( - responses.GET, 'http://a.com/badgeclass_image', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/unbaked_image.png'), 'rb').read(), - status=200, content_type='image/png' - ) - - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - - self.setup_user(email='test@example.com', token_scope='rw:backpack') - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual( - get_response.data[0].get('json', {}).get('id'), 'http://a.com/instance', - "The badge in our backpack should report its JSON-LD id as its original OpenBadgeId" - ) - - new_instance = BadgeInstance.objects.first() - expected_url = "{}{}".format(OriginSetting.HTTP, reverse('badgeinstance_image', kwargs=dict(entity_id=new_instance.entity_id))) - self.assertEqual(get_response.data[0].get('json', {}).get('image', {}).get('id'), expected_url) - - @responses.activate - def test_submit_basic_1_0_badge_via_url_plain_json(self): - setup_basic_1_0() - self.setup_user(email='test@example.com', token_scope='rw:backpack') - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges?json_format=plain', post_input - ) - self.assertEqual(response.status_code, 201) - self.assertEqual( - response.data.get('json').get('badge').get('description'), - 'Basic as it gets. v1.0' - ) - - @responses.activate - def test_submit_basic_1_0_badge_via_url_bad_email(self): - setup_basic_1_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='not.test@email.example.com', authenticate=True) - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 400) - self.assertIsNotNone(first_node_match(response.data, dict( - messageLevel='ERROR', - name='VERIFY_RECIPIENT_IDENTIFIER', - ))) - - @responses.activate - def test_submit_basic_1_0_badge_from_image_url_baked_w_assertion(self): - setup_basic_1_0() - self.setup_user(email='test@example.com', authenticate=True) - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - - responses.add( - responses.GET, 'http://a.com/baked_image', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/baked_image.png'), 'rb').read(), - status=200, content_type='image/png' - ) - - post_input = { - 'url': 'http://a.com/baked_image' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual( - get_response.data[0].get('json', {}).get('id'), 'http://a.com/instance', - "The badge in our backpack should report its JSON-LD id as its original OpenBadgeId" - ) - - - @responses.activate - def test_submit_basic_1_0_badge_image_png(self): - setup_basic_1_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - image = open(os.path.join(CURRENT_DIRECTORY, 'testfiles/baked_image.png'), 'rb') - post_input = { - 'image': image - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual( - get_response.data[0].get('json', {}).get('id'), 'http://a.com/instance', - "The badge in our backpack should report its JSON-LD id as its original OpenBadgeId" - ) - - @responses.activate - def test_submit_baked_1_1_badge_preserves_metadata_roundtrip(self): - assertion_metadata = { - "@context": "https://w3id.org/openbadges/v1", - "type": "Assertion", - "id": "http://a.com/instance2", - "recipient": {"identity": "test@example.com", "hashed": False, "type": "email"}, - "badge": "http://a.com/badgeclass", - "issuedOn": "2015-04-30T00:00+00:00", - "verify": {"type": "hosted", "url": "http://a.com/instance2"}, - "extensions:ExampleExtension": { - "@context": "https://openbadgespec.org/extensions/exampleExtension/context.json", - "type": ["Extension", "extensions:ExampleExtension"], - "exampleProperty": "some extended text" - }, - "schema:unknownMetadata": 55 - } - badgeclass_metadata = { - "@context": "https://w3id.org/openbadges/v1", - "type": "BadgeClass", - "id": "http://a.com/badgeclass", - "name": "Basic Badge", - "description": "Basic as it gets. v1.1", - "image": "http://a.com/badgeclass_image", - "criteria": "http://a.com/badgeclass_criteria", - "issuer": "http://a.com/issuer" - } - issuer_metadata = { - "@context": "https://w3id.org/openbadges/v1", - "type": "Issuer", - "id": "http://a.com/issuer", - "name": "Basic Issuer", - "url": "http://a.com/issuer/website" - } - - with open(os.path.join(CURRENT_DIRECTORY, 'testfiles/baked_image.png'), 'rb') as image_file: - original_image = bake(image_file, json.dumps(assertion_metadata)) - original_image.seek(0) - - responses.add( - responses.GET, 'http://a.com/badgeclass_image', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/unbaked_image.png'), 'rb').read(), - status=200, content_type='image/png' - ) - - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': "https://openbadgespec.org/extensions/exampleExtension/context.json", 'response_body': json.dumps( - { - "@context": { - "obi": "https://w3id.org/openbadges#", - "extensions": "https://w3id.org/openbadges/extensions#", - "exampleProperty": "http://schema.org/text" - }, - "obi:validation": [ - { - "obi:validatesType": "extensions:ExampleExtension", - "obi:validationSchema": "https://openbadgespec.org/extensions/exampleExtension/schema.json" - } - ] - } - )}, - {'url': "https://openbadgespec.org/extensions/exampleExtension/schema.json", 'response_body': json.dumps( - { - "$schema": "http://json-schema.org/draft-04/schema#", - "title": "1.1 Open Badge Example Extension", - "description": "An extension that allows you to add a single string exampleProperty to an extension object to represent some of your favorite text.", - "type": "object", - "properties": { - "exampleProperty": { - "type": "string" - } - }, - "required": [ - "exampleProperty" - ] - } - )}, - {'url': 'http://a.com/instance2', 'response_body': json.dumps(assertion_metadata)}, - {'url': 'http://a.com/badgeclass', 'response_body': json.dumps(badgeclass_metadata)}, - {'url': 'http://a.com/issuer', 'response_body': json.dumps(issuer_metadata)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - self.assertDictEqual(json.loads(unbake(original_image)), assertion_metadata) - - original_image.seek(0) - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post('/v1/earner/badges', - {'image': original_image}) - self.assertEqual(response.status_code, 201) - - public_url = response.data.get('shareUrl') - self.assertIsNotNone(public_url) - response = self.client.get(public_url, Accept="application/json") - - for key in ['issuedOn']: - fetched_ts = dateutil.parser.parse(response.data.get(key)) - metadata_ts = dateutil.parser.parse(assertion_metadata.get(key)) - self.assertEqual(fetched_ts, metadata_ts) - - for key in ['recipient', 'extensions:ExampleExtension']: - fetched_dict = response.data.get(key) - self.assertIsNotNone(fetched_dict, "Field '{}' is missing".format(key)) - metadata_dict = assertion_metadata.get(key) - self.assertDictContainsSubset(metadata_dict, fetched_dict) - - for key in ['schema:unknownMetadata']: - self.assertEqual(response.data.get(key), assertion_metadata.get(key)) - - @responses.activate - def test_submit_basic_1_0_badge_image_datauri_png(self): - setup_basic_1_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - image = open(os.path.join(CURRENT_DIRECTORY, 'testfiles/baked_image.png'), 'rb') - encoded = 'data:image/png;base64,' + base64.b64encode(image.read()).decode('utf-8') - post_input = { - 'image': encoded - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input, format='json' - ) - self.assertEqual(response.status_code, 201) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual( - get_response.data[0].get('json', {}).get('id'), 'http://a.com/instance', - "The badge in our backpack should report its JSON-LD id as its original OpenBadgeId" - ) - # I think this test failure will be fixed by a badgecheck update to openbadges 1.0.1 as well - - @responses.activate - def test_submit_basic_1_0_badge_assertion(self): - setup_basic_1_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'assertion': open(os.path.join(CURRENT_DIRECTORY, 'testfiles/1_0_basic_instance.json'), 'r').read() - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual( - get_response.data[0].get('json', {}).get('id'), 'http://a.com/instance', - "The badge in our backpack should report its JSON-LD id as its original OpenBadgeId" - ) - - @responses.activate - def test_submit_basic_1_0_badge_url_variant_email(self): - setup_basic_1_0(**{'exclude': 'http://a.com/instance'}) - setup_resources([ - {'url': 'http://a.com/instance3', 'filename': '1_0_basic_instance3.json'}, - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - # add variant explicitly - response = self.client.post('/v1/user/emails', dict( - email='TEST@example.com' - )) - self.assertEqual(response.status_code, 400) # adding a variant successfully returns a 400 - - post_input = { - 'url': 'http://a.com/instance3', - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual( - get_response.data[0].get('json', {}).get('id'), - 'http://a.com/instance3' - ) - self.assertEqual( - get_response.data[0].get('json', {}).get('recipient', {}).get('@value', {}).get('recipient'), 'TEST@example.com' - ) - - email = CachedEmailAddress.objects.get(email='test@example.com') - self.assertTrue('TEST@example.com' in [e.email for e in email.cached_variants()]) - - @responses.activate - def test_submit_basic_1_0_badge_with_inaccessible_badge_image(self): - setup_basic_1_0(**{'exclude': ['http://a.com/badgeclass_image']}) - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 400) - self.assertIsNotNone(first_node_match(response.data, dict( - messageLevel='ERROR', - name='IMAGE_VALIDATION' - ))) - - @responses.activate - def test_submit_basic_1_0_badge_missing_issuer(self): - setup_basic_1_0(**{'exclude': ['http://a.com/issuer']}) - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 400) - self.assertIsNotNone(first_node_match(response.data, dict( - messageLevel='ERROR', - name='FETCH_HTTP_NODE' - ))) - - @responses.activate - def test_submit_basic_1_0_badge_missing_badge_prop(self): - self.setup_user(email='test@example.com', authenticate=True) - - responses.add( - responses.GET, 'http://a.com/instance', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/1_0_basic_instance_missing_badge_prop.json'), 'r').read(), - status=200, content_type='application/json' - ) - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - - post_input = { - 'url': 'http://a.com/instance' - } - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - - self.assertEqual(response.status_code, 400) - self.assertIsNotNone(first_node_match(response.data, dict( - messageLevel='ERROR', - name='VALIDATE_PROPERTY', - prop_name='badge' - ))) - - @responses.activate - def test_submit_basic_0_5_0_badge_via_url(self): - setup_basic_0_5_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'url': 'http://oldstyle.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual(get_response.data[0].get('json', {}).get('id'), post_input.get('url'), - "The badge in our backpack should report its JSON-LD id as the original OpenBadgeId") - - @responses.activate - def test_submit_0_5_badge_upload_by_assertion(self): - setup_basic_0_5_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'assertion': open(os.path.join(CURRENT_DIRECTORY, 'testfiles', '0_5_basic_instance.json'), 'r').read() - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 400) - # TODO Update to support 0.5 badges - - @responses.activate - def test_creating_no_duplicate_badgeclasses_and_issuers(self): - setup_basic_1_0() - setup_resources([ - {'url': 'http://a.com/instance2', 'filename': '1_0_basic_instance2.json'}, - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - badgeclass_count = BadgeClass.objects.all().count() - issuer_count = Issuer.objects.all().count() - - post_input = { - 'url': 'http://a.com/instance' - } - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - - post2_input = { - 'url': 'http://a.com/instance2' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response2 = self.client.post( - '/v1/earner/badges', post2_input - ) - self.assertEqual(response2.status_code, 201) - - self.assertEqual(BadgeClass.objects.all().count(), badgeclass_count+1) - self.assertEqual(Issuer.objects.all().count(), issuer_count+1) - - def test_shouldnt_access_already_stored_badgeclass_for_validation(self): - """ - TODO: If we already have a LocalBadgeClass saved for a URL, - don't bother fetching again too soon. - """ - pass - - def test_should_recheck_stale_localbadgeclass_in_validation(self): - """ - TODO: If it has been more than a month since we last examined a LocalBadgeClass, - maybe we should check - it again. - """ - pass - # TODO: Re-evaluate badgecheck caching strategy - - @responses.activate - def test_submit_badge_assertion_with_bad_date(self): - setup_basic_1_0() - setup_resources([ - {'url': 'http://a.com/instancebaddate', 'filename': '1_0_basic_instance_with_bad_date.json'}, - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'url': 'http://a.com/instancebaddate' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 400) - - self.assertIsNotNone(first_node_match(response.data, dict( - messageLevel='ERROR', - name='VALIDATE_PROPERTY', - prop_name='issuedOn' - ))) - - @responses.activate - def test_submit_badge_invalid_component_json(self): - setup_basic_1_0(**{'exclude': ['http://a.com/issuer']}) - setup_resources([ - {'url': 'http://a.com/issuer', 'filename': '1_0_basic_issuer_invalid_json.json'}, - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 400) - - self.assertIsNotNone(first_node_match(response.data, dict( - messageLevel='ERROR', - name='FETCH_HTTP_NODE' - ))) - - @responses.activate - def test_submit_badge_invalid_assertion_json(self): - setup_resources([ - {'url': 'http://a.com/instance', 'filename': '1_0_basic_issuer_invalid_json.json'}, - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 400) - - # openbadges returns FETCH_HTTP_NODE error when retrieving invalid json - self.assertIsNotNone(first_node_match(response.data, dict( - messageLevel='ERROR', - name='FETCH_HTTP_NODE' - ))) - - @responses.activate - def test_submit_badges_with_intragraph_references(self): - setup_resources([ - {'url': 'http://a.com/assertion-embedded1', 'filename': '2_0_assertion_embedded_badgeclass.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': 'http://a.com/badgeclass_image', 'filename': "unbaked_image.png", 'mode': 'rb'}, - ]) - self.setup_user(email='test@example.com', authenticate=True) - - assertion = { - "@context": 'https://w3id.org/openbadges/v2', - "id": 'http://a.com/assertion-embedded1', - "type": "Assertion", - } - post_input = { - 'assertion': json.dumps(assertion) - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post('/v1/earner/badges', post_input, - format='json') - self.assertEqual(response.status_code, 201) - - @responses.activate - def test_submit_basic_1_0_badge_via_url_delete_and_readd(self): - setup_basic_1_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', token_scope='rw:backpack') - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual( - get_response.data[0].get('json', {}).get('id'), 'http://a.com/instance', - "The badge in our backpack should report its JSON-LD id as its original OpenBadgeId" - ) - - new_instance = BadgeInstance.objects.first() - expected_url = "{}{}".format(OriginSetting.HTTP, - reverse('badgeinstance_image', kwargs=dict(entity_id=new_instance.entity_id))) - self.assertEqual(get_response.data[0].get('json', {}).get('image', {}).get('id'), expected_url) - - response = self.client.delete('/v1/earner/badges/{}'.format(new_instance.entity_id)) - self.assertEqual(response.status_code, 204) - self.assertEqual(BadgeInstance.objects.count(), 0) - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - - @responses.activate - def test_submit_badge_without_valid_image(self): - setup_basic_1_0_bad_image() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', token_scope='rw:backpack') - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - - self.assertEqual(response.status_code, 400) - - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual(len(get_response.data), 0, "The backpack should be empty") - self.assertEqual(BadgeInstance.objects.count(), 0) - -class TestDeleteLocalAssertion(BadgrTestCase, SetupIssuerHelper): - @responses.activate - def test_can_delete_local(self): - test_issuer_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_issuer_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - test_recipient = self.setup_user(email='test_recipient@email.test', authenticate=True) - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - assertion = test_badgeclass.issue( - recipient_id='test_recipient@email.test', recipient_type='email') - - response = self.client.get( - '/v1/earner/badges' - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1, "There is a badge in the recipient's backpack") - - response = self.client.delete('/v1/earner/badges/{}'.format(assertion.entity_id)) - self.assertEqual(response.status_code, 204) - self.assertEqual(BadgeInstance.objects.count(), 1) - assertion = BadgeInstance.objects.get(pk=assertion.pk) - self.assertEqual(assertion.acceptance, BadgeInstance.ACCEPTANCE_REJECTED) - - response = self.client.get( - '/v1/earner/badges' - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 0, "There is no longer a badge in the recipient's backpack") - - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': assertion.jsonld_id, 'response_body': json.dumps(assertion.get_json())}, - { - 'url': assertion.jsonld_id + '/image', - 'response_body': assertion.image.read(), - 'content-type': 'image/png' - }, - {'url': test_badgeclass.jsonld_id, 'response_body': json.dumps(test_badgeclass.get_json())}, - { - 'url': test_badgeclass.jsonld_id + '/image', - 'response_body': test_badgeclass.image.read(), - 'content-type': 'image/png' - }, - {'url': test_issuer.jsonld_id, 'response_body': json.dumps(test_issuer.get_json())} - ]) - - post_input = { - 'url': assertion.jsonld_id - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - self.assertEqual(response.data['id'], assertion.entity_id) - get_response = self.client.get('/v1/earner/badges') - self.assertEqual(get_response.status_code, 200) - self.assertEqual(len(get_response.data), 1) - - @responses.activate - def test_can_upload_non_hashed_url_badge(self): - test_recipient = self.setup_user(authenticate=True) - UserRecipientIdentifier.objects.create( - user=test_recipient, identifier='https://twitter.com/testuser1', verified=True, - type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL - ) - assertion_data = """{"@context":"https://w3id.org/openbadges/v2","type":"Assertion","id":"https://gist.githubusercontent.com/badgebotio/456assertion789/raw","recipient":{"type":"url","hashed":false,"identity":"https://twitter.com/testuser1"},"evidence":{"id:":"https://twitter.com/someuser/status/1176267317866635999","narrative":"Issued on Twitter by Badgebot from [@someuser](https://twitter.com/someuser)"},"issuedOn":"2019-10-02T11:29:25-04:00","badge":"https://gist.githubusercontent.com/badgebotio/456badgeclass789/raw","verification":{"type":"hosted"}}""" - badgeclass_data = """{"@context":"https://w3id.org/openbadges/v2","type":"BadgeClass","id":"https://gist.githubusercontent.com/badgebotio/456badgeclass789/raw","name":"You Rock! Badge","description":"Inaugural BadgeBot badge! Recipients of this badge are being recognized for making an impact.","image":"https://gist.githubusercontent.com/badgebotio/456badgeclass789/raw/you-rock-badge.svg","criteria":{"narrative":"Awarded on Twitter"},"issuer":"https://gist.githubusercontent.com/badgebotio/456issuer789/raw"}""" - badgeclass_image = """""" - issuer_data = """{"@context":"https://w3id.org/openbadges/v2","type":"Issuer","id":"https://gist.githubusercontent.com/badgebotio/456issuer789/raw","name":"BadgeBot","url":"https://badgebot.io"}""" - setup_resources([ - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': json.loads(assertion_data)['id'], 'response_body': assertion_data}, - {'url': json.loads(badgeclass_data)['id'], 'response_body': badgeclass_data}, - { - 'url': json.loads(badgeclass_data)['image'], - 'response_body': badgeclass_image, - 'content_type': 'image/svg+xml' - }, - {'url': json.loads(issuer_data)['id'], 'response_body': issuer_data}, - ]) - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post('/v2/backpack/import', { - 'url': json.loads(assertion_data)['id'] - }, format='json') - self.assertEqual(response.status_code, 201) - - -class TestAcceptanceHandling(BadgrTestCase, SetupIssuerHelper): - def test_can_accept_badge(self): - test_issuer_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_issuer_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - test_recipient = self.setup_user(email='test_recipient@email.test', authenticate=True, token_scope='rw:backpack') - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - assertion = test_badgeclass.issue( - recipient_id='test_recipient@email.test', - recipient_type='email') - - response = self.client.put( - '/v2/backpack/assertions/{}'.format(assertion.entity_id), - {'acceptance': assertion.ACCEPTANCE_ACCEPTED} - ) - self.assertEqual(response.status_code, 200) - - -class TestExpandAssertions(BadgrTestCase, SetupIssuerHelper): - def test_no_expands(self): - '''Expect correct result if no expand parameters are passed in''' - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - test_recipient = self.setup_user(email='test_recipient@email.test', authenticate=True) - test_badgeclass.issue(recipient_id='test_recipient@email.test') - - response = self.client.get('/v2/backpack/assertions') - - self.assertEqual(response.status_code, 200) - # checking if 'badgeclass' was expanded into a dictionary - self.assertTrue(not isinstance(response.data['result'][0]['badgeclass'], collections.OrderedDict)) - - fid = response.data['result'][0]['entityId'] - response = self.client.get('/v2/backpack/assertions/{}'.format(fid)) - self.assertEqual(response.status_code, 200) - - def test_expand_badgeclass_single_assertion_single_issuer(self): - '''For a client with a single badge, attempting to expand the badgeclass without - also expanding the issuer.''' - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - test_recipient = self.setup_user(email='test_recipient@email.test', authenticate=True) - test_badgeclass.issue(recipient_id='test_recipient@email.test') - - response = self.client.get('/v2/backpack/assertions?expand=badgeclass') - - self.assertEqual(response.status_code, 200) - self.assertTrue(isinstance(response.data['result'][0]['badgeclass'], collections.OrderedDict)) - self.assertTrue(not isinstance(response.data['result'][0]['badgeclass']['issuer'], collections.OrderedDict)) - - fid = response.data['result'][0]['entityId'] - response = self.client.get('/v2/backpack/assertions/{}?expand=badgeclass&expand=issuer'.format(fid)) - self.assertEqual(response.status_code, 200) - - self.assertTrue(isinstance(response.data['result'][0]['badgeclass'], dict)) - - def test_expand_issuer_single_assertion_single_issuer(self): - '''For a client with a single badge, attempting to expand the issuer without - also expanding the badgeclass should result in no expansion to the response.''' - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - test_recipient = self.setup_user(email='test_recipient@email.test', authenticate=True) - test_badgeclass.issue(recipient_id='test_recipient@email.test') - - responseOne = self.client.get('/v2/backpack/assertions?expand=issuer') - responseTwo = self.client.get('/v2/backpack/assertions') - - self.assertEqual(responseOne.status_code, 200) - self.assertEqual(responseTwo.status_code, 200) - self.assertEqual(responseOne.data, responseTwo.data) - - def test_expand_badgeclass_and_isser_single_assertion_single_issuer(self): - '''For a client with a single badge, attempting to expand the badgeclass and issuer.''' - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - test_recipient = self.setup_user(email='test_recipient@email.test', authenticate=True) - test_badgeclass.issue(recipient_id='test_recipient@email.test') - - response = self.client.get('/v2/backpack/assertions?expand=badgeclass&expand=issuer') - - self.assertEqual(response.status_code, 200) - self.assertTrue(isinstance(response.data['result'][0]['badgeclass'], collections.OrderedDict)) - self.assertTrue(isinstance(response.data['result'][0]['badgeclass']['issuer'], collections.OrderedDict)) - - def test_expand_badgeclass_mult_assertions_mult_issuers(self): - '''For a client with multiple badges, attempting to expand the badgeclass without - also expanding the issuer.''' - - # define users and issuers - test_user = self.setup_user(email='test_recipient@email.test', authenticate=True) - test_issuer_one = self.setup_issuer(name="Test Issuer 1",owner=test_user) - test_issuer_two = self.setup_issuer(name="Test Issuer 2",owner=test_user) - test_issuer_three = self.setup_issuer(name="Test Issuer 3",owner=test_user) - - # define badgeclasses - test_badgeclass_one = self.setup_badgeclass(name='Test Badgeclass 1',issuer=test_issuer_one) - test_badgeclass_two = self.setup_badgeclass(name='Test Badgeclass 2',issuer=test_issuer_one) - test_badgeclass_three = self.setup_badgeclass(name='Test Badgeclass 3',issuer=test_issuer_two) - test_badgeclass_four = self.setup_badgeclass(name='Test Badgeclass 4',issuer=test_issuer_three) - test_badgeclass_five = self.setup_badgeclass(name='Test Badgeclass 5',issuer=test_issuer_three) - test_badgeclass_six = self.setup_badgeclass(name='Test Badgeclass 6',issuer=test_issuer_three) - - # issue badges to user - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - - response = self.client.get('/v2/backpack/assertions?expand=badgeclass') - - self.assertEqual(len(response.data['result']), 6) - for i in range(6): - self.assertTrue(isinstance(response.data['result'][i]['badgeclass'], collections.OrderedDict)) - self.assertTrue(not isinstance(response.data['result'][i]['badgeclass']['issuer'], collections.OrderedDict)) - - def test_expand_badgeclass_and_issuer_mult_assertions_mult_issuers(self): - '''For a client with multiple badges, attempting to expand the badgeclass and issuer.''' - - # define users and issuers - test_user = self.setup_user(email='test_recipient@email.test', authenticate=True) - test_issuer_one = self.setup_issuer(name="Test Issuer 1",owner=test_user) - test_issuer_two = self.setup_issuer(name="Test Issuer 2",owner=test_user) - test_issuer_three = self.setup_issuer(name="Test Issuer 3",owner=test_user) - - # define badgeclasses - test_badgeclass_one = self.setup_badgeclass(name='Test Badgeclass 1',issuer=test_issuer_one) - test_badgeclass_two = self.setup_badgeclass(name='Test Badgeclass 2',issuer=test_issuer_one) - test_badgeclass_three = self.setup_badgeclass(name='Test Badgeclass 3',issuer=test_issuer_two) - test_badgeclass_four = self.setup_badgeclass(name='Test Badgeclass 4',issuer=test_issuer_three) - test_badgeclass_five = self.setup_badgeclass(name='Test Badgeclass 5',issuer=test_issuer_three) - test_badgeclass_six = self.setup_badgeclass(name='Test Badgeclass 6',issuer=test_issuer_three) - - # issue badges to user - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - test_badgeclass_one.issue(recipient_id='test_recipient@email.test') - - response = self.client.get('/v2/backpack/assertions?expand=badgeclass&expand=issuer') - - self.assertEqual(len(response.data['result']), 6) - for i in range(6): - self.assertTrue(isinstance(response.data['result'][i]['badgeclass'], collections.OrderedDict)) - self.assertTrue(isinstance(response.data['result'][i]['badgeclass']['issuer'], collections.OrderedDict)) - - -class TestPendingBadges(BadgrTestCase, SetupIssuerHelper): - @responses.activate - def test_view_badge_i_imported(self): - setup_resources([ - {'url': 'http://a.com/assertion-embedded1', 'filename': '2_0_assertion_embedded_badgeclass.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': 'http://a.com/badgeclass_image', 'filename': "unbaked_image.png", 'mode': 'rb'}, - ]) - unverified_email = 'test@example.com' - test_user = self.setup_user(email='verified@example.com', authenticate=True) - CachedEmailAddress.objects.add_email(test_user, unverified_email) - post_input = {"url": "http://a.com/assertion-embedded1"} - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - post_resp = self.client.post('/v2/backpack/import', post_input, - format='json') - assertion = BadgeInstance.objects.first() - - test_issuer_one = self.setup_issuer(name="Test Issuer 1", owner=test_user) - test_badgeclass_one = self.setup_badgeclass(name='Test Badgeclass 1', issuer=test_issuer_one) - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - test_badgeclass_one.issue(recipient_id='verified@example.com') - - get_resp = self.client.get('/v2/backpack/assertions?include_pending=1') - - self.assertEqual(post_resp.status_code, 201) - - self.assertEqual(get_resp.status_code, 200) - self.assertEqual(len(get_resp.data.get('result')), 2) - self.assertTrue(get_resp.data.get('result')[0]['pending']) - self.assertFalse(get_resp.data.get('result')[1]['pending']) - - get_resp = self.client.get('/v1/earner/badges?json_format=plain&include_pending=1') - self.assertEqual(len(get_resp.data), 2) - self.assertTrue(get_resp.data[0]['pending']) - self.assertFalse(get_resp.data[1]['pending']) - - get_resp = self.client.get('/v1/earner/badges?json_format=plain&include_pending=0') - self.assertEqual(len(get_resp.data), 1) - - # User should be able to delete it as well - del_resp = self.client.delete('/v2/backpack/assertions/{}'.format(assertion.entity_id)) - self.assertEqual(del_resp.status_code, 204) - - get_resp = self.client.get('/v1/earner/badges?json_format=plain&include_pending=1') - self.assertEqual(len(get_resp.data), 1) - - @responses.activate - def test_view_badge_i_imported_with_v1(self): - setup_resources([ - {'url': 'http://a.com/assertion-embedded1', 'filename': '2_0_assertion_embedded_badgeclass.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': 'http://a.com/badgeclass_image', 'filename': "unbaked_image.png", 'mode': 'rb'}, - ]) - unverified_email = 'test@example.com' - test_user = self.setup_user(email='verified@example.com', authenticate=True) - CachedEmailAddress.objects.add_email(test_user, unverified_email) - post_input = {"url": "http://a.com/assertion-embedded1"} - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - post_resp = self.client.post('/v1/earner/badges', post_input, - format='json') - self.assertEqual(post_resp.status_code, 201) - assertion = BadgeInstance.objects.first() - - get_resp2 = self.client.get('/v1/earner/badges?json_format=plain') - self.assertEqual(len(get_resp2.data), 0) - - get_resp3 = self.client.get('/v1/earner/badges?json_format=plain&include_pending=1') - self.assertEqual(len(get_resp3.data), 1) - - get_resp4 = self.client.get('/v1/earner/badges?json_format=plain&include_pending=false') - self.assertEqual(len(get_resp4.data), 0) - - # User should be able to delete it as well - del_resp = self.client.delete('/v1/earner/badges/{}'.format(assertion.entity_id)) - self.assertEqual(del_resp.status_code, 204) - - # apps.badgeuser.tests.UserRecipientIdentifierTests.test_verified_recipient_v2_assertions_endpoint - # apps.badgeuser.tests.UserRecipientIdentifierTests.test_verified_recipient_v1_badges_endpoint - - def test_cant_view_badge_awarded_to_unverified_that_i_did_not_import(self): - unverified_email = 'test@example.com' - test_user = self.setup_user(email='verified@example.com', authenticate=True) - CachedEmailAddress.objects.add_email(test_user, unverified_email) - test_issuer_one = self.setup_issuer(name="Test Issuer 1", owner=test_user) - test_badgeclass_one = self.setup_badgeclass(name='Test Badgeclass 1', issuer=test_issuer_one) - test_badgeclass_one.issue(recipient_id='test@example.com', recipient_type='email') - get_resp = self.client.get('/v2/backpack/assertions?include_pending=1') - - self.assertEqual(get_resp.status_code, 200) - self.assertEqual(len(get_resp.data.get('result')), 0) - - get_resp2 = self.client.get('/v1/earner/badges?json_format=plain') - self.assertEqual(get_resp2.status_code, 200) - self.assertEqual(len(get_resp2.data), 0) - - -class TestInclusionFlags(BadgrTestCase, SetupIssuerHelper): - def test_include_revoked(self): - test_user = self.setup_user(email='test@example.com', authenticate=True) - test_issuer_one = self.setup_issuer(name="Test Issuer 1", owner=test_user) - test_badgeclass_one = self.setup_badgeclass(name='Test Badgeclass 1', issuer=test_issuer_one) - revoked_assertion = test_badgeclass_one.issue(recipient_id='test@example.com', recipient_type='email') - revoked_assertion.revoked = True - revoked_assertion.save() - test_badgeclass_one.issue(recipient_id='test@example.com', recipient_type='email') - - result = self.client.get('/v2/backpack/assertions?include_revoked=1') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data.get('result')), 2) - - result = self.client.get('/v1/earner/badges') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data), 1, "V1 Backpack defaults to false for these revoked") - - result = self.client.get('/v2/backpack/assertions') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data.get('result')), 1) - - def test_include_expired(self): - test_user = self.setup_user(email='test@example.com', authenticate=True) - test_issuer_one = self.setup_issuer(name="Test Issuer 1", owner=test_user) - test_badgeclass_one = self.setup_badgeclass(name='Test Badgeclass 1', issuer=test_issuer_one) - expired_assertion = test_badgeclass_one.issue(recipient_id='test@example.com', recipient_type='email') - expired_assertion.expires_at = datetime.datetime.now() - datetime.timedelta(days=1) - expired_assertion.save() - test_badgeclass_one.issue(recipient_id='test@example.com', recipient_type='email') - - result = self.client.get('/v2/backpack/assertions?include_expired=1') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data.get('result')), 2) - - result = self.client.get('/v1/earner/badges') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data), 2, "V1 Backpack defaults to true for these values") - - result = self.client.get('/v2/backpack/assertions') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data.get('result')), 1) - - def test_include_expired_and_revoked(self): - test_user = self.setup_user(email='test@example.com', authenticate=True) - test_issuer_one = self.setup_issuer(name="Test Issuer 1", owner=test_user) - test_badgeclass_one = self.setup_badgeclass(name='Test Badgeclass 1', issuer=test_issuer_one) - expired_assertion = test_badgeclass_one.issue(recipient_id='test@example.com', recipient_type='email') - expired_assertion.expires_at = datetime.datetime.now() - datetime.timedelta(days=1) - expired_assertion.save() - revoked_assertion = test_badgeclass_one.issue(recipient_id='test@example.com', recipient_type='email') - revoked_assertion.revoked = True - revoked_assertion.save() - test_badgeclass_one.issue(recipient_id='test@example.com', recipient_type='email') - - result = self.client.get('/v2/backpack/assertions?include_expired=1&include_revoked=1') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data.get('result')), 3) - - result = self.client.get('/v1/earner/badges') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data), 2, "V1 Backpack defaults to true for expired but not revoked") - - result = self.client.get('/v2/backpack/assertions?include_expired=1') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data.get('result')), 2) - - result = self.client.get('/v2/backpack/assertions?include_revoked=1') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data.get('result')), 2) - - result = self.client.get('/v2/backpack/assertions') - self.assertEqual(result.status_code, 200) - self.assertEqual(len(result.data.get('result')), 1) - diff --git a/apps/backpack/tests/test_badge_connect.py b/apps/backpack/tests/test_badge_connect.py deleted file mode 100644 index 9b192ca95..000000000 --- a/apps/backpack/tests/test_badge_connect.py +++ /dev/null @@ -1,619 +0,0 @@ -# encoding: utf-8 -import base64 -import hashlib -import json -import os -import random -import string -import shutil -from urllib import parse - -from openbadges.verifier.openbadges_context import OPENBADGES_CONTEXT_V2_URI, OPENBADGES_CONTEXT_V2_DICT -import responses -import mock - -from django.conf import settings -from django.core.files.storage import default_storage -from django.utils.encoding import force_text -from rest_framework.fields import DateTimeField - -from backpack.tests.utils import setup_resources -from mainsite.models import BadgrApp -from mainsite.tests import BadgrTestCase, SetupIssuerHelper -from mainsite.utils import fetch_remote_file_to_storage - - -class ManifestFileTests(BadgrTestCase): - def test_can_retrieve_manifest_files(self): - ba = BadgrApp.objects.create(name='test', cors='some.domain.com') - response = self.client.get('/bcv1/manifest/some.domain.com', headers={'Accept': 'application/json'}) - self.assertEqual(response.status_code, 200) - data = response.data - self.assertEqual(data['@context'], 'https://w3id.org/openbadges/badgeconnect/v1') - self.assertIn('https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly', data['badgeConnectAPI'][0]['scopesOffered']) - - response = self.client.get('/bcv1/manifest/some.otherdomain.com', headers={'Accept': 'application/json'}) - self.assertEqual(response.status_code, 404) - - response = self.client.get('/.well-known/badgeconnect.json') - self.assertEqual(response.status_code, 302) - - url = parse.urlparse(response._headers['location'][1]) - self.assertIn('/bcv1/manifest/', url.path) - - def test_manifest_file_is_theme_appropriate(self): - ba = BadgrApp.objects.create(name='test', cors='some.domain.com') - response = self.client.get('/bcv1/manifest/some.domain.com', headers={'Accept': 'application/json'}) - data = response.data - self.assertEqual(data['badgeConnectAPI'][0]['name'], ba.name) - - def test_manifest_file_token_and_registration_values(self): - ba = BadgrApp.objects.create(name='test', cors='some.domain.com') - response = self.client.get('/bcv1/manifest/some.domain.com', headers={'Accept': 'application/json'}) - data = response.data - self.assertIn('o/register', data['badgeConnectAPI'][0]['registrationUrl']) - self.assertIn('o/token', data['badgeConnectAPI'][0]['tokenUrl']) - - -class BadgeConnectOAuthTests(BadgrTestCase, SetupIssuerHelper): - def get_test_upload_path(self, *args): - return 'testfiles' - - def setUp(self): - super(BadgeConnectOAuthTests, self).setUp() - - from mainsite.oauth2_api import RegistrationSerializer - - upload_to_path = self.get_test_upload_path() - """" - patching function so upload_to argument points to the testfiles directory. - This guaranties any uploaded files can be clean up after testing - """ - def patched_fetch_and_process_logo_uri(self, logo_uri): - return fetch_remote_file_to_storage(logo_uri, - upload_to=upload_to_path, - allowed_mime_types=['image/png', 'image/svg+xml'], - resize_to_height=512) - self.patcher = mock.patch.object(RegistrationSerializer, "fetch_and_process_logo_uri", - patched_fetch_and_process_logo_uri) - self.patcher.start() - - def tearDown(self): - super(BadgeConnectOAuthTests, self).tearDown() - - testfile_path = os.path.join('{base_url}/{upload_to}/'.format( - base_url=default_storage.location, - upload_to=self.get_test_upload_path(), - )) - if os.path.exists(testfile_path): - try: - shutil.rmtree(testfile_path) - except Exception as e: - print("testfiles were not deleted, %s" % e) - - self.patcher.stop() - - def _register_mock_GET_response_for_logo_uri(self, logo_uri, test_image_path): - """ - Returns a local test image when RegistrationSerializer#create tries to fetch the logo at logo_uri - """ - responses.add( - responses.GET, - logo_uri, - body=open(test_image_path, 'rb').read(), - status=200 - ) - - def _perform_registration_and_authentication(self, **kwargs): - default_requested_scopes = [ - "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly", - ] - requested_scopes = kwargs.get('requested_scopes', default_requested_scopes) - registration_data = { - "client_name": "Badge Issuer", - "client_uri": "https://issuer.example.com", - "logo_uri": "https://issuer.example.com/logo.png", - "tos_uri": "https://issuer.example.com/terms-of-service", - "policy_uri": "https://issuer.example.com/privacy-policy", - "software_id": "13dcdc83-fc0d-4c8d-9159-6461da297388", - "software_version": "54dfc83-fc0d-4c8d-9159-6461da297388", - "redirect_uris": [ - "https://issuer.example.com/o/redirect" - ], - "token_endpoint_auth_method": "client_secret_basic", - "grant_types": [ - "authorization_code", - "refresh_token" - ], - "response_types": [ - "code" - ], - "scope": ' '.join(requested_scopes) - } - - user = self.setup_user(email='test@example.com', authenticate=True) - - self._register_mock_GET_response_for_logo_uri(registration_data['logo_uri'], self.get_test_image_path()) - - response = self.client.post('/o/register', registration_data) - client_id = response.data['client_id'] - client_secret = response.data['client_secret'] - - # Make sure registration didn't sneak any scopes in and that they were attenuated properly - registered_scopes = response.data['scope'].split(' ') - self.assertEqual(set(requested_scopes) - set(default_requested_scopes), set(requested_scopes) - set(registered_scopes)) - - self.assertEqual(registration_data['redirect_uris'][0], response.data['redirect_uris'][0]) - for required_property in [ - 'client_id', 'client_secret', 'client_id_issued_at', 'client_secret_expires_at', - 'client_name', 'client_uri', 'logo_uri', 'tos_uri', 'policy_uri', 'software_id', 'software_version', - 'redirect_uris' - ]: - self.assertIn(required_property, response.data) - - # At this point the client would trigger the user's agent to make a GET request to the authorize UI endpooint - # which would in turn make sure the user is authenticated and then trigger a post to the API to obtain a - # success URL that includes a code. Then the user is redirected to that success URL so the client can continue. - url = '/o/authorize' - verifier = ''.join(random.choices(string.ascii_lowercase + string.digits, k=16)) - data = { - "allow": True, - "response_type": "code", - "client_id": response.data['client_id'], - "redirect_uri": registration_data['redirect_uris'][0], - "scopes": registered_scopes, - "state": "", - "code_challenge": base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip('='), - "code_challenge_method": 'S256' - } - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 200) - self.assertTrue(response.data['success_url'].startswith(registration_data['redirect_uris'][0])) - self.assertTrue('scope' in response.data['success_url']) - url = parse.urlparse(response.data['success_url']) - code = parse.parse_qs(url.query)['code'][0] - - # Now the client has retrieved the code and will attempt to exchange it for an access token. - if kwargs.get('pkce_fail') is True: - verifier = "swisscheese" - - data = { - 'grant_type': 'authorization_code', - 'code': code, - 'redirect_uri': registration_data['redirect_uris'][0], - 'scope': ' '.join(registered_scopes), - 'code_verifier': verifier - } - basic_auth_header = 'Basic ' + base64.b64encode( - '{}:{}'.format( - parse.quote(client_id), parse.quote(client_secret) - ).encode('ascii') - ).decode('ascii') - self.client.credentials(HTTP_AUTHORIZATION=basic_auth_header) - response = self.client.post('/o/token', data=data) - if kwargs.get('pkce_fail') is True: - self.assertEqual(response.status_code, 400) - return - - self.assertEqual(response.status_code, 200) - - self.client.logout() - - token_data = json.loads(response.content) - self.assertTrue('refresh_token' in token_data) - access_token = token_data['access_token'] - - test_issuer_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_issuer_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - assertion = test_badgeclass.issue(user.email, notify=False) - - # Get the assertion - self.client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(access_token)) - response = self.client.get('/bcv1/assertions') - self.assertEqual(response.status_code, 200) - - REMOTE_BADGE_URI = 'http://a.com/assertion-embedded1' - setup_resources([ - {'url': REMOTE_BADGE_URI, 'filename': '2_0_assertion_embedded_badgeclass.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': 'http://a.com/badgeclass_image', 'filename': "unbaked_image.png", 'mode': 'rb'}, - ]) - # Post new external assertion - assertion.save() - - expected_status = { - "error": None, - "statusCode": 200, - "statusText": 'OK' - } - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post('/bcv1/assertions', data={'assertion': {'id': REMOTE_BADGE_URI}}, format='json') - self.assertEqual(response.status_code, 201) - self.assertJSONEqual(force_text(response.content), { - "status": expected_status - }) - - response = self.client.get('/bcv1/assertions') - self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_text(json.dumps(response.data['status'])), expected_status) - self.assertEqual(len(response.data['results']), 2) - ids = [response.data['results'][0]['id'], response.data['results'][1]['id']] - self.assertTrue(assertion.jsonld_id in ids) - self.assertTrue(REMOTE_BADGE_URI in ids) - for result in response.data['results']: - self.assertEqual(result['@context'], OPENBADGES_CONTEXT_V2_URI) - self.assertEqual(result['type'], 'Assertion') - - response = self.client.get('/bcv1/profile') - self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_text(response.content), { - "status": expected_status, - "results": [ - { - "@context": "https://w3id.org/openbadges/v2", - "name": "firsty lastington", - "email": "test@example.com" - } - ] - }) - - @responses.activate - def test_can_register_and_auth_badge_connect_app(self): - self._perform_registration_and_authentication() - - @responses.activate - def test_cannot_register_and_auth_badge_connect_app_if_pkce_verification_fails(self): - self._perform_registration_and_authentication(pkce_fail=True) - - @responses.activate - def test_scope_attenuation(self): - """ - If a Badge Connect Relying Party asks for a scope we don't support, we don't need to reject that request. - We can just attenuate the scopes and return what we do support. - """ - all_spec_scopes = [ - "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.update" - ] - self._perform_registration_and_authentication(requested_scopes=all_spec_scopes) - - @responses.activate - def test_supply_default_scope(self): - registration_data = { - "client_name": "Badge Issuer", - "client_uri": "https://issuer.example.com", - "logo_uri": "https://issuer.example.com/logo.png", - "tos_uri": "https://issuer.example.com/terms-of-service", - "policy_uri": "https://issuer.example.com/privacy-policy", - "software_id": "13dcdc83-fc0d-4c8d-9159-6461da297388", - "software_version": "54dfc83-fc0d-4c8d-9159-6461da297388", - "redirect_uris": [ - "https://issuer.example.com/o/redirect" - ], - "token_endpoint_auth_method": "client_secret_basic", - "grant_types": [ - "authorization_code", - "refresh_token" - ], - "response_types": [ - "code" - ], - } - user = self.setup_user(email='test@example.com', authenticate=True) - - self._register_mock_GET_response_for_logo_uri(registration_data['logo_uri'], self.get_test_image_path()) - response = self.client.post('/o/register', registration_data) - self.assertTrue('client_id' in response.data) - - @responses.activate - def register_and_process_logo_uri(self, test_image_path): - requested_scopes = [ - "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly", - ] - registration_data = { - "client_name": "Badge Issuer", - "client_uri": "https://issuer.example.com", - "logo_uri": "https://issuer.example.com/logo.png", - "tos_uri": "https://issuer.example.com/terms-of-service", - "policy_uri": "https://issuer.example.com/privacy-policy", - "software_id": "13dcdc83-fc0d-4c8d-9159-6461da297388", - "software_version": "54dfc83-fc0d-4c8d-9159-6461da297388", - "redirect_uris": [ - "https://issuer.example.com/o/redirect" - ], - "token_endpoint_auth_method": "client_secret_basic", - "grant_types": [ - "authorization_code", - "refresh_token" - ], - "response_types": [ - "code" - ], - "scope": ' '.join(requested_scopes) - } - - self._register_mock_GET_response_for_logo_uri(registration_data['logo_uri'], test_image_path) - - return self.client.post('/o/register', registration_data) - - def assert_logo_url_was_handled(self, response): - logo_url_storage_name = response.data['logo_uri'].split(getattr(settings, 'HTTP_ORIGIN')+"/media/") - self.assertEqual(response.status_code, 201) - self.assertTrue(default_storage.size(logo_url_storage_name[1]) > 0) - - def test_registration_when_logo_uri_is_png(self): - self.assert_logo_url_was_handled(self.register_and_process_logo_uri(self.get_test_png_image_path())) - - def test_registration_when_logo_uri_svg(self): - self.assert_logo_url_was_handled(self.register_and_process_logo_uri(self.get_test_svg_image_path())) - - def test_registration_when_logo_uri_is_svg_hacked(self): - self.assert_logo_url_was_handled(self.register_and_process_logo_uri(self.get_hacked_svg_image_path())) - - def test_registration_when_logo_uri_is_not_svg_or_png(self): - response = self.register_and_process_logo_uri(self.get_test_jpeg_image_path()) - self.assertEqual(response.status_code, 415) - - - def test_reject_different_domains(self): - registration_data = { - "client_name": "Badge Issuer", - "client_uri": "https://issuer.example.com", - "logo_uri": "https://issuer.example.com/logo.png", - "tos_uri": "https://issuer.example.com/terms-of-service", - "policy_uri": "https://issuer.example.com/privacy-policy", - "software_id": "13dcdc83-fc0d-4c8d-9159-6461da297388", - "software_version": "54dfc83-fc0d-4c8d-9159-6461da297388", - "redirect_uris": [ - "https://issuer2.example.com/o/redirect" - ], - "token_endpoint_auth_method": "client_secret_basic", - "grant_types": [ - "authorization_code", - "refresh_token" - ], - "response_types": [ - "code" - ], - } - user = self.setup_user(email='test@example.com', authenticate=True) - - response = self.client.post('/o/register', registration_data) - self.assertIn("do not match", response.data['error']) - registration_data['redirect_uris'][0] = "https://issuer2.example.com/o/redirect" - registration_data['logo_uri'] = "https://issuer2.example.com/logo.png" - response = self.client.post('/o/register', registration_data) - self.assertIn("do not match", response.data['error']) - registration_data['logo_uri'] = "https://issuer.example.com/logo.png" - registration_data['tos_uri'] = "https://issuer2.example.com/terms-of-service" - response = self.client.post('/o/register', registration_data) - self.assertIn("do not match", response.data['error']) - registration_data['tos_uri'] = "https://issuer.example.com/terms-of-service" - registration_data['policy_uri'] = "https://issuer2.example.com/privacy-policy" - response = self.client.post('/o/register', registration_data) - self.assertIn("do not match", response.data['error']) - registration_data['policy_uri'] = "https://issuer.example.com/privacy-policy" - registration_data['client_uri'] = "https://issuer2.example.com" - response = self.client.post('/o/register', registration_data) - self.assertIn("do not match", response.data['error']) - - def test_all_https_uris(self): - registration_data = { - "client_name": "Badge Issuer", - "client_uri": "https://issuer.example.com", - "logo_uri": "https://issuer.example.com/logo.png", - "tos_uri": "https://issuer.example.com/terms-of-service", - "policy_uri": "https://issuer.example.com/privacy-policy", - "software_id": "13dcdc83-fc0d-4c8d-9159-6461da297388", - "software_version": "54dfc83-fc0d-4c8d-9159-6461da297388", - "redirect_uris": [ - "http://issuer.example.com/o/redirect" - ], - "token_endpoint_auth_method": "client_secret_basic", - "grant_types": [ - "authorization_code", - "refresh_token" - ], - "response_types": [ - "code" - ], - } - user = self.setup_user(email='test@example.com', authenticate=True) - - response = self.client.post('/o/register', registration_data) - self.assertEqual(response.data['error'], "redirect_uris: Must be a valid HTTPS URI") - registration_data['redirect_uris'][0] = "https://issuer.example.com/o/redirect" - registration_data['logo_uri'] = "http://issuer.example.com/logo.png" - response = self.client.post('/o/register', registration_data) - self.assertEqual(response.data['error'], "logo_uri: Must be a valid HTTPS URI") - registration_data['logo_uri'] = "https://issuer.example.com/logo.png" - registration_data['tos_uri'] = "http://issuer.example.com/terms-of-service" - response = self.client.post('/o/register', registration_data) - self.assertEqual(response.data['error'], "tos_uri: Must be a valid HTTPS URI") - registration_data['tos_uri'] = "https://issuer.example.com/terms-of-service" - registration_data['policy_uri'] = "http://issuer.example.com/privacy-policy" - response = self.client.post('/o/register', registration_data) - self.assertEqual(response.data['error'], "policy_uri: Must be a valid HTTPS URI") - registration_data['policy_uri'] = "https://issuer.example.com/privacy-policy" - registration_data['client_uri'] = "http://issuer.example.com" - response = self.client.post('/o/register', registration_data) - self.assertEqual(response.data['error'], "client_uri: Must be a valid HTTPS URI") - - @responses.activate - def test_no_refresh_token(self): - requested_scopes = [ - "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create", - "https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly", - ] - registration_data = { - "client_name": "Badge Issuer", - "client_uri": "https://issuer.example.com", - "logo_uri": "https://issuer.example.com/logo.png", - "tos_uri": "https://issuer.example.com/terms-of-service", - "policy_uri": "https://issuer.example.com/privacy-policy", - "software_id": "13dcdc83-fc0d-4c8d-9159-6461da297388", - "software_version": "54dfc83-fc0d-4c8d-9159-6461da297388", - "redirect_uris": [ - "https://issuer.example.com/o/redirect" - ], - "token_endpoint_auth_method": "client_secret_basic", - "grant_types": [ - "authorization_code", - ], - "response_types": [ - "code" - ], - "scope": ' '.join(requested_scopes) - } - - user = self.setup_user(email='test@example.com', authenticate=True) - - self._register_mock_GET_response_for_logo_uri(registration_data['logo_uri'], self.get_test_image_path()) - - response = self.client.post('/o/register', registration_data) - client_id = response.data['client_id'] - url = '/o/authorize' - data = { - "allow": True, - "response_type": "code", - "client_id": response.data['client_id'], - "redirect_uri": registration_data['redirect_uris'][0], - "scopes": requested_scopes, - "state": "" - } - response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 200) - self.assertTrue(response.data['success_url'].startswith(registration_data['redirect_uris'][0])) - url = parse.urlparse(response.data['success_url']) - code = parse.parse_qs(url.query)['code'][0] - - data = { - 'grant_type': 'authorization_code', - 'code': code, - 'client_id': client_id, - 'redirect_uri': registration_data['redirect_uris'][0], - 'scope': ' '.join(requested_scopes), - } - response = self.client.post('/o/token', data=data) - self.assertEqual(response.status_code, 200) - - token_data = json.loads(response.content) - self.assertTrue('refresh_token' not in token_data) - - -class BadgeConnectAPITests(BadgrTestCase, SetupIssuerHelper): - - def test_unauthenticated_requests(self): - expected_response = { - "status": { - "error": None, - "statusCode": 401, - "statusText": 'UNAUTHENTICATED' - } - } - - response = self.client.get('/bcv1/assertions') - self.assertEquals(response.status_code, 401) - self.assertJSONEqual(force_text(response.content), expected_response) - - response = self.client.post('/bcv1/assertions', data={'id': 'http://a.com/assertion-embedded1'}, format='json') - self.assertEquals(response.status_code, 401) - self.assertJSONEqual(force_text(response.content), expected_response) - - response = self.client.get('/bcv1/profile') - self.assertEqual(response.status_code, 401) - self.assertJSONEqual(force_text(response.content), expected_response) - - @responses.activate - def test_submit_badges_with_intragraph_references(self): - setup_resources([ - {'url': 'http://a.com/assertion-embedded1', 'filename': '2_0_assertion_embedded_badgeclass.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': 'http://a.com/badgeclass_image', 'filename': "unbaked_image.png", 'mode': 'rb'}, - ]) - self.setup_user(email='test@example.com', authenticate=True) - - assertion = { - "@context": 'https://w3id.org/openbadges/v2', - "id": 'http://a.com/assertion-embedded1', - "type": "Assertion", - } - post_input = { - 'assertion': assertion - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post('/bcv1/assertions', post_input, format='json') - self.assertEqual(response.status_code, 201) - - def test_assertions_pagination(self): - self.user = self.setup_user(authenticate=True) - - test_issuer_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_issuer_user) - assertions = [] - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - for _ in range(25): - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertions.append(test_badgeclass.issue(self.user.email, - notify=False)) - response = self.client.get('/bcv1/assertions?limit=10&offset=0') - self.assertEqual(len(response.data['results']), 10) - self.assertTrue(response.has_header('Link')) - self.assertTrue('; rel="next"' in response['Link']) - self.assertTrue('; rel="last"' in response['Link']) - self.assertTrue('; rel="first"' in response['Link']) - for x in range(0, 10): - self.assertEqual(response.data['results'][x]['id'], assertions[24 - x].jsonld_id) - - response = self.client.get('/bcv1/assertions?limit=10&offset=10') - self.assertEqual(len(response.data['results']), 10) - self.assertTrue(response.has_header('Link')) - self.assertTrue('; rel="next"' in response['Link']) - self.assertTrue('; rel="last"' in response['Link']) - self.assertTrue('; rel="first"' in response['Link']) - self.assertTrue('; rel="prev"' in response['Link']) - for x in range(0, 10): - self.assertEqual(response.data['results'][x]['id'], assertions[24 - (x + 10)].jsonld_id) - - response = self.client.get('/bcv1/assertions?limit=10&offset=20') - self.assertEqual(len(response.data['results']), 5) - self.assertTrue(response.has_header('Link')) - self.assertTrue('; rel="last"' in response['Link']) - self.assertTrue('; rel="first"' in response['Link']) - self.assertTrue('; rel="prev"' in response['Link']) - for x in range(0, 5): - self.assertEqual(response.data['results'][x]['id'], assertions[24 - (x + 20)].jsonld_id) - - since = parse.quote(DateTimeField().to_representation(assertions[5].created_at)) - response = self.client.get('/bcv1/assertions?limit=10&offset=0&since=' + since) - self.assertEqual(len(response.data['results']), 10) - self.assertTrue('; rel="next"' % since in response['Link']) - self.assertTrue('; rel="last"' % since in response['Link']) - self.assertTrue('; rel="first"' % since in response['Link']) - for x in range(0, 10): - self.assertEqual(response.data['results'][x]['id'], assertions[24 - x].jsonld_id) - - response = self.client.get('/bcv1/assertions?limit=10&offset=10&since=' + since) - self.assertEqual(len(response.data['results']), 10) - self.assertTrue(response.has_header('Link')) - self.assertTrue('; rel="last"' % since in response['Link']) - self.assertTrue('; rel="first"' % since in response['Link']) - self.assertTrue('; rel="prev"' % since in response['Link']) - for x in range(0, 10): - self.assertEqual(response.data['results'][x]['id'], assertions[24 - (x + 10)].jsonld_id) diff --git a/apps/backpack/tests/test_collections.py b/apps/backpack/tests/test_collections.py index 8f396bc35..4a5b4b708 100644 --- a/apps/backpack/tests/test_collections.py +++ b/apps/backpack/tests/test_collections.py @@ -1,3 +1,8 @@ +import json +import os + +from django.test import override_settings +from mainsite import TOP_DIR from badgeuser.models import BadgeUser, CachedEmailAddress from issuer.models import BadgeClass, Issuer, BadgeInstance from mainsite.tests.base import BadgrTestCase @@ -6,155 +11,192 @@ from backpack.serializers_v1 import CollectionSerializerV1 from backpack.serializers_v2 import BackpackCollectionSerializerV2 -from .utils import setup_basic_0_5_0, setup_basic_1_0, setup_resources - +# Override MEDIA_ROOT to point tests to the testfiles for issuer and badge images +@override_settings(MEDIA_ROOT=os.path.join(TOP_DIR, "apps/mainsite/tests/testfiles")) class TestCollections(BadgrTestCase): def setUp(self): super(TestCollections, self).setUp() - self.user, _ = BadgeUser.objects.get_or_create(email='test@example.com') + self.user, _ = BadgeUser.objects.get_or_create(email="test@example.com") - self.cached_email, _ = CachedEmailAddress.objects.get_or_create(user=self.user, email='test@example.com', verified=True, primary=True) + self.cached_email, _ = CachedEmailAddress.objects.get_or_create( + user=self.user, email="test@example.com", verified=True, primary=True + ) self.issuer = Issuer.objects.create( name="Open Badges", created_at="2015-12-15T15:55:51Z", - created_by=None, + created_by=self.user, slug="open-badges", source_url="http://badger.openbadges.org/program/meta/bda68a0b505bc0c7cf21bc7900280ee74845f693", source="test-fixture", - image="" + image="issuer.png", + url="example.com", + email="issuer@example.com", + verified=True, + linkedinId="", ) self.badge_class = BadgeClass.objects.create( - name="MozFest Reveler", + name="This is a badge", + description="created on Open Educational Badges", created_at="2015-12-15T15:55:51Z", created_by=None, slug="mozfest-reveler", criteria_text=None, source_url="http://badger.openbadges.org/badge/meta/mozfest-reveler", source="test-fixture", - image="", - issuer=self.issuer + image="badge.png", + issuer=self.issuer, + extension_items={ + "extensions:CategoryExtension": json.loads( + '{ "type": ["Extension", "extensions:CategoryExtension"], "Category": "participation" }' + ), + "extensions:CompetencyExtension": json.loads("[]"), + }, ) self.local_badge_instance_1 = BadgeInstance.objects.create( recipient_identifier="test@example.com", badgeclass=self.badge_class, issuer=self.issuer, - image="uploads/badges/local_badgeinstance_174e70bf-b7a8-4b71-8125-c34d1a994a7c.png", - acceptance=BadgeInstance.ACCEPTANCE_ACCEPTED + image=None, + acceptance=BadgeInstance.ACCEPTANCE_ACCEPTED, ) self.local_badge_instance_2 = BadgeInstance.objects.create( recipient_identifier="test@example.com", badgeclass=self.badge_class, issuer=self.issuer, - image="uploads/badges/local_badgeinstance_174e70bf-b7a8-4b71-8125-c34d1a994a7c.png", - acceptance=BadgeInstance.ACCEPTANCE_ACCEPTED + image=None, + acceptance=BadgeInstance.ACCEPTANCE_ACCEPTED, ) self.local_badge_instance_3 = BadgeInstance.objects.create( recipient_identifier="test@example.com", badgeclass=self.badge_class, issuer=self.issuer, - image="uploads/badges/local_badgeinstance_174e70bf-b7a8-4b71-8125-c34d1a994a7c.png", - acceptance=BadgeInstance.ACCEPTANCE_ACCEPTED + image=None, + acceptance=BadgeInstance.ACCEPTANCE_ACCEPTED, ) self.collection = BackpackCollection.objects.create( created_by=self.user, - description='The Freshest Ones', - name='Fresh Badges', - slug='fresh-badges' + description="The Freshest Ones", + name="Fresh Badges", + slug="fresh-badges", ) BackpackCollection.objects.create( created_by=self.user, - description='It\'s even fresher.', - name='Cool New Collection', - slug='cool-new-collection' + description="It's even fresher.", + name="Cool New Collection", + slug="cool-new-collection", ) BackpackCollection.objects.create( created_by=self.user, - description='Newest!', - name='New collection', - slug='new-collection' + description="Newest!", + name="New collection", + slug="new-collection", ) def test_can_get_collection_list(self): self.client.force_authenticate(user=self.user) - response = self.client.get('/v1/earner/collections') + response = self.client.get("/v1/earner/collections") self.assertEqual(len(response.data), 3) - self.assertEqual(response.data[0]['badges'], []) + self.assertEqual(response.data[0]["badges"], []) def test_can_get_collection_detail(self): self.client.force_authenticate(user=self.user) - response = self.client.get('/v1/earner/collections/fresh-badges') + response = self.client.get("/v1/earner/collections/fresh-badges") - self.assertEqual(response.data['badges'], []) + self.assertEqual(response.data["badges"], []) - response = self.client.get('/v2/backpack/collections/{}'.format(self.collection.entity_id)) + response = self.client.get( + "/v2/backpack/collections/{}".format(self.collection.entity_id) + ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['result'][0]['shareHash'], '') - self.assertEqual(response.data['result'][0]['owner'], self.user.entity_id) + self.assertEqual(response.data["result"][0]["shareHash"], "") + self.assertEqual(response.data["result"][0]["owner"], self.user.entity_id) self.collection.published = True self.collection.save() - response = self.client.get('/v2/backpack/collections/{}'.format(self.collection.entity_id)) + response = self.client.get( + "/v2/backpack/collections/{}".format(self.collection.entity_id) + ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['result'][0]['shareHash'], self.collection.share_hash) - + self.assertEqual( + response.data["result"][0]["shareHash"], self.collection.share_hash + ) def test_can_define_collection(self): """ Authorized user can create a new collection via API. """ data = { - 'name': 'Fruity Collection', - 'description': 'Apples and Oranges', - 'published': True, - 'badges': [ - {'id': self.local_badge_instance_1.entity_id}, - {'id': self.local_badge_instance_2.entity_id, 'description': 'A cool badge'} - ] + "name": "Fruity Collection", + "description": "Apples and Oranges", + "published": True, + "badges": [ + {"id": self.local_badge_instance_1.entity_id}, + { + "id": self.local_badge_instance_2.entity_id, + "description": "A cool badge", + }, + ], } self.client.force_authenticate(user=self.user) - response = self.client.post('/v1/earner/collections', data, format='json') + response = self.client.post("/v1/earner/collections", data, format="json") self.assertEqual(response.status_code, 201) - self.assertTrue(response.data.get('published')) - - self.assertEqual([i['id'] for i in response.data.get('badges')], [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]) + self.assertTrue(response.data.get("published")) + + self.assertEqual( + [i["id"] for i in response.data.get("badges")], + [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ], + ) def test_can_define_collection_serializer(self): """ A new collection may be created directly via serializer. """ data = { - 'name': 'Fruity Collection', - 'description': 'Apples and Oranges', - 'badges': [{'id': self.local_badge_instance_1.entity_id}, {'id': self.local_badge_instance_2.entity_id, 'description': 'A cool badge'}] + "name": "Fruity Collection", + "description": "Apples and Oranges", + "badges": [ + {"id": self.local_badge_instance_1.entity_id}, + { + "id": self.local_badge_instance_2.entity_id, + "description": "A cool badge", + }, + ], } - serializer = CollectionSerializerV1(data=data, context={'user': self.user}) + serializer = CollectionSerializerV1(data=data, context={"user": self.user}) serializer.is_valid(raise_exception=True) collection = serializer.save() self.assertIsNotNone(collection.pk) - self.assertEqual(collection.name, data['name']) + self.assertEqual(collection.name, data["name"]) self.assertEqual(collection.cached_badgeinstances().count(), 2) def test_can_delete_collection(self): """ Authorized user may delete one of their defined collections. """ - collection = BackpackCollection.objects.filter(created_by_id=self.user.id).first() + collection = BackpackCollection.objects.filter( + created_by_id=self.user.id + ).first() self.client.force_authenticate(user=self.user) - response = self.client.delete('/v1/earner/collections/{}'.format(collection.entity_id)) + response = self.client.delete( + "/v1/earner/collections/{}".format(collection.entity_id) + ) self.assertEqual(response.status_code, 204) @@ -164,27 +206,25 @@ def test_can_publish_unpublish_collection_serializer(self): via update method. """ collection = BackpackCollection.objects.first() - self.assertIn(collection.share_url, ('', None)) + self.assertIn(collection.share_url, ("", None)) serializer = CollectionSerializerV1( - collection, - data={'published': True}, partial=True + collection, data={"published": True}, partial=True ) serializer.is_valid(raise_exception=True) serializer.save() - self.assertNotEqual(collection.share_url, '') + self.assertNotEqual(collection.share_url, "") self.assertTrue(collection.published) serializer = CollectionSerializerV1( - collection, - data={'published': False}, partial=True + collection, data={"published": False}, partial=True ) serializer.is_valid(raise_exception=True) serializer.save() self.assertFalse(collection.published) - self.assertIn(collection.share_url, ('', None)) + self.assertIn(collection.share_url, ("", None)) def test_can_publish_unpublish_collection_api_share_method(self): """ @@ -192,17 +232,15 @@ def test_can_publish_unpublish_collection_api_share_method(self): via the CollectionGenerateShare GET/DELETE methods. """ self.client.force_authenticate(user=self.user) - response = self.client.get( - '/v1/earner/collections/fresh-badges/share' - ) + response = self.client.get("/v1/earner/collections/fresh-badges/share") self.assertEqual(response.status_code, 200) - self.assertTrue(response.data.startswith('http')) + self.assertTrue(response.data.startswith("http")) collection = BackpackCollection.objects.get(pk=self.collection.pk) self.assertTrue(collection.published) - response = self.client.delete('/v1/earner/collections/fresh-badges/share') + response = self.client.delete("/v1/earner/collections/fresh-badges/share") self.assertEqual(response.status_code, 204) self.assertIsNone(response.data) @@ -217,27 +255,49 @@ def test_can_add_remove_collection_badges_via_serializer_v1(self): serializer = CollectionSerializerV1( collection, - data={'badges': [{'id': self.local_badge_instance_1.entity_id}, {'id': self.local_badge_instance_2.entity_id}]}, - partial=True + data={ + "badges": [ + {"id": self.local_badge_instance_1.entity_id}, + {"id": self.local_badge_instance_2.entity_id}, + ] + }, + partial=True, ) serializer.is_valid(raise_exception=True) serializer.save() self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ], + ) serializer = CollectionSerializerV1( collection, - data={'badges': [{'id': self.local_badge_instance_2.entity_id}, {'id': self.local_badge_instance_3.entity_id}]}, - partial=True + data={ + "badges": [ + {"id": self.local_badge_instance_2.entity_id}, + {"id": self.local_badge_instance_3.entity_id}, + ] + }, + partial=True, ) serializer.is_valid(raise_exception=True) serializer.save() self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_2.entity_id, self.local_badge_instance_3.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [ + self.local_badge_instance_2.entity_id, + self.local_badge_instance_3.entity_id, + ], + ) def test_can_add_remove_collection_badges_via_serializer_v2(self): """ @@ -248,27 +308,49 @@ def test_can_add_remove_collection_badges_via_serializer_v2(self): serializer = BackpackCollectionSerializerV2( collection, - data={'assertions': [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]}, - partial=True + data={ + "assertions": [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ] + }, + partial=True, ) serializer.is_valid(raise_exception=True) serializer.save() self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ], + ) serializer = BackpackCollectionSerializerV2( collection, - data={'assertions': [self.local_badge_instance_2.entity_id, self.local_badge_instance_3.entity_id]}, - partial=True + data={ + "assertions": [ + self.local_badge_instance_2.entity_id, + self.local_badge_instance_3.entity_id, + ] + }, + partial=True, ) serializer.is_valid(raise_exception=True) serializer.save() self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_2.entity_id, self.local_badge_instance_3.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [ + self.local_badge_instance_2.entity_id, + self.local_badge_instance_3.entity_id, + ], + ) def test_can_add_remove_collection_badges_via_collection_detail_api(self): """ @@ -279,91 +361,167 @@ def test_can_add_remove_collection_badges_via_collection_detail_api(self): self.assertEqual(len(self.collection.cached_badgeinstances()), 0) data = { - 'badges': [{'id': self.local_badge_instance_1.entity_id}, {'id': self.local_badge_instance_2.entity_id}], - 'name': collection.name, - 'description': collection.description + "badges": [ + {"id": self.local_badge_instance_1.entity_id}, + {"id": self.local_badge_instance_2.entity_id}, + ], + "name": collection.name, + "description": collection.description, } self.client.force_authenticate(user=self.user) response = self.client.put( - '/v1/earner/collections/{}'.format(collection.entity_id), data=data, - format='json') + "/v1/earner/collections/{}".format(collection.entity_id), + data=data, + format="json", + ) self.assertEqual(response.status_code, 200) - collection = BackpackCollection.objects.get(entity_id=response.data.get('slug')) # reload + collection = BackpackCollection.objects.get( + entity_id=response.data.get("slug") + ) # reload self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ], + ) data = { - 'badges': [{'id': self.local_badge_instance_2.entity_id}, {'id': self.local_badge_instance_3.entity_id}], - 'name': collection.name, + "badges": [ + {"id": self.local_badge_instance_2.entity_id}, + {"id": self.local_badge_instance_3.entity_id}, + ], + "name": collection.name, } response = self.client.put( - '/v1/earner/collections/{}'.format(collection.entity_id), - data=data, format='json') + "/v1/earner/collections/{}".format(collection.entity_id), + data=data, + format="json", + ) self.assertEqual(response.status_code, 200) - self.assertEqual([i['id'] for i in response.data.get('badges')], [self.local_badge_instance_2.entity_id, self.local_badge_instance_3.entity_id]) - collection = BackpackCollection.objects.get(entity_id=response.data.get('slug')) # reload + self.assertEqual( + [i["id"] for i in response.data.get("badges")], + [ + self.local_badge_instance_2.entity_id, + self.local_badge_instance_3.entity_id, + ], + ) + collection = BackpackCollection.objects.get( + entity_id=response.data.get("slug") + ) # reload self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_2.entity_id, self.local_badge_instance_3.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [ + self.local_badge_instance_2.entity_id, + self.local_badge_instance_3.entity_id, + ], + ) def test_can_add_remove_badges_via_collection_badge_detail_api(self): self.assertEqual(len(self.collection.cached_badgeinstances()), 0) - data = [{'id': self.local_badge_instance_1.entity_id}, {'id': self.local_badge_instance_2.entity_id}] + data = [ + {"id": self.local_badge_instance_1.entity_id}, + {"id": self.local_badge_instance_2.entity_id}, + ] self.client.force_authenticate(user=self.user) response = self.client.post( - '/v1/earner/collections/{}/badges'.format(self.collection.entity_id), data=data, - format='json') + "/v1/earner/collections/{}/badges".format(self.collection.entity_id), + data=data, + format="json", + ) self.assertEqual(response.status_code, 201) - self.assertEqual([i['id'] for i in response.data], [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]) + self.assertEqual( + [i["id"] for i in response.data], + [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ], + ) collection = BackpackCollection.objects.first() # reload self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ], + ) - response = self.client.get('/v1/earner/collections/{}/badges'.format(collection.slug)) + response = self.client.get( + "/v1/earner/collections/{}/badges".format(collection.slug) + ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 2) response = self.client.delete( - '/v1/earner/collections/{}/badges/{}'.format(collection.entity_id, data[0]['id']), - data=data, format='json') + "/v1/earner/collections/{}/badges/{}".format( + collection.entity_id, data[0]["id"] + ), + data=data, + format="json", + ) self.assertEqual(response.status_code, 204) self.assertIsNone(response.data) collection = BackpackCollection.objects.first() # reload self.assertEqual(collection.cached_badgeinstances().count(), 1) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_2.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [self.local_badge_instance_2.entity_id], + ) def test_can_add_remove_issuer_badges_via_api(self): self.assertEqual(len(self.collection.cached_badgeinstances()), 0) data = [ - {'id': self.local_badge_instance_1.entity_id}, - {'id': self.local_badge_instance_2.entity_id} + {"id": self.local_badge_instance_1.entity_id}, + {"id": self.local_badge_instance_2.entity_id}, ] self.client.force_authenticate(user=self.user) response = self.client.post( - '/v1/earner/collections/{}/badges'.format(self.collection.entity_id), data=data, - format='json') + "/v1/earner/collections/{}/badges".format(self.collection.entity_id), + data=data, + format="json", + ) self.assertEqual(response.status_code, 201) - self.assertEqual([i['id'] for i in response.data], [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]) + self.assertEqual( + [i["id"] for i in response.data], + [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ], + ) self.assertEqual(self.collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in self.collection.cached_badgeinstances()], [self.local_badge_instance_1.entity_id, self.local_badge_instance_2.entity_id]) + self.assertEqual( + [i.entity_id for i in self.collection.cached_badgeinstances()], + [ + self.local_badge_instance_1.entity_id, + self.local_badge_instance_2.entity_id, + ], + ) response = self.client.get( - '/v1/earner/collections/{}/badges/{}'.format(self.collection.entity_id, self.local_badge_instance_2.entity_id) + "/v1/earner/collections/{}/badges/{}".format( + self.collection.entity_id, self.local_badge_instance_2.entity_id + ) ) self.assertEqual(response.status_code, 200) response = self.client.delete( - '/v1/earner/collections/{}/badges/{}'.format(self.collection.entity_id, self.local_badge_instance_2.entity_id) + "/v1/earner/collections/{}/badges/{}".format( + self.collection.entity_id, self.local_badge_instance_2.entity_id + ) ) self.assertEqual(response.status_code, 204) @@ -371,14 +529,18 @@ def test_api_handles_null_description_and_adds_badge(self): self.assertEqual(len(self.collection.cached_badgeinstances()), 0) data = { - 'badges': [{'id': self.local_badge_instance_1.entity_id, 'description': None}], - 'name': self.collection.name, + "badges": [ + {"id": self.local_badge_instance_1.entity_id, "description": None} + ], + "name": self.collection.name, } self.client.force_authenticate(user=self.user) response = self.client.put( - '/v1/earner/collections/{}'.format(self.collection.entity_id), data=data, - format='json') + "/v1/earner/collections/{}".format(self.collection.entity_id), + data=data, + format="json", + ) self.assertEqual(response.status_code, 200) entry = self.collection.cached_collects().first() @@ -391,37 +553,66 @@ def test_can_add_remove_collection_badges_collection_badgelist_api(self): """ self.assertEqual(len(self.collection.cached_badgeinstances()), 0) - data = [{'id': self.local_badge_instance_1.entity_id}, {'id': self.local_badge_instance_2.entity_id}] + data = [ + {"id": self.local_badge_instance_1.entity_id}, + {"id": self.local_badge_instance_2.entity_id}, + ] self.client.force_authenticate(user=self.user) response = self.client.put( - '/v1/earner/collections/{}/badges'.format(self.collection.entity_id), data=data, - format='json') + "/v1/earner/collections/{}/badges".format(self.collection.entity_id), + data=data, + format="json", + ) self.assertEqual(response.status_code, 200) collection = BackpackCollection.objects.first() # reload self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.pk for i in collection.cached_badgeinstances()], [self.local_badge_instance_1.pk, self.local_badge_instance_2.pk]) + self.assertEqual( + [i.pk for i in collection.cached_badgeinstances()], + [self.local_badge_instance_1.pk, self.local_badge_instance_2.pk], + ) - data = [{'id': self.local_badge_instance_2.entity_id}, {'id': self.local_badge_instance_3.entity_id}] + data = [ + {"id": self.local_badge_instance_2.entity_id}, + {"id": self.local_badge_instance_3.entity_id}, + ] response = self.client.put( - '/v1/earner/collections/{}/badges'.format(collection.entity_id), - data=data, format='json') + "/v1/earner/collections/{}/badges".format(collection.entity_id), + data=data, + format="json", + ) self.assertEqual(response.status_code, 200) - self.assertEqual([i['id'] for i in response.data], [self.local_badge_instance_2.entity_id, self.local_badge_instance_3.entity_id]) + self.assertEqual( + [i["id"] for i in response.data], + [ + self.local_badge_instance_2.entity_id, + self.local_badge_instance_3.entity_id, + ], + ) collection = BackpackCollection.objects.first() # reload self.assertEqual(collection.cached_badgeinstances().count(), 2) - self.assertEqual([i.entity_id for i in collection.cached_badgeinstances()], [self.local_badge_instance_2.entity_id, self.local_badge_instance_3.entity_id]) + self.assertEqual( + [i.entity_id for i in collection.cached_badgeinstances()], + [ + self.local_badge_instance_2.entity_id, + self.local_badge_instance_3.entity_id, + ], + ) def xit_test_can_update_badge_description_in_collection_via_detail_api(self): self.assertEqual(self.collection.cached_badgeinstances().count(), 0) serializer = CollectionSerializerV1( self.collection, - data={'badges': [{'id': self.local_badge_instance_1.pk}, - {'id': self.local_badge_instance_2.pk}]}, - partial=True + data={ + "badges": [ + {"id": self.local_badge_instance_1.pk}, + {"id": self.local_badge_instance_2.pk}, + ] + }, + partial=True, ) serializer.is_valid(raise_exception=True) @@ -431,34 +622,50 @@ def xit_test_can_update_badge_description_in_collection_via_detail_api(self): self.client.force_authenticate(user=self.user) response = self.client.put( - '/v1/earner/collections/{}/badges/{}'.format(self.collection.entity_id, self.local_badge_instance_1.pk), - data={'id': 1, 'description': 'A cool badge.'}, format='json' + "/v1/earner/collections/{}/badges/{}".format( + self.collection.entity_id, self.local_badge_instance_1.pk + ), + data={"id": 1, "description": "A cool badge."}, + format="json", ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, {'id': self.local_badge_instance_1.pk, 'description': 'A cool badge.'}) + self.assertEqual( + response.data, + {"id": self.local_badge_instance_1.pk, "description": "A cool badge."}, + ) - obj = BackpackCollectionBadgeInstance.objects.get(collection=self.collection, instance_id=self.local_badge_instance_1.pk) - self.assertEqual(obj.description, 'A cool badge.') + obj = BackpackCollectionBadgeInstance.objects.get( + collection=self.collection, instance_id=self.local_badge_instance_1.pk + ) + self.assertEqual(obj.description, "A cool badge.") def test_badge_share_json(self): """ Legacy Badge Share pages should redirect to public pages """ - response = self.client.get('/share/badge/{}'.format(self.local_badge_instance_1.pk), **dict( - HTTP_ACCEPT="application/json" - )) + response = self.client.get( + "/share/badge/{}".format(self.local_badge_instance_1.pk), + **dict(HTTP_ACCEPT="application/json"), + ) self.assertEqual(response.status_code, 301) - self.assertEqual(response.get('Location', None), self.local_badge_instance_1.public_url) + self.assertEqual( + response.get("Location", None), self.local_badge_instance_1.public_url + ) def test_badge_share_html(self): """ Legacy Badge Share pages should redirect to public pages """ - response = self.client.get('/share/badge/{}'.format(self.local_badge_instance_1.entity_id), **dict( - HTTP_ACCEPT='text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8' - )) + response = self.client.get( + "/share/badge/{}".format(self.local_badge_instance_1.entity_id), + **dict( + HTTP_ACCEPT="text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" + ), + ) self.assertEqual(response.status_code, 301) - self.assertEqual(response.get('Location', None), self.local_badge_instance_1.public_url) + self.assertEqual( + response.get("Location", None), self.local_badge_instance_1.public_url + ) diff --git a/apps/backpack/tests/testfiles/0_5_basic_instance.json b/apps/backpack/tests/testfiles/0_5_basic_instance.json deleted file mode 100644 index c8c34e17c..000000000 --- a/apps/backpack/tests/testfiles/0_5_basic_instance.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "recipient": "test@example.com", - "badge": { - "version": "0.5.0", - "name": "Basic McBadge", - "image": "http://oldstyle.com/images/1", - "description": "A basic badge.", - "criteria": "http://oldsyle.com/criteria/1", - "issuer": { - "origin": "http://oldstyle.com", - "name": "Basic Issuer" - } - }, - "issued_on": "2011-12-31" -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/1_0_basic_badgeclass.json b/apps/backpack/tests/testfiles/1_0_basic_badgeclass.json deleted file mode 100644 index e416bc7f8..000000000 --- a/apps/backpack/tests/testfiles/1_0_basic_badgeclass.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "Basic Badge", - "description": "Basic as it gets. v1.0", - "image": "http://a.com/badgeclass_image", - "criteria": "http://a.com/badgeclass_criteria", - "issuer": "http://a.com/issuer" -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/1_0_basic_instance.json b/apps/backpack/tests/testfiles/1_0_basic_instance.json deleted file mode 100644 index 833adbb61..000000000 --- a/apps/backpack/tests/testfiles/1_0_basic_instance.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "uid":"123abc", - "recipient": {"identity": "test@example.com","hashed": false, "type": "email"}, - "badge": "http://a.com/badgeclass", - "issuedOn": "2015-04-30", - "verify": {"type": "hosted", "url": "http://a.com/instance"} -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/1_0_basic_instance2.json b/apps/backpack/tests/testfiles/1_0_basic_instance2.json deleted file mode 100644 index 5eac99cb7..000000000 --- a/apps/backpack/tests/testfiles/1_0_basic_instance2.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "uid":"123abd", - "recipient": {"identity": "test@example.com","hashed": false, "type": "email"}, - "badge": "http://a.com/badgeclass", - "issuedOn": "2015-04-30", - "verify": {"type": "hosted", "url": "http://a.com/instance2"} -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/1_0_basic_instance3.json b/apps/backpack/tests/testfiles/1_0_basic_instance3.json deleted file mode 100644 index 2ff7360d5..000000000 --- a/apps/backpack/tests/testfiles/1_0_basic_instance3.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "uid":"123abd", - "recipient": {"identity": "TEST@example.com","hashed": false, "type": "email"}, - "badge": "http://a.com/badgeclass", - "issuedOn": "2015-04-30", - "verify": {"type": "hosted", "url": "http://a.com/instance3"} -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/1_0_basic_instance_missing_badge_prop.json b/apps/backpack/tests/testfiles/1_0_basic_instance_missing_badge_prop.json deleted file mode 100644 index a534616fa..000000000 --- a/apps/backpack/tests/testfiles/1_0_basic_instance_missing_badge_prop.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "uid":"123abc", - "recipient": {"identity": "test@example.com","hashed": false, "type": "email"}, - "issuedOn": "2015-04-30", - "verify": {"type": "hosted", "url": "http://a.com/instance"} -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/1_0_basic_instance_with_bad_date.json b/apps/backpack/tests/testfiles/1_0_basic_instance_with_bad_date.json deleted file mode 100644 index c2554b8bd..000000000 --- a/apps/backpack/tests/testfiles/1_0_basic_instance_with_bad_date.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "uid":"123abd", - "recipient": {"identity": "test@example.com","hashed": false, "type": "email"}, - "badge": "http://a.com/badgeclass", - "issuedOn": "2015-04-31", - "verify": {"type": "hosted", "url": "http://a.com/instancebaddate"} -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/1_0_basic_issuer.json b/apps/backpack/tests/testfiles/1_0_basic_issuer.json deleted file mode 100644 index 15fa69ecc..000000000 --- a/apps/backpack/tests/testfiles/1_0_basic_issuer.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "Basic Issuer", - "url": "http://a.com/issuer/website" -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/1_0_basic_issuer_invalid_json.json b/apps/backpack/tests/testfiles/1_0_basic_issuer_invalid_json.json deleted file mode 100644 index 04cc2b85f..000000000 --- a/apps/backpack/tests/testfiles/1_0_basic_issuer_invalid_json.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "Basic Issuer", - "url": "http://a.com/issuer/website" - "EXTRA PROPERTY, NO VALUE NO COMMA. SO INVALID!" -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/2_0_assertion_embedded_badgeclass.json b/apps/backpack/tests/testfiles/2_0_assertion_embedded_badgeclass.json deleted file mode 100644 index da1ed441a..000000000 --- a/apps/backpack/tests/testfiles/2_0_assertion_embedded_badgeclass.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "@context": "https://w3id.org/openbadges/v2", - "type": "Assertion", - "id": "http://a.com/assertion-embedded1", - "badge": { - "type": "BadgeClass", - "id": "urn:uuid:8b4efa06-41bc-4f53-a80d-e3d5e063c936", - "name": "Embedded badgeclass", - "description": "bespoke embedded badgeclass", - "criteria": { - "narrative": "do it" - }, - "image": "http://a.com/badgeclass_image", - "issuer": { - "type": "Issuer", - "name": "Basic Issuer", - "url": "http://a.com/issuer/website" - } - }, - "verify": { - "url": "http://a.com/assertion-embedded1", - "type": "hosted" - }, - "issuedOn": "2017-06-29T21:50:14+00:00", - "recipient": { - "type": "email", - "hashed": false, - "identity": "test@example.com" - } -} \ No newline at end of file diff --git a/apps/backpack/tests/testfiles/bad_image.png b/apps/backpack/tests/testfiles/bad_image.png deleted file mode 100644 index a6411cb08..000000000 --- a/apps/backpack/tests/testfiles/bad_image.png +++ /dev/null @@ -1,4 +0,0 @@ -{ - "some":"bad", - "stuff": "to-leak" -} diff --git a/apps/backpack/tests/testfiles/baked_image.png b/apps/backpack/tests/testfiles/baked_image.png deleted file mode 100644 index 427ef482b..000000000 Binary files a/apps/backpack/tests/testfiles/baked_image.png and /dev/null differ diff --git a/apps/backpack/tests/testfiles/unbaked_image.png b/apps/backpack/tests/testfiles/unbaked_image.png deleted file mode 100644 index 1c573221d..000000000 Binary files a/apps/backpack/tests/testfiles/unbaked_image.png and /dev/null differ diff --git a/apps/backpack/tests/testfiles/v1_context.json b/apps/backpack/tests/testfiles/v1_context.json deleted file mode 100644 index a97f3964b..000000000 --- a/apps/backpack/tests/testfiles/v1_context.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "@context": [ - { - "id": "@id", - "type": "@type", - - "ob": "https://w3id.org/openbadges#", - "dc": "http://purl.org/dc/terms/", - "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - "rdfs": "http://www.w3.org/2000/01/rdf-schema#", - "sec": "https://w3id.org/security#", - "schema": "http://schema.org/", - "xsd": "http://www.w3.org/2001/XMLSchema#", - - "about": {"@id": "schema:about", "@type": "@id"}, - "alignment": {"@id": "ob:alignment", "@type": "@id"}, - "badge": {"@id": "ob:badge", "@type": "@id"}, - "badgeOffer": {"@id": "ob:badgeOffer", "@type": "@id"}, - "badgeTemplate": {"@id": "ob:badgeTemplate", "@type": "@id"}, - "criteria": {"@id": "ob:criteria", "@type": "@id"}, - "evidence": {"@id": "ob:evidence", "@type": "@id"}, - "issued": {"@id": "ob:issued", "@type": "xsd:dateTime"}, - "issuer": {"@id": "ob:issuer", "@type": "@id"}, - "recipient": {"@id": "ob:recipient", "@type": "@id"}, - "recipientEmail": "ob:recipientEmail", - "recipientPassword": "ob:recipientPassword", - "tag": "ob:tag", - "Identity": "ob:Identity", - "Badge": "ob:Badge", - "BadgeOffer": "ob:BadgeOffer", - "BadgeTemplate": "ob:BadgeTemplate", - - "address": {"@id": "schema:address", "@type": "@id"}, - "addressCountry": "schema:addressCountry", - "addressLocality": "schema:addressLocality", - "addressRegion": "schema:addressRegion", - "comment": "rdfs:comment", - "created": {"@id": "dc:created", "@type": "xsd:dateTime"}, - "creator": {"@id": "dc:creator", "@type": "@id"}, - "description": "schema:description", - "email": "schema:email", - "familyName": "schema:familyName", - "givenName": "schema:givenName", - "image": {"@id": "schema:image", "@type": "@id"}, - "label": "rdfs:label", - "name": "schema:name", - "postalCode": "schema:postalCode", - "streetAddress": "schema:streetAddress", - "title": "dc:title", - "url": {"@id": "schema:url", "@type": "@id"}, - "PostalAddress": "schema:PostalAddress", - - "identityService": {"@id": "https://w3id.org/identity#identityService", "@type": "@id"}, - - "credential": {"@id": "sec:credential", "@type": "@id"}, - "cipherAlgorithm": "sec:cipherAlgorithm", - "cipherData": "sec:cipherData", - "cipherKey": "sec:cipherKey", - "claim": {"@id": "sec:claim", "@type": "@id"}, - "digestAlgorithm": "sec:digestAlgorithm", - "digestValue": "sec:digestValue", - "domain": "sec:domain", - "expires": {"@id": "sec:expiration", "@type": "xsd:dateTime"}, - "initializationVector": "sec:initializationVector", - "nonce": "sec:nonce", - "normalizationAlgorithm": "sec:normalizationAlgorithm", - "owner": {"@id": "sec:owner", "@type": "@id"}, - "password": "sec:password", - "privateKey": {"@id": "sec:privateKey", "@type": "@id"}, - "privateKeyPem": "sec:privateKeyPem", - "publicKey": {"@id": "sec:publicKey", "@type": "@id"}, - "publicKeyPem": "sec:publicKeyPem", - "publicKeyService": {"@id": "sec:publicKeyService", "@type": "@id"}, - "revoked": {"@id": "sec:revoked", "@type": "xsd:dateTime"}, - "signature": "sec:signature", - "signatureAlgorithm": "sec:signatureAlgorithm", - "signatureValue": "sec:signatureValue", - "EncryptedMessage": "sec:EncryptedMessage", - "CryptographicKey": "sec:Key", - "GraphSignature2012": "sec:GraphSignature2012" - }, - { - "id": "@id", - "type": "@type", - - "obi": "https://w3id.org/openbadges#", - "extensions": "https://w3id.org/openbadges/extensions#", - "validation": "obi:validation", - - "xsd": "http://www.w3.org/2001/XMLSchema#", - "schema": "http://schema.org/", - "sec": "https://w3id.org/security#", - - "Assertion": "obi:Assertion", - "BadgeClass": "obi:BadgeClass", - "Issuer": "obi:Issuer", - "IssuerOrg": "obi:Issuer", - "Extension": "obi:Extension", - "hosted": "obi:HostedBadge", - "signed": "obi:SignedBadge", - "TypeValidation": "obi:TypeValidation", - "FrameValidation": "obi:FrameValidation", - - "name": { "@id": "schema:name" }, - "description": { "@id": "schema:description" }, - "url": { "@id": "schema:url", "@type": "@id" }, - "image": { "@id": "schema:image", "@type": "@id" }, - - "uid": { "@id": "obi:uid" }, - "recipient": { "@id": "obi:recipient", "@type": "@id" }, - "hashed": { "@id": "obi:hashed", "@type": "xsd:boolean" }, - "salt": { "@id": "obi:salt" }, - "identity": { "@id": "obi:identityHash" }, - "issuedOn": { "@id": "obi:issueDate", "@type": "xsd:dateTime" }, - "expires": { "@id": "sec:expiration", "@type": "xsd:dateTime" }, - "evidence": { "@id": "obi:evidence", "@type": "@id" }, - "verify": { "@id": "obi:verify", "@type": "@id" }, - - "badge": { "@id": "obi:badge", "@type": "@id" }, - "criteria": { "@id": "obi:criteria", "@type": "@id" }, - "tags": { "@id": "schema:keywords" }, - "alignment": { "@id": "obi:alignment", "@type": "@id" }, - - "issuer": { "@id": "obi:issuer", "@type": "@id" }, - "email": "schema:email", - "revocationList": { "@id": "obi:revocationList", "@type": "@id" }, - - "validatesType": "obi:validatesType", - "validationSchema": "obi:validationSchema", - "validationFrame": "obi:validationFrame" - }], - -"validation": [ - { - "type": "TypeValidation", - "validatesType": "Assertion", - "validationSchema": "https://openbadgespec.org/v1/schema/assertion.json" - }, - { - "type": "TypeValidation", - "validatesType": "BadgeClass", - "validationSchema": "https://openbadgespec.org/v1/schema/badgeclass.json" - }, - { - "type": "TypeValidation", - "validatesType": "Issuer", - "validationSchema": "https://openbadgespec.org/v1/schema/issuer.json" - }, - { - "type": "TypeValidation", - "validatesType": "Extension", - "validationSchema": "https://openbadgespec.org/v1/schema/extension.json" - } - ] -} diff --git a/apps/backpack/tests/utils.py b/apps/backpack/tests/utils.py deleted file mode 100644 index 913bb4be4..000000000 --- a/apps/backpack/tests/utils.py +++ /dev/null @@ -1,85 +0,0 @@ -import os -import responses - -CURRENT_DIRECTORY = os.path.dirname(__file__) - - -def setup_basic_1_0(**kwargs): - if not kwargs or not 'http://a.com/instance' in kwargs.get('exclude', []): - responses.add( - responses.GET, 'http://a.com/instance', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/1_0_basic_instance.json'), 'r').read(), - status=200, content_type='application/json' - ) - if not kwargs or not 'http://a.com/badgeclass' in kwargs.get('exclude', []): - responses.add( - responses.GET, 'http://a.com/badgeclass', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/1_0_basic_badgeclass.json'), 'r').read(), - status=200, content_type='application/json' - ) - if not kwargs or not 'http://a.com/issuer' in kwargs.get('exclude', []): - responses.add( - responses.GET, 'http://a.com/issuer', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/1_0_basic_issuer.json'), 'r').read(), - status=200, content_type='application/json' - ) - if not kwargs or not 'http://a.com/badgeclass_image' in kwargs.get('exclude', []): - responses.add( - responses.GET, 'http://a.com/badgeclass_image', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/unbaked_image.png'), 'rb').read(), - status=200, content_type='image/png' - ) - -def setup_basic_1_0_bad_image(**kwargs): - if not kwargs or not 'http://a.com/instance' in kwargs.get('exclude', []): - responses.add( - responses.GET, 'http://a.com/instance', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/1_0_basic_instance.json'), 'r').read(), - status=200, content_type='application/json' - ) - if not kwargs or not 'http://a.com/badgeclass' in kwargs.get('exclude', []): - responses.add( - responses.GET, 'http://a.com/badgeclass', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/1_0_basic_badgeclass.json'), 'r').read(), - status=200, content_type='application/json' - ) - if not kwargs or not 'http://a.com/issuer' in kwargs.get('exclude', []): - responses.add( - responses.GET, 'http://a.com/issuer', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/1_0_basic_issuer.json'), 'r').read(), - status=200, content_type='application/json' - ) - if not kwargs or not 'http://a.com/badgeclass_image' in kwargs.get('exclude', []): - responses.add( - responses.GET, 'http://a.com/badgeclass_image', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/bad_image.png'), 'rb').read(), - status=200, content_type='image/png' - ) - -def setup_resources(resources): - for item in resources: - response_body = item.get('response_body') - if response_body is None: - mode = item['mode'] if 'mode' in item else 'r' - response_body = open(os.path.join(CURRENT_DIRECTORY, 'testfiles', item['filename']), mode).read() - responses.add( - responses.GET, item['url'], - body=response_body, - status=item.get('status', 200), - content_type=item.get('content_type', 'application/json') - - ) - - -def setup_basic_0_5_0(**kwargs): - responses.add( - responses.GET, 'http://oldstyle.com/instance', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/0_5_basic_instance.json'), 'r').read(), - status=200, content_type='application/json' - ) - if not kwargs or not 'http://oldstyle.com/images/1' in kwargs.get('exclude'): - responses.add( - responses.GET, 'http://oldstyle.com/images/1', - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/unbaked_image.png'), 'rb').read(), - status=200, content_type='image/png' - ) diff --git a/apps/backpack/utils.py b/apps/backpack/utils.py new file mode 100644 index 000000000..45e637dd3 --- /dev/null +++ b/apps/backpack/utils.py @@ -0,0 +1,41 @@ +import json +from urllib.parse import urlparse + +from django.conf import settings + +from apps.mainsite.views import call_aiskills_api + +# pulls esco competencies from badge assertions and enhances them with +# tree structure breadrcumbs using the AI Tool APIs +def get_skills_tree(badge_instances, language): + skill_studyloads = {} + for instance in badge_instances: + if len(instance.badgeclass.cached_extensions()) > 0: + for extension in instance.badgeclass.cached_extensions(): + if extension.name == "extensions:CompetencyExtension": + extension_json = json.loads(extension.original_json) + for competency in extension_json: + if competency["framework_identifier"]: + esco_uri = competency["framework_identifier"] + parsed_uri = urlparse(esco_uri) + uri_path = parsed_uri.path + studyload = competency["studyLoad"] + try: + skill_studyloads[uri_path] += studyload + except KeyError: + skill_studyloads[uri_path] = studyload + + if not len(skill_studyloads.keys()) > 0: + return { "skills": [] } + + # get esco trees from ai skills api + endpoint = getattr(settings, "AISKILLS_ENDPOINT_TREE") + payload = {"concept_uris": list(skill_studyloads.keys()), "lang": language} + tree_json = call_aiskills_api(endpoint, "POST", payload) + tree = json.loads(tree_json.content.decode()) + + # extend with our studyloads + for skill in tree["skills"]: + skill["studyLoad"] = skill_studyloads[skill["concept_uri"]] + + return tree diff --git a/apps/backpack/v1_api_urls.py b/apps/backpack/v1_api_urls.py index baafea206..6233eeb85 100644 --- a/apps/backpack/v1_api_urls.py +++ b/apps/backpack/v1_api_urls.py @@ -1,24 +1,92 @@ -from django.conf.urls import url +from django.urls import re_path -from backpack.api import BackpackAssertionList, BackpackAssertionDetail, BackpackAssertionDetailImage, \ - BackpackCollectionList, BackpackCollectionDetail, ShareBackpackAssertion, ShareBackpackCollection -from backpack.api_v1 import CollectionLocalBadgeInstanceList, CollectionLocalBadgeInstanceDetail, \ - CollectionGenerateShare +from backpack.api import ( + BackpackAssertionList, + BackpackAssertionDetail, + BackpackAssertionDetailImage, + BackpackCollectionList, + BackpackCollectionDetail, + BackpackSkillList, + ImportedBadgeInstanceDetail, + ImportedBadgeInstanceList, + ShareBackpackAssertion, + ShareBackpackCollection, +) +from backpack.api_v1 import ( + CollectionLocalBadgeInstanceList, + CollectionLocalBadgeInstanceDetail, + CollectionGenerateShare, +) -urlpatterns = [ - - url(r'^badges$', BackpackAssertionList.as_view(), name='v1_api_localbadgeinstance_list'), - url(r'^badges/(?P[^/]+)$', BackpackAssertionDetail.as_view(), name='v1_api_localbadgeinstance_detail'), - url(r'^badges/(?P[^/]+)/image$', BackpackAssertionDetailImage.as_view(), name='v1_api_localbadgeinstance_image'), - - url(r'^collections$', BackpackCollectionList.as_view(), name='v1_api_collection_list'), - url(r'^collections/(?P[-\w]+)$', BackpackCollectionDetail.as_view(), name='v1_api_collection_detail'), +from backpack.views import collectionPdf, pdf +urlpatterns = [ + re_path( + r"^badges$", + BackpackAssertionList.as_view(), + name="v1_api_localbadgeinstance_list", + ), + re_path( + r"^badges/(?P[^/]+)$", + BackpackAssertionDetail.as_view(), + name="v1_api_localbadgeinstance_detail", + ), + re_path( + r"^badges/(?P[^/]+)/image$", + BackpackAssertionDetailImage.as_view(), + name="v1_api_localbadgeinstance_image", + ), + re_path(r"^skills$", BackpackSkillList.as_view(), name="v1_api_skills_list"), + re_path( + r"^collections$", + BackpackCollectionList.as_view(), + name="v1_api_collection_list", + ), + re_path( + r"^collections/(?P[-\w]+)$", + BackpackCollectionDetail.as_view(), + name="v1_api_collection_detail", + ), # legacy v1 endpoints - url(r'^collections/(?P[-\w]+)/badges$', CollectionLocalBadgeInstanceList.as_view(), name='v1_api_collection_badges'), - url(r'^collections/(?P[-\w]+)/badges/(?P[^/]+)$', CollectionLocalBadgeInstanceDetail.as_view(), name='v1_api_collection_localbadgeinstance_detail'), - url(r'^collections/(?P[-\w]+)/share$', CollectionGenerateShare.as_view(), name='v1_api_collection_generate_share'), - - url(r'^share/badge/(?P[^/]+)$', ShareBackpackAssertion.as_view(), name='v1_api_analytics_share_badge'), - url(r'^share/collection/(?P[^/]+)$', ShareBackpackCollection.as_view(), name='v1_api_analytics_share_collection'), + re_path( + r"^collections/(?P[-\w]+)/badges$", + CollectionLocalBadgeInstanceList.as_view(), + name="v1_api_collection_badges", + ), + re_path( + r"^collections/(?P[-\w]+)/badges/(?P[^/]+)$", + CollectionLocalBadgeInstanceDetail.as_view(), + name="v1_api_collection_localbadgeinstance_detail", + ), + re_path( + r"^collections/(?P[-\w]+)/share$", + CollectionGenerateShare.as_view(), + name="v1_api_collection_generate_share", + ), + re_path( + r"^share/badge/(?P[^/]+)$", + ShareBackpackAssertion.as_view(), + name="v1_api_analytics_share_badge", + ), + re_path( + r"^share/collection/(?P[^/]+)$", + ShareBackpackCollection.as_view(), + name="v1_api_analytics_share_collection", + ), + re_path(r"^badges/pdf/(?P[^/]+)$", pdf, name="generate-pdf"), + re_path( + r"^collections/pdf/(?P[^/]+)$", + collectionPdf, + name="generate-collection-pdf", + ), + re_path( + r"^imported-badges$", + ImportedBadgeInstanceList.as_view(), + name="v1_api_importedbadge_list", + ), + re_path( + r"^imported-badges/(?P[^/]+)$", + ImportedBadgeInstanceDetail.as_view(), + name="v1_api_importedbadge_detail", + ), ] diff --git a/apps/backpack/v2_api_urls.py b/apps/backpack/v2_api_urls.py index 7b2a2b552..d9898c0da 100644 --- a/apps/backpack/v2_api_urls.py +++ b/apps/backpack/v2_api_urls.py @@ -1,24 +1,62 @@ # encoding: utf-8 -from django.conf.urls import url - -from backpack.api import BackpackAssertionList, BackpackAssertionDetail, BackpackCollectionList, \ - BackpackCollectionDetail, BackpackAssertionDetailImage, BackpackImportBadge, ShareBackpackCollection, \ - ShareBackpackAssertion, BadgesFromUser +from django.urls import re_path + +from backpack.api import ( + BackpackAssertionList, + BackpackAssertionDetail, + BackpackCollectionList, + BackpackCollectionDetail, + BackpackAssertionDetailImage, + BackpackImportBadge, + ShareBackpackCollection, + ShareBackpackAssertion, + BadgesFromUser, +) urlpatterns = [ - url(r'^import$', BackpackImportBadge.as_view(), name='v2_api_backpack_import_badge'), - - url(r'^assertions$', BackpackAssertionList.as_view(), name='v2_api_backpack_assertion_list'), - url(r'^assertions/(?P[^/]+)$', BackpackAssertionDetail.as_view(), name='v2_api_backpack_assertion_detail'), - url(r'^assertions/(?P[^/]+)/image$', BackpackAssertionDetailImage.as_view(), name='v2_api_backpack_assertion_detail_image'), - - url(r'^collections$', BackpackCollectionList.as_view(), name='v2_api_backpack_collection_list'), - url(r'^collections/(?P[^/]+)$', BackpackCollectionDetail.as_view(), name='v2_api_backpack_collection_detail'), - - url(r'^share/assertion/(?P[^/]+)$', ShareBackpackAssertion.as_view(), name='v2_api_share_assertion'), - url(r'^share/collection/(?P[^/]+)$', ShareBackpackCollection.as_view(), name='v2_api_share_collection'), - - url(r'^(?P[^/]+)$', BadgesFromUser().as_view(), name='v2_api_badges_from_user'), -] \ No newline at end of file + re_path( + r"^import$", BackpackImportBadge.as_view(), name="v2_api_backpack_import_badge" + ), + re_path( + r"^assertions$", + BackpackAssertionList.as_view(), + name="v2_api_backpack_assertion_list", + ), + re_path( + r"^assertions/(?P[^/]+)$", + BackpackAssertionDetail.as_view(), + name="v2_api_backpack_assertion_detail", + ), + re_path( + r"^assertions/(?P[^/]+)/image$", + BackpackAssertionDetailImage.as_view(), + name="v2_api_backpack_assertion_detail_image", + ), + re_path( + r"^collections$", + BackpackCollectionList.as_view(), + name="v2_api_backpack_collection_list", + ), + re_path( + r"^collections/(?P[^/]+)$", + BackpackCollectionDetail.as_view(), + name="v2_api_backpack_collection_detail", + ), + re_path( + r"^share/assertion/(?P[^/]+)$", + ShareBackpackAssertion.as_view(), + name="v2_api_share_assertion", + ), + re_path( + r"^share/collection/(?P[^/]+)$", + ShareBackpackCollection.as_view(), + name="v2_api_share_collection", + ), + re_path( + r"^(?P[^/]+)$", + BadgesFromUser().as_view(), + name="v2_api_badges_from_user", + ), +] diff --git a/apps/backpack/v3_api_urls.py b/apps/backpack/v3_api_urls.py new file mode 100644 index 000000000..b56d88c4c --- /dev/null +++ b/apps/backpack/v3_api_urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework import routers + +from . import api_v3 + +router = routers.DefaultRouter() +router.register(r"badges", api_v3.Badges, basename="badges") +router.register(r"learningpaths", api_v3.LearningPaths, basename="learningpaths") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/backpack/views.py b/apps/backpack/views.py index b25282273..cb3c05783 100644 --- a/apps/backpack/views.py +++ b/apps/backpack/views.py @@ -1,21 +1,140 @@ +from backpack.models import BackpackCollection +from django.http import Http404, HttpResponse from django.urls import reverse -from django.http import Http404 from django.views.generic import RedirectView - -from backpack.models import BackpackCollection -from issuer.models import BadgeInstance +from issuer.models import BadgeClass, BadgeInstance +from mainsite.badge_pdf import BadgePDFCreator +from mainsite.collection_pdf import CollectionPDFCreator +from rest_framework.authentication import ( + BasicAuthentication, + SessionAuthentication, + TokenAuthentication, +) +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, +) +from rest_framework.permissions import IsAuthenticated +from drf_spectacular.utils import ( + extend_schema, + OpenApiParameter, + OpenApiResponse, +) +from drf_spectacular.types import OpenApiTypes + + +@extend_schema( + summary="Download Badge PDF", + description=( + "Returns a PDF version of a badge instance. " + "The user must be the badge recipient or issuer owner." + ), + parameters=[ + OpenApiParameter( + name="slug", + type=str, + location=OpenApiParameter.PATH, + description="Entity ID of the badge instance.", + ), + ], + responses={ + 200: OpenApiResponse( + description="PDF file", + response=OpenApiTypes.BINARY, + ), + 404: OpenApiResponse(description="Badge not found"), + 403: OpenApiResponse(description="Permission denied"), + }, +) +@api_view(["GET"]) +@authentication_classes( + [TokenAuthentication, SessionAuthentication, BasicAuthentication] +) +@permission_classes([IsAuthenticated]) +def pdf(request, *args, **kwargs): + slug = kwargs["slug"] + try: + badgeinstance = BadgeInstance.objects.get(entity_id=slug) + + # Get emails of all issuer owners + """ issuer= Issuer.objects.get(entity_id=badgeinstance.issuer.entity_id) + issuer_owners = issuer.staff.filter(issuerstaff__role=IssuerStaff.ROLE_OWNER) + issuer_owners_emails = list(map(attrgetter('primary_email'), issuer_owners)) """ + + # User must be the recipient or an issuer staff with OWNER role + # TODO: Check other recipient types + # Temporary commented out + """ if request.user.email != badgeinstance.recipient_identifier and + request.user.email not in issuer_owners_emails: + raise PermissionDenied """ + except BadgeInstance.DoesNotExist: + raise Http404 + try: + badgeclass = BadgeClass.objects.get( + entity_id=badgeinstance.badgeclass.entity_id + ) + except BadgeClass.DoesNotExist: + raise Http404 + + pdf_creator = BadgePDFCreator() + pdf_content = pdf_creator.generate_pdf( + badgeinstance, badgeclass, origin=request.META.get("HTTP_ORIGIN") + ) + return HttpResponse(pdf_content, content_type="application/pdf") + + +@extend_schema( + summary="Download Badge PDF", + description=("Returns a PDF version of a collection. "), + parameters=[ + OpenApiParameter( + name="slug", + type=str, + location=OpenApiParameter.PATH, + description="Entity ID of the badge collection.", + ), + ], + responses={ + 200: OpenApiResponse( + description="PDF file", + response=OpenApiTypes.BINARY, + ), + 404: OpenApiResponse(description="Collection not found"), + 403: OpenApiResponse(description="Permission denied"), + }, +) +@api_view(["GET"]) +@authentication_classes( + [TokenAuthentication, SessionAuthentication, BasicAuthentication] +) +@permission_classes([IsAuthenticated]) +def collectionPdf(request, *args, **kwargs): + slug = kwargs["slug"] + try: + collection = BackpackCollection.objects.get(entity_id=slug) + except BackpackCollection.DoesNotExist: + raise Http404 + + pdf_creator = CollectionPDFCreator() + pdf_content = pdf_creator.generate_pdf( + collection, origin=request.META.get("HTTP_ORIGIN") + ) + return HttpResponse(pdf_content, content_type="application/pdf") class RedirectSharedCollectionView(RedirectView): permanent = True def get_redirect_url(self, *args, **kwargs): - share_hash = kwargs.get('share_hash', None) + share_hash = kwargs.get("share_hash", None) if not share_hash: raise Http404 try: - collection = BackpackCollection.cached.get_by_slug_or_entity_id_or_id(share_hash) + collection = BackpackCollection.cached.get_by_slug_or_entity_id_or_id( + share_hash + ) except BackpackCollection.DoesNotExist: raise Http404 return collection.public_url @@ -25,8 +144,8 @@ class LegacyCollectionShareRedirectView(RedirectView): permanent = True def get_redirect_url(self, *args, **kwargs): - new_pattern_name = self.request.resolver_match.url_name.replace('legacy_','') - kwargs.pop('pk') + new_pattern_name = self.request.resolver_match.url_name.replace("legacy_", "") + kwargs.pop("pk") url = reverse(new_pattern_name, args=args, kwargs=kwargs) return url @@ -36,12 +155,14 @@ class LegacyBadgeShareRedirectView(RedirectView): def get_redirect_url(self, *args, **kwargs): badgeinstance = None - share_hash = kwargs.get('share_hash', None) + share_hash = kwargs.get("share_hash", None) if not share_hash: raise Http404 try: - badgeinstance = BadgeInstance.cached.get_by_slug_or_entity_id_or_id(share_hash) + badgeinstance = BadgeInstance.cached.get_by_slug_or_entity_id_or_id( + share_hash + ) except BadgeInstance.DoesNotExist: pass @@ -56,4 +177,3 @@ def get_redirect_url(self, *args, **kwargs): raise Http404 return badgeinstance.public_url - diff --git a/apps/badgeuser/__init__.py b/apps/badgeuser/__init__.py index f126c63c6..39837fbe9 100644 --- a/apps/badgeuser/__init__.py +++ b/apps/badgeuser/__init__.py @@ -1 +1 @@ -default_app_config = 'badgeuser.apps.BadgeUserConfig' +default_app_config = "badgeuser.apps.BadgeUserConfig" diff --git a/apps/badgeuser/account_forms.py b/apps/badgeuser/account_forms.py index 1d8474b44..130e74c33 100644 --- a/apps/badgeuser/account_forms.py +++ b/apps/badgeuser/account_forms.py @@ -5,6 +5,7 @@ class AddEmailForm(allauth_forms.AddEmailForm): email = forms.EmailField( - label="email", required=True, - widget=forms.TextInput(attrs={"type": "email", "size": "30"}) + label="email", + required=True, + widget=forms.TextInput(attrs={"type": "email", "size": "30"}), ) diff --git a/apps/badgeuser/admin.py b/apps/badgeuser/admin.py index 380d3f0aa..e5fceb82c 100644 --- a/apps/badgeuser/admin.py +++ b/apps/badgeuser/admin.py @@ -1,75 +1,142 @@ +from django.db import models from django.contrib.admin import ModelAdmin, TabularInline from django.core.cache import cache from django.utils import timezone from django.utils.html import format_html from django_object_actions import DjangoObjectActions -from externaltools.models import ExternalToolUserActivation from mainsite.admin import badgr_admin from mainsite.utils import backoff_cache_key -from .models import (BadgeUser, EmailAddressVariant, TermsVersion, TermsAgreement, CachedEmailAddress, - UserRecipientIdentifier) - - -class ExternalToolInline(TabularInline): - model = ExternalToolUserActivation - fk_name = 'user' - fields = ('externaltool',) - extra = 0 +from .models import ( + BadgeUser, + EmailAddressVariant, + TermsVersion, + TermsAgreement, + CachedEmailAddress, + UserPreference, + UserRecipientIdentifier, +) +from issuer.models import Issuer class TermsAgreementInline(TabularInline): model = TermsAgreement - fk_name = 'user' + fk_name = "user" extra = 0 max_num = 0 can_delete = False - readonly_fields = ('created_at', 'terms_version') - fields = ('created_at', 'terms_version') + readonly_fields = ("created_at", "terms_version") + fields = ("created_at", "terms_version") class EmailAddressInline(TabularInline): model = CachedEmailAddress - fk_name = 'user' + fk_name = "user" extra = 0 - fields = ('email','verified','primary') + fields = ("email", "verified", "primary") class UserRecipientIdentifierInline(TabularInline): model = UserRecipientIdentifier - fk_name = 'user' + fk_name = "user" extra = 0 - fields = ('type', 'identifier', 'verified') + fields = ("type", "identifier", "verified") class BadgeUserAdmin(DjangoObjectActions, ModelAdmin): - readonly_fields = ('entity_id', 'date_joined', 'last_login', 'username', 'entity_id', 'agreed_terms_version', - 'login_backoff', 'has_usable_password',) - list_display = ('email', 'first_name', 'last_name', 'is_active', 'is_staff', 'entity_id', 'date_joined') - list_filter = ('is_active', 'is_staff', 'is_superuser', 'date_joined', 'last_login') - search_fields = ('email', 'first_name', 'last_name', 'username', 'entity_id') + readonly_fields = ( + "entity_id", + "date_joined", + "last_login", + "username", + "entity_id", + "agreed_terms_version", + "login_backoff", + "has_usable_password", + ) + list_display = ( + "email", + "first_name", + "last_name", + "is_active", + "is_staff", + "zip_code", + "issuers", + "assertion_count", + "date_joined", + ) + list_filter = ("is_active", "is_staff", "is_superuser", "date_joined", "last_login") + search_fields = ( + "email", + "first_name", + "last_name", + "username", + "entity_id", + "zip_code", + ) fieldsets = ( - ('Metadata', {'fields': ('entity_id', 'username', 'date_joined',), 'classes': ('collapse',)}), - (None, {'fields': ('email', 'first_name', 'last_name', 'badgrapp', 'agreed_terms_version', 'marketing_opt_in')}), - ('Access', {'fields': ('is_active', 'is_staff', 'is_superuser', 'has_usable_password', 'password', 'login_backoff')}), - ('Permissions', {'fields': ('groups', 'user_permissions')}), + ( + "Metadata", + { + "fields": ( + "entity_id", + "username", + "date_joined", + ), + "classes": ("collapse",), + }, + ), + ( + None, + { + "fields": ( + "email", + "first_name", + "last_name", + "badgrapp", + "zip_code", + "agreed_terms_version", + "marketing_opt_in", + "secure_password_set", + ) + }, + ), + ( + "Access", + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "has_usable_password", + "password", + "login_backoff", + ) + }, + ), + ("Permissions", {"fields": ("groups", "user_permissions")}), ) inlines = [ EmailAddressInline, UserRecipientIdentifierInline, - ExternalToolInline, TermsAgreementInline, ] - change_actions = [ - 'clear_login_backoff' - ] + change_actions = ["clear_login_backoff"] + + def get_queryset(self, request): + qs = super(BadgeUserAdmin, self).get_queryset(request) + qs = qs.annotate(number_of_assertions=models.Count("badgeinstance")) + return qs def clear_login_backoff(self, request, obj): for email in obj.all_verified_recipient_identifiers: cache_key = backoff_cache_key(email) cache.delete(cache_key) + clear_login_backoff.label = "Clear login backoffs" - clear_login_backoff.short_description = "Remove blocks created by failed login attempts" + clear_login_backoff.short_description = ( + "Remove blocks created by failed login attempts" + ) def login_backoff(self, obj): blocks = [] @@ -77,52 +144,107 @@ def login_backoff(self, obj): cache_key = backoff_cache_key(email) backoff = cache.get(cache_key) if backoff is not None: - blocks += ["{email} - {ip}: {until} ({count} attempts)".format( - email=email, ip=key, - until=backoff[key].get('until').astimezone(timezone.get_current_timezone()).strftime("%Y-%m-%d %H:%M:%S"), - count=backoff[key].get('count') - ) for key in backoff.keys()] + blocks += [ + "{email} - {ip}: {until} ({count} attempts)".format( + email=email, + ip=key, + until=backoff[key] + .get("until") + .astimezone(timezone.get_current_timezone()) + .strftime("%Y-%m-%d %H:%M:%S"), + count=backoff[key].get("count"), + ) + for key in backoff.keys() + ] if len(blocks): return format_html("
  • {}
".format("
  • ".join(blocks))) return "None" + login_backoff.allow_tags = True + def assertion_count(self, obj): + return obj.number_of_assertions + + assertion_count.admin_order_field = "number_of_assertions" + + def issuers(self, obj): + issuers = Issuer.objects.filter(staff=obj) + return ",".join([x.name for x in issuers]) + + badgr_admin.register(BadgeUser, BadgeUserAdmin) class EmailAddressVariantAdmin(ModelAdmin): - search_fields = ('canonical_email', 'email',) - list_display = ('email', 'canonical_email',) - raw_id_fields = ('canonical_email',) + search_fields = ( + "canonical_email", + "email", + ) + list_display = ( + "email", + "canonical_email", + ) + raw_id_fields = ("canonical_email",) + badgr_admin.register(EmailAddressVariant, EmailAddressVariantAdmin) class TermsVersionAdmin(ModelAdmin): - list_display = ('version','created_at','is_active') - readonly_fields = ('created_at','created_by','updated_at','updated_by', 'latest_terms_version') + list_display = ("version", "created_at", "is_active") + readonly_fields = ( + "created_at", + "created_by", + "updated_at", + "updated_by", + "latest_terms_version", + ) fieldsets = ( - ('Metadata', { - 'fields': ('created_at','created_by','updated_at','updated_by'), - 'classes': ('collapse',) - }), - (None, {'fields': ( - 'latest_terms_version', 'is_active','version','short_description', - )}) + ( + "Metadata", + { + "fields": ("created_at", "created_by", "updated_at", "updated_by"), + "classes": ("collapse",), + }, + ), + ( + None, + { + "fields": ( + "latest_terms_version", + "is_active", + "version", + "short_description", + ) + }, + ), ) def latest_terms_version(self, obj): return TermsVersion.cached.latest_version() + latest_terms_version.short_description = "Current Terms Version" + badgr_admin.register(TermsVersion, TermsVersionAdmin) class RecipientIdentifierAdmin(ModelAdmin): - list_display = ('type', 'identifier', 'user', 'verified') - list_filter = ('type', 'verified',) - search_fields = ('identifier', 'user__email') - raw_id_fields = ('user',) + list_display = ("type", "identifier", "user", "verified") + list_filter = ( + "type", + "verified", + ) + search_fields = ("identifier", "user__email") + raw_id_fields = ("user",) badgr_admin.register(UserRecipientIdentifier, RecipientIdentifierAdmin) + + +class UserPreferencesAdmin(ModelAdmin): + list_display = ("user",) + raw_id_fields = ("user",) + + +badgr_admin.register(UserPreference, UserPreferencesAdmin) diff --git a/apps/badgeuser/api.py b/apps/badgeuser/api.py index f0e10325f..87320fbe5 100644 --- a/apps/badgeuser/api.py +++ b/apps/badgeuser/api.py @@ -1,15 +1,16 @@ import datetime import json import re -import urllib.request, urllib.parse, urllib.error import urllib.parse +from jsonschema import ValidationError + from allauth.account.adapter import get_adapter from allauth.account.models import EmailConfirmationHMAC from allauth.account.utils import user_pk_to_url_str, url_str_to_user_pk -from apispec_drf.decorators import (apispec_get_operation, apispec_put_operation, apispec_post_operation, apispec_operation, - apispec_delete_operation, apispec_list_operation,) -from django.conf import settings +from drf_spectacular.utils import ( + extend_schema, +) from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password from django.contrib.auth.tokens import default_token_generator @@ -20,31 +21,64 @@ from django.http import Http404 from django.utils import timezone from django.views.generic import RedirectView +from django.conf import settings +from apps.badgeuser.utils import notify_on_password_change +from issuer.models import ( + Issuer, + IssuerStaff, + IssuerStaffRequest, + LearningPath, + LearningPathBadge, + NetworkInvite, +) +from issuer.serializers_v1 import IssuerStaffRequestSerializer, LearningPathSerializerV1 from rest_framework import permissions, serializers, status from rest_framework.exceptions import ValidationError as RestframeworkValidationError from rest_framework.response import Response from rest_framework.serializers import BaseSerializer -from rest_framework.status import (HTTP_302_FOUND, HTTP_200_OK, HTTP_404_NOT_FOUND, HTTP_201_CREATED, - HTTP_400_BAD_REQUEST,) - -from badgeuser.authcode import authcode_for_accesstoken, decrypt_authcode +from rest_framework.status import ( + HTTP_302_FOUND, + HTTP_200_OK, + HTTP_404_NOT_FOUND, + HTTP_201_CREATED, + HTTP_400_BAD_REQUEST, +) +from oauth2_provider.models import get_application_model + +from badgeuser.authcode import decrypt_authcode from badgeuser.models import BadgeUser, CachedEmailAddress, TermsVersion from badgeuser.permissions import BadgeUserIsAuthenticatedUser -from badgeuser.serializers_v1 import BadgeUserProfileSerializerV1, BadgeUserTokenSerializerV1 -from badgeuser.serializers_v2 import (BadgeUserTokenSerializerV2, BadgeUserSerializerV2, AccessTokenSerializerV2, - TermsVersionSerializerV2,) -from badgeuser.tasks import process_email_verification, process_post_recipient_id_verification_change +from badgeuser.serializers_v1 import ( + BadgeUserProfileSerializerV1, + BadgeUserTokenSerializerV1, + EmailSerializerV1, +) +from badgeuser.serializers_v2 import ( + BadgeUserTokenSerializerV2, + BadgeUserSerializerV2, + AccessTokenSerializerV2, + TermsVersionSerializerV2, +) +from badgeuser.tasks import process_email_verification from badgrsocialauth.utils import redirect_to_frontend_error_toast -import badgrlog from entity.api import BaseEntityDetailView, BaseEntityListView from entity.serializers import BaseSerializerV2 from issuer.permissions import BadgrOAuthTokenHasScope -from mainsite.models import BadgrApp, AccessTokenProxy -from mainsite.utils import backoff_cache_key, OriginSetting, set_url_query_params, throttleable +from mainsite.models import BadgrApp, AccessTokenProxy, ApplicationInfo +from mainsite.utils import ( + backoff_cache_key, + OriginSetting, + set_url_query_params, + throttleable, + verifyIssuerAutomatically, +) +from mainsite.serializers import ApplicationInfoSerializer -RATE_LIMIT_DELTA = datetime.timedelta(minutes=5) +import logging + +logger = logging.getLogger("Badgr.Events") -logger = badgrlog.BadgrLogger() +RATE_LIMIT_DELTA = datetime.timedelta(minutes=5) class BadgeUserDetail(BaseEntityDetailView): @@ -56,53 +90,98 @@ class BadgeUserDetail(BaseEntityDetailView): "post": ["*"], "get": ["r:profile", "rw:profile"], "put": ["rw:profile"], + "delete": ["rw:profile"], } - @apispec_post_operation('BadgeUser', - summary="Post a single BadgeUser profile", - description="Make an account", - tags=['BadgeUsers'] - ) + @extend_schema( + summary="Post a single BadgeUser profile", + description="Make an account", + tags=["BadgeUsers"], + ) @throttleable def post(self, request, **kwargs): """ Signup for a new account """ - if request.version == 'v1': + if request.version == "v1": + # email = request.data.get("email") + # TODO: investigate how we can use this to improve the spam filter + # only send email domain to spamfilter API to protect users privacy + # _, email_domain = email.split("@", 1) + # firstname = request.data.get("first_name") + # lastname = request.data.get("last_name") + + # apiKey = getattr(settings, "ALTCHA_API_KEY") + # endpoint = getattr(settings, "ALTCHA_SPAMFILTER_ENDPOINT") + # payload = { + # "text": [firstname, lastname], + # the following options seem to classify too much data as spam, i commented them out for now + # "email": email_domain, + # "expectedLanguages": ["en", "de"], + # } + # params = {"apiKey": apiKey} + # headers = { + # "Content-Type": "application/json", + # "referer": getattr(settings, "HTTP_ORIGIN"), + # } + # response = requests.post( + # endpoint, params=params, data=json.dumps(payload), headers=headers + # ) + # if response.status_code == 200: + # data = response.json() + # classification = data["classification"] + # if classification == "BAD": + # # TODO: show reasons why data was classified as spam + # return JsonResponse( + # { + # "error": "Spam filter detected spam. Your account was not created." + # }, + # status=status.HTTP_403_FORBIDDEN, + # ) + serializer_cls = self.get_serializer_class() + captcha = request.data.get("captcha") serializer = serializer_cls( - data=request.data, context={'request': request} + data=request.data, context={"request": request, "captcha": captcha} ) serializer.is_valid(raise_exception=True) try: - new_user = serializer.save() + serializer.save() except DjangoValidationError as e: raise RestframeworkValidationError(e.message) return Response(serializer.data, status=HTTP_201_CREATED) return Response(status=HTTP_404_NOT_FOUND) - @apispec_get_operation('BadgeUser', - summary="Get a single BadgeUser profile", - description="Use the entityId 'self' to retrieve the authenticated user's profile", - tags=['BadgeUsers'] - ) + @extend_schema( + summary="Get a single BadgeUser profile", + description="Use the entityId 'self' to retrieve the authenticated user's profile", + tags=["BadgeUsers"], + ) def get(self, request, **kwargs): return super(BadgeUserDetail, self).get(request, **kwargs) - @apispec_put_operation('BadgeUser', - summary="Update a BadgeUser", - description="Use the entityId 'self' to update the authenticated user's profile", - tags=['BadgeUsers'] - ) + @extend_schema( + summary="Update a BadgeUser", + description="Use the entityId 'self' to update the authenticated user's profile", + tags=["BadgeUsers"], + ) def put(self, request, **kwargs): return super(BadgeUserDetail, self).put(request, allow_partial=True, **kwargs) + @extend_schema( + summary="Delete a BadgeUser", + description="Use the entityId 'self' to delete the authenticated user's profile", + tags=["BadgeUsers"], + ) + def delete(self, request, **kwargs): + return super(BadgeUserDetail, self).delete(request, **kwargs) + def get_object(self, request, **kwargs): - version = getattr(request, 'version', 'v1') - if version == 'v2': - entity_id = kwargs.get('entity_id') - if entity_id == 'self': + version = getattr(request, "version", "v1") + if version == "v2": + entity_id = kwargs.get("entity_id") + if entity_id == "self": self.object = request.user return self.object try: @@ -111,7 +190,7 @@ def get_object(self, request, **kwargs): pass else: return self.object - elif version == 'v1': + elif version == "v1": if request.user.is_authenticated: self.object = request.user return self.object @@ -119,12 +198,11 @@ def get_object(self, request, **kwargs): def has_object_permissions(self, request, obj): method = request.method.lower() - if method == 'post': + if method == "post": return True if isinstance(obj, BadgeUser): - - if method == 'get': + if method == "get": if request.user.id == obj.id: # always have access to your own user return True @@ -132,14 +210,14 @@ def has_object_permissions(self, request, obj): # you can see some info about users you know about return True - if method == 'put': + if method == "put" or method == "delete": # only current user can update their own profile return request.user.id == obj.id return False def get_context_data(self, **kwargs): context = super(BadgeUserDetail, self).get_context_data(**kwargs) - context['isSelf'] = (self.object.id == self.request.user.id) + context["isSelf"] = self.object.id == self.request.user.id return context @@ -173,7 +251,7 @@ def put(self, request, **kwargs): def get_context_data(self, **kwargs): context = super(BadgeUserToken, self).get_context_data(**kwargs) - context['tokenReplaced'] = getattr(self, 'token_replaced', False) + context["tokenReplaced"] = getattr(self, "token_replaced", False) return context @@ -194,6 +272,7 @@ def get_response(self, obj={}, status=HTTP_200_OK): class BadgeUserForgotPassword(BaseUserRecoveryView): + schema = None authentication_classes = () permission_classes = (permissions.AllowAny,) v1_serializer_class = serializers.Serializer @@ -201,7 +280,7 @@ class BadgeUserForgotPassword(BaseUserRecoveryView): def get(self, request, *args, **kwargs): badgr_app = None - badgrapp_id = self.request.GET.get('a') + badgrapp_id = self.request.GET.get("a") if badgrapp_id: try: badgr_app = BadgrApp.objects.get(id=badgrapp_id) @@ -211,33 +290,33 @@ def get(self, request, *args, **kwargs): badgr_app = BadgrApp.objects.get_current(request) redirect_url = badgr_app.forgot_password_redirect - token = request.GET.get('token', '') + token = request.GET.get("token", "") tokenized_url = "{}{}".format(redirect_url, token) - return Response(status=HTTP_302_FOUND, headers={'Location': tokenized_url}) + return Response(status=HTTP_302_FOUND, headers={"Location": tokenized_url}) - @apispec_operation( - summary="Request an account recovery email", - tags=["Authentication"], - parameters=[ - { - "in": "body", - "name": "body", - "required": True, - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email", - "description": "The email address on file to send recovery email to" - } - } - }, - } - ] - ) + # @apispec_operation( + # summary="Request an account recovery email", + # tags=["Authentication"], + # parameters=[ + # { + # "in": "body", + # "name": "body", + # "required": True, + # "schema": { + # "type": "object", + # "properties": { + # "email": { + # "type": "string", + # "format": "email", + # "description": "The email address on file to send recovery email to", + # } + # }, + # }, + # } + # ], + # ) def post(self, request, **kwargs): - email = request.data.get('email') + email = request.data.get("email") try: email_address = CachedEmailAddress.cached.get(email=email) except CachedEmailAddress.DoesNotExist: @@ -257,9 +336,11 @@ def post(self, request, **kwargs): send_email = True if not send_email: - return Response("Forgot password request limit exceeded. Please check your" - + " inbox for an existing message or wait to retry.", - status=status.HTTP_429_TOO_MANY_REQUESTS) + return Response( + "Forgot password request limit exceeded. Please check your" + + " inbox for an existing message or wait to retry.", + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) email_address.set_last_forgot_password_sent_time(datetime.datetime.now()) @@ -275,61 +356,61 @@ def post(self, request, **kwargs): return self.get_response() temp_key = default_token_generator.make_token(user) - token = "{uidb36}-{key}".format(uidb36=user_pk_to_url_str(user), - key=temp_key) + token = "{uidb36}-{key}".format(uidb36=user_pk_to_url_str(user), key=temp_key) badgrapp = BadgrApp.objects.get_current(request=request) - api_path = reverse('{version}_api_auth_forgot_password'.format(version=request.version)) + api_path = reverse( + "{version}_api_auth_forgot_password".format(version=request.version) + ) reset_url = "{origin}{path}?token={token}&a={badgrapp}".format( - origin=OriginSetting.HTTP, - path=api_path, - token=token, - badgrapp=badgrapp.id + origin=OriginSetting.HTTP, path=api_path, token=token, badgrapp=badgrapp.id ) email_context = { "site": get_current_site(request), "user": user, "password_reset_url": reset_url, - 'badgr_app': badgrapp, + "badgr_app": badgrapp, } - get_adapter().send_mail('account/email/password_reset_key', email, email_context) + get_adapter().send_mail( + "account/email/password_reset_key", email, email_context + ) return self.get_response() - @apispec_operation( - summary="Recover an account and set a new password", - tags=["Authentication"], - parameters=[ - { - "in": "body", - "name": "body", - "required": True, - "schema": { - "type": "object", - "properties": { - "token": { - "type": "string", - "format": "string", - "description": "The token recieved in the recovery email", - 'required': True - }, - "password": { - 'type': "string", - 'description': "The new password to use", - 'required': True - } - } - }, - } - ] - ) + # @apispec_operation( + # summary="Recover an account and set a new password", + # tags=["Authentication"], + # parameters=[ + # { + # "in": "body", + # "name": "body", + # "required": True, + # "schema": { + # "type": "object", + # "properties": { + # "token": { + # "type": "string", + # "format": "string", + # "description": "The token recieved in the recovery email", + # "required": True, + # }, + # "password": { + # "type": "string", + # "description": "The new password to use", + # "required": True, + # }, + # }, + # }, + # } + # ], + # ) def put(self, request, **kwargs): - token = request.data.get('token') - password = request.data.get('password') + token = request.data.get("token") + password = request.data.get("password") - matches = re.search(r'([0-9A-Za-z]+)-(.*)', token) + matches = re.search(r"([0-9A-Za-z]+)-(.*)", token) if not matches: return Response(status=HTTP_404_NOT_FOUND) uidb36 = matches.group(1) @@ -353,10 +434,57 @@ def put(self, request, **kwargs): user.set_password(password) user.save() + notify_on_password_change(user) + return self.get_response() -class BadgeUserEmailConfirm(BaseUserRecoveryView): +class BaseRedirectView: + """ + A base view for handling conditional redirects with flexible configuration. + """ + + def _prepare_redirect(self, request, badgrapp, intended_redirect): + """ + Prepare redirect URL and response based on authentication status. + + :param request: HTTP request object + :param badgrapp: BadgrApp instance + :param intended_redirect: The target redirect path + :return: Response object with appropriate redirect + """ + # Prepare frontend base URL + frontend_base_url = badgrapp.cors.rstrip("/") if badgrapp.cors else "" + if frontend_base_url and not frontend_base_url.startswith( + ("http://", "https://") + ): + frontend_base_url = f"https://{frontend_base_url}" + + # If user is authenticated, redirect to the intended page + if request.user.is_authenticated: + detail_url = f"{frontend_base_url}{intended_redirect}" + return Response(status=HTTP_302_FOUND, headers={"Location": detail_url}) + + # If not authenticated, prepare redirect to login + redirect_url = badgrapp.ui_login_redirect.rstrip("/") + response = Response(status=HTTP_302_FOUND, headers={"Location": redirect_url}) + + # Set cookie for intended redirect + response.set_cookie( + "intended_redirect", + intended_redirect, + max_age=3600, # 1 hour + httponly=True, + secure=settings.SECURE_SSL_REDIRECT, + samesite="Lax", + domain=badgrapp.cors.split("://")[-1] if badgrapp.cors else None, + ) + + return response + + +class BadgeUserEmailConfirm(BaseUserRecoveryView, BaseRedirectView): + schema = None permission_classes = (permissions.AllowAny,) v1_serializer_class = BaseSerializer v2_serializer_class = BaseSerializerV2 @@ -372,72 +500,104 @@ def get(self, request, **kwargs): description: The token received in the recovery email required: true """ - token = request.query_params.get('token', '') - badgrapp_id = request.query_params.get('a') + token = request.query_params.get("token", "") + badgrapp_id = request.query_params.get("a") # Get BadgrApp instance badgrapp = BadgrApp.objects.get_by_id_or_default(badgrapp_id) # Get EmailConfirmation instance - emailconfirmation = EmailConfirmationHMAC.from_key(kwargs.get('confirm_id')) + emailconfirmation = EmailConfirmationHMAC.from_key(kwargs.get("confirm_id")) if emailconfirmation is None: - logger.event(badgrlog.NoEmailConfirmation()) - return redirect_to_frontend_error_toast(request, - "Your email confirmation link is invalid. Please attempt to " - "create an account with this email address, again.") # 202 + logger.warning( + "No email confirmation found for confirm id '%s'", + kwargs.get("confirm_id"), + ) + return redirect_to_frontend_error_toast( + request, + "Your email confirmation link is invalid. Please attempt to " + "create an account with this email address, again.", + ) # 202 # Get EmailAddress instance else: try: email_address = CachedEmailAddress.cached.get( - pk=emailconfirmation.email_address.pk) + pk=emailconfirmation.email_address.pk + ) except CachedEmailAddress.DoesNotExist: - logger.event(badgrlog.NoEmailConfirmationEmailAddress( - request, email_address=emailconfirmation.email_address)) - return redirect_to_frontend_error_toast(request, - "Your email confirmation link is invalid. Please attempt " - "to create an account with this email address, again.") # 202 + logger.warning( + "No email confirmation email address found for confirm id '%s'", + kwargs.get("confirm_id"), + ) + return redirect_to_frontend_error_toast( + request, + "Your email confirmation link is invalid. Please attempt " + "to create an account with this email address, again.", + ) # 202 if email_address.verified: - logger.event(badgrlog.EmailConfirmationAlreadyVerified( - request, email_address=email_address, token=token)) - return redirect_to_frontend_error_toast(request, - "Your email address is already verified. You may now log in.") + logger.info( + "Email address for confirm id '%s' was already verified", + kwargs.get("confirm_id"), + ) + return redirect_to_frontend_error_toast( + request, "Your email address is already verified. You may now log in." + ) # Validate 'token' syntax from query param - matches = re.search(r'([0-9A-Za-z]+)-(.*)', token) + matches = re.search(r"([0-9A-Za-z]+)-(.*)", token) if not matches: - logger.event(badgrlog.InvalidEmailConfirmationToken( - request, token=token, email_address=email_address)) + logger.warning( + "The token '%s' for the email confirmation id '%s' is invalid", + token, + kwargs.get("confirm_id"), + ) email_address.send_confirmation(request=request, signup=False) - return redirect_to_frontend_error_toast(request, - "Your email confirmation token is invalid. You have been sent " - "a new link. Please check your email and try again.") # 2 + return redirect_to_frontend_error_toast( + request, + "Your email confirmation token is invalid. You have been sent " + "a new link. Please check your email and try again.", + ) # 2 uidb36 = matches.group(1) key = matches.group(2) if not (uidb36 and key): - logger.event(badgrlog.InvalidEmailConfirmationToken( - request, token=token, email_address=email_address)) + logger.warning( + "The token '%s' for the email confirmation id '%s' is invalid", + token, + kwargs.get("confirm_id"), + ) email_address.send_confirmation(request=request, signup=False) - return redirect_to_frontend_error_toast(request, - "Your email confirmation token is invalid. You have been sent " - "a new link. Please check your email and try again.") # 2 + return redirect_to_frontend_error_toast( + request, + "Your email confirmation token is invalid. You have been sent " + "a new link. Please check your email and try again.", + ) # 2 # Get User instance from literal 'token' value user = self._get_user(uidb36) if user is None or not default_token_generator.check_token(user, key): - logger.event(badgrlog.EmailConfirmationTokenExpired( - request, email_address=email_address)) + logger.warning( + "The confirmation link for the confirm id '%s' has expired or is invalid", + kwargs.get("confirm_id"), + ) email_address.send_confirmation(request=request, signup=False) - return redirect_to_frontend_error_toast(request, - "Your authorization link has expired. You have been sent a new " - "link. Please check your email and try again.") + return redirect_to_frontend_error_toast( + request, + "Your authorization link has expired. You have been sent a new " + "link. Please check your email and try again.", + ) if email_address.user != user: - logger.event(badgrlog.OtherUsersEmailConfirmationToken( - request, email_address=email_address, token=token, other_user=user)) - return redirect_to_frontend_error_toast(request, - "Your email confirmation token is associated with an unexpected " - "user. You may try again") + logger.warning( + "The email confirmation token '%s' of confirm id '%s' belongs to another user", + token, + kwargs.get("confirm_id"), + ) + return redirect_to_frontend_error_toast( + request, + "Your email confirmation token is associated with an unexpected " + "user. You may try again", + ) # Perform main operation, set EmaiAddress .verified and .primary True old_primary = CachedEmailAddress.objects.get_primary(user) @@ -446,24 +606,40 @@ def get(self, request, **kwargs): email_address.verified = True email_address.save() + # check whether the user has unverified institutions that should be verified with the new email + for issuer in user.cached_issuers(): + if not issuer.verified: + if verifyIssuerAutomatically(issuer.url, str(email_address)): + issuer.verified = True + issuer.save() + process_email_verification.delay(email_address.pk) # Create an OAuth AccessTokenProxy instance for this user - accesstoken = AccessTokenProxy.objects.generate_new_token_for_user( - user, - application=badgrapp.oauth_application if badgrapp.oauth_application_id else None, - scope='rw:backpack rw:profile rw:issuer') - - redirect_url = get_adapter().get_email_confirmation_redirect_url( - request, badgr_app=badgrapp) - - if badgrapp.use_auth_code_exchange: - authcode = authcode_for_accesstoken(accesstoken) - redirect_url = set_url_query_params(redirect_url, authCode=authcode) - else: - redirect_url = set_url_query_params(redirect_url, authToken=accesstoken.token) + # accesstoken = AccessTokenProxy.objects.generate_new_token_for_user( + # user, + # application=( + # badgrapp.oauth_application if badgrapp.oauth_application_id else None + # ), + # scope="rw:backpack rw:profile rw:issuer", + # ) + + redirect_url = badgrapp.ui_login_redirect.rstrip("/") + + response = Response(status=HTTP_302_FOUND, headers={"Location": redirect_url}) + + intended_redirect = "/auth/welcome" + + response.set_cookie( + "intended_redirect", + intended_redirect, + max_age=3600, + httponly=True, + secure=request.is_secure(), + samesite="Lax", + ) - return Response(status=HTTP_302_FOUND, headers={'Location': redirect_url}) + return response class BadgeUserAccountConfirm(RedirectView): @@ -474,76 +650,123 @@ def error_redirect_url(self): self.badgrapp = BadgrApp.objects.get_by_id_or_default() return set_url_query_params( - self.badgrapp.ui_login_redirect.rstrip('/'), - authError='Error validating request.' + self.badgrapp.ui_login_redirect.rstrip("/"), + authError="Error validating request.", ) def get_redirect_url(self, *args, **kwargs): - authcode = kwargs.get('authcode', None) + authcode = kwargs.get("authcode", None) if not authcode: return self.error_redirect_url() user_info = decrypt_authcode(authcode) try: user_info = json.loads(user_info) - except (TypeError, ValueError,): + except ( + TypeError, + ValueError, + ): user_info = None if not user_info: return self.error_redirect_url() - badgrapp_id = user_info.get('badgrapp_id', None) + badgrapp_id = user_info.get("badgrapp_id", None) self.badgrapp = BadgrApp.objects.get_by_id_or_default(badgrapp_id) try: - email_address = CachedEmailAddress.cached.get(email=user_info.get('email')) + email_address = CachedEmailAddress.cached.get(email=user_info.get("email")) except CachedEmailAddress.DoesNotExist: return self.error_redirect_url() user = email_address.user - user.first_name = user_info.get('first_name', user.first_name) - user.last_name = user_info.get('last_name', user.last_name) + user.first_name = user_info.get("first_name", user.first_name) + user.last_name = user_info.get("last_name", user.last_name) user.badgrapp = self.badgrapp - user.marketing_opt_in = user_info.get('marketing_opt_in', user.marketing_opt_in) + user.marketing_opt_in = user_info.get("marketing_opt_in", user.marketing_opt_in) user.agreed_terms_version = TermsVersion.cached.latest_version() user.email_verified = True - if user_info.get('plaintext_password'): - user.set_password(user_info['plaintext_password']) + if user_info.get("plaintext_password"): + user.set_password(user_info["plaintext_password"]) user.save() redirect_url = urllib.parse.urljoin( - self.badgrapp.email_confirmation_redirect.rstrip('/') + '/', - urllib.parse.quote(user.first_name.encode('utf8')) + self.badgrapp.email_confirmation_redirect.rstrip("/") + "/", + urllib.parse.quote(user.first_name.encode("utf8")), + ) + redirect_url = set_url_query_params( + redirect_url, email=email_address.email.encode("utf8") ) - redirect_url = set_url_query_params(redirect_url, email=email_address.email.encode('utf8')) return redirect_url class AccessTokenList(BaseEntityListView): model = AccessTokenProxy + serializer_class = AccessTokenSerializerV2 v2_serializer_class = AccessTokenSerializerV2 permission_classes = (permissions.IsAuthenticated, BadgrOAuthTokenHasScope) - valid_scopes = ['rw:profile'] + valid_scopes = ["rw:profile"] def get_objects(self, request, **kwargs): - return AccessTokenProxy.objects.filter(user=request.user, expires__gt=timezone.now()) + return AccessTokenProxy.objects.filter( + user=request.user, expires__gt=timezone.now() + ) - @apispec_list_operation('AccessToken', - summary='Get a list of access tokens for authenticated user', - tags=['Authentication'] - ) + @extend_schema( + summary="Get a list of access tokens for authenticated user", + tags=["Authentication"], + ) def get(self, request, **kwargs): return super(AccessTokenList, self).get(request, **kwargs) +class ApplicationList(BaseEntityListView): + model = get_application_model() + serializer_class = ApplicationInfoSerializer + v2_serializer_class = ApplicationInfoSerializer + permission_classes = (permissions.IsAuthenticated, BadgrOAuthTokenHasScope) + valid_scopes = ["rw:profile"] + + def get_objects(self, request, **kwargs): + return ApplicationInfo.objects.filter(application__user=request.user) + + @extend_schema( + summary="Get a list of application registered for the authenticated user", + tags=["Authentication"], + ) + def get(self, request, **kwargs): + return super(ApplicationList, self).get(request, **kwargs) + + +class ApplicationDetails(BaseEntityDetailView): + model = ApplicationInfo + serializer_class = ApplicationInfoSerializer + v2_serializer_class = ApplicationInfoSerializer + permission_classes = (permissions.IsAuthenticated, BadgrOAuthTokenHasScope) + valid_scopes = ["rw:profile"] + + @extend_schema( + summary="Delete one registed set of access tokens", tags=["Authentication"] + ) + def delete(self, request, application_id, **kwargs): + model = get_application_model() + + obj = model.objects.filter(client_id=application_id, user=request.user) + obj.delete() + return Response(status=204) + + class AccessTokenDetail(BaseEntityDetailView): model = AccessTokenProxy + serializer_class = AccessTokenSerializerV2 v2_serializer_class = AccessTokenSerializerV2 permission_classes = (permissions.IsAuthenticated, BadgrOAuthTokenHasScope) - valid_scopes = ['rw:profile'] + valid_scopes = ["rw:profile"] def get_object(self, request, **kwargs): try: - self.object = AccessTokenProxy.objects.get_from_entity_id(kwargs.get('entity_id')) + self.object = AccessTokenProxy.objects.get_from_entity_id( + kwargs.get("entity_id") + ) except AccessTokenProxy.DoesNotExist: raise Http404 @@ -551,17 +774,11 @@ def get_object(self, request, **kwargs): raise Http404 return self.object - @apispec_get_operation('AccessToken', - summary='Get a single AccessToken', - tags=['Authentication'] - ) + @extend_schema(summary="Get a single AccessToken", tags=["Authentication"]) def get(self, request, **kwargs): return super(AccessTokenDetail, self).get(request, **kwargs) - @apispec_delete_operation('AccessToken', - summary='Revoke an AccessToken', - tags=['Authentication'] - ) + @extend_schema(summary="Revoke an AccessToken", tags=["Authentication"]) def delete(self, request, **kwargs): obj = self.get_object(request, **kwargs) if not self.has_object_permissions(request, obj): @@ -572,6 +789,7 @@ def delete(self, request, **kwargs): class LatestTermsVersionDetail(BaseEntityDetailView): model = TermsVersion + serializer_class = TermsVersionSerializerV2 v2_serializer_class = TermsVersionSerializerV2 permission_classes = (permissions.AllowAny,) @@ -580,4 +798,391 @@ def get_object(self, request, **kwargs): if latest: return latest - raise Http404("No TermsVersion has been defined. Please contact server administrator.") + raise Http404( + "No TermsVersion has been defined. Please contact server administrator." + ) + + +class BadgeUserResendEmailConfirmation(BaseUserRecoveryView): + permission_classes = (permissions.AllowAny,) + serializer_class = EmailSerializerV1 + v1_serializer_class = EmailSerializerV1 + + def put(self, request, **kwargs): + email = request.data.get("email") + + try: + email_address = CachedEmailAddress.cached.get(email=email) + except CachedEmailAddress.DoesNotExist: + # return 200 here because we don't want to expose information about which emails we know about + return self.get_response() + + if email_address.verified: + return Response( + {"Your email address is already confirmed. You can login."}, + status=status.HTTP_409_CONFLICT, + ) + else: + # email rate limiting + resend_confirmation = False + current_time = datetime.datetime.now() + last_request_time = email_address.get_last_verification_sent_time() + + if last_request_time is None: + email_address.set_last_verification_sent_time(datetime.datetime.now()) + resend_confirmation = True + else: + time_delta = current_time - last_request_time + if time_delta > RATE_LIMIT_DELTA: + resend_confirmation = True + + if resend_confirmation: + email_address.send_confirmation(signup=True) + email_address.set_last_verification_sent_time(datetime.datetime.now()) + else: + return Response( + "You have reached a limit for resending verification email. Please check your" + + " inbox for an existing message or retry after 5 minutes.", + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = EmailSerializerV1(email_address, context={"request": request}) + serialized = serializer.data + return Response(serialized, status=status.HTTP_200_OK) + + +class LearningPathList(BaseEntityListView): + """ + GET a list of learning paths for the authenticated user + """ + + model = LearningPath + permission_classes = permission_classes = ( + permissions.IsAuthenticated, + BadgrOAuthTokenHasScope, + ) + valid_scopes = ["rw:profile"] + v1_serializer_class = LearningPathSerializerV1 + + def get_objects(self, request, **kwargs): + badgeinstances = request.user.cached_badgeinstances().all() + badges = list( + { + badgeinstance.badgeclass + for badgeinstance in badgeinstances + if badgeinstance.revoked is False + } + ) + lp_badges = LearningPathBadge.objects.filter(badge__in=badges) + lps = LearningPath.objects.filter( + activated=True, learningpathbadge__in=lp_badges + ).distinct() + + return lps + + @extend_schema( + summary="Get a list of LearningPaths for authenticated user", + tags=["LearningPaths"], + ) + def get(self, request, **kwargs): + return super(LearningPathList, self).get(request, **kwargs) + + +class BaseRedirectView: + """ + A base view for handling conditional redirects with flexible configuration. + """ + + def _prepare_redirect(self, request, badgrapp, intended_redirect): + """ + Prepare redirect URL and response based on authentication status. + + :param request: HTTP request object + :param badgrapp: BadgrApp instance + :param intended_redirect: The target redirect path + :return: Response object with appropriate redirect + """ + # Prepare frontend base URL + frontend_base_url = badgrapp.cors.rstrip("/") if badgrapp.cors else "" + if frontend_base_url and not frontend_base_url.startswith( + ("http://", "https://") + ): + frontend_base_url = f"https://{frontend_base_url}" + + # If user is authenticated, redirect to the intended page + if request.user.is_authenticated: + detail_url = f"{frontend_base_url}{intended_redirect}" + return Response(status=HTTP_302_FOUND, headers={"Location": detail_url}) + + # If not authenticated, prepare redirect to login + redirect_url = badgrapp.ui_login_redirect.rstrip("/") + response = Response(status=HTTP_302_FOUND, headers={"Location": redirect_url}) + + # Set cookie for intended redirect + response.set_cookie( + "intended_redirect", + intended_redirect, + max_age=3600, # 1 hour + httponly=True, + secure=settings.SECURE_SSL_REDIRECT, + samesite="Lax", + domain=badgrapp.cors.split("://")[-1] if badgrapp.cors else None, + ) + + return response + + +class BadgeUserSaveMicroDegree(BaseEntityDetailView, BaseRedirectView): + schema = None + permission_classes = (permissions.AllowAny,) + v1_serializer_class = BaseSerializer + v2_serializer_class = BaseSerializerV2 + + def get(self, request, **kwargs): + """ + Redirect to the micro degree detail page after the user logs in + """ + badgrapp_id = request.query_params.get("a") + badgrapp = BadgrApp.objects.get_by_id_or_default(badgrapp_id) + + microdegree_id = kwargs.get("entity_id") + intended_redirect = f"/public/learningpaths/{microdegree_id}" + + return self._prepare_redirect(request, badgrapp, intended_redirect) + + +class BadgeUserCollectBadgesInBackpack(BaseEntityDetailView, BaseRedirectView): + schema = None + permission_classes = (permissions.AllowAny,) + v1_serializer_class = BaseSerializer + v2_serializer_class = BaseSerializerV2 + + def get(self, request, **kwargs): + """ + Redirect to the user's backpack page after the user logs in + """ + badgrapp_id = request.query_params.get("a") + badgrapp = BadgrApp.objects.get_by_id_or_default(badgrapp_id) + + intended_redirect = "/recipient/badges/" + + return self._prepare_redirect(request, badgrapp, intended_redirect) + + +@extend_schema(exclude=True) +class GetRedirectPath(BaseEntityDetailView): + permission_classes = (permissions.IsAuthenticated,) + + def post(self, request, **kwargs): + redirect_path = request.COOKIES.get("intended_redirect") + + response = Response( + {"success": True, "redirectPath": redirect_path or "/issuer"} + ) + + response.delete_cookie("intended_redirect") + + return response + + +class BadgeUserStaffRequestList(BaseEntityListView): + """List and create staff requests for the authenticated user""" + + model = IssuerStaffRequest + v1_serializer_class = IssuerStaffRequestSerializer + permission_classes = (permissions.IsAuthenticated,) + valid_scopes = { + "post": ["rw:profile"], + "get": ["r:profile", "rw:profile"], + } + + @extend_schema( + summary="Get my staff membership requests", + description="Get all staff requests created by the authenticated user", + tags=["BadgeUser StaffRequests"], + ) + def get(self, request, issuer_id=None, **kwargs): + return super().get(request, **kwargs) + + def get_objects(self, request, issuer_id=None, **kwargs): + queryset = IssuerStaffRequest.objects.filter( + user=request.user, + revoked=False, + ) + + if issuer_id: + queryset = queryset.filter(issuer__entity_id=issuer_id) + + return queryset + + @extend_schema( + summary="Create a new issuer staff request", + description="Request membership to an institution's staff", + tags=["BadgeUser StaffRequests"], + ) + def post(self, request, issuer_id, **kwargs): + try: + issuer = Issuer.objects.get(entity_id=issuer_id) + except Issuer.DoesNotExist: + return Response( + {"response": "Issuer not found"}, status=status.HTTP_404_NOT_FOUND + ) + + existing_request = IssuerStaffRequest.objects.filter( + issuer=issuer, + user=request.user, + status__in=[ + IssuerStaffRequest.Status.PENDING, + ], + ).first() + + if existing_request: + return Response( + { + "response": "FÃŧr diese Institution liegt noch eine offene Anfrage von dir vor!" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + for member in issuer.cached_issuerstaff(): + if request.user == member.cached_user: + return Response( + {"response": "Du bist bereits Teil dieser Institution!"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + staff_request = IssuerStaffRequest.objects.create( + issuer=issuer, + user=request.user, + status=IssuerStaffRequest.Status.PENDING, + revoked=False, + ) + except Exception as e: + return Response({"response": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + serializer = self.get_serializer_class()( + staff_request, context={"request": request} + ) + + email_context = { + "site": get_current_site(request), + "user": request.user, + "issuer": issuer, + "activate_url": OriginSetting.HTTP + + reverse( + "v1_api_user_confirm_staffrequest", + kwargs={"entity_id": issuer.entity_id}, + ), + "call_to_action_label": "Anfrage bestätigen", + } + + for member in issuer.cached_issuerstaff(): + if member.role == IssuerStaff.ROLE_OWNER: + email = member.cached_user.email + get_adapter().send_mail( + "account/email/email_staff_request", email, email_context + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class BadgeUserStaffRequestDetail(BaseEntityDetailView): + """View and manage user's own staff requests""" + + model = IssuerStaffRequest + v1_serializer_class = IssuerStaffRequestSerializer + permission_classes = (permissions.IsAuthenticated,) + valid_scopes = ["rw:profile"] + + def get_object(self, request, **kwargs): + try: + self.object = IssuerStaffRequest.objects.filter( + entity_id=kwargs.get("request_id"), + ).first() + except IssuerStaffRequest.DoesNotExist: + raise Http404 + + if not self.has_object_permissions(request, self.object): + raise Http404 + return self.object + + @extend_schema( + summary="Get a specific staff request", + description="Get details of a staff request created by the authenticated user", + tags=["BadgeUser StaffRequests"], + ) + def get(self, request, **kwargs): + staff_request = self.get_object(request, **kwargs) + if not self.has_object_permissions(request, staff_request): + return Response(status=status.HTTP_404_NOT_FOUND) + + serializer = self.get_serializer_class()( + staff_request, context={"request": request} + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Revoke a request for an issuer membership", + description="Revoke your own pending staff membership request", + tags=["BadgeUser StaffRequests"], + ) + def delete(self, request, **kwargs): + staff_request = self.get_object(request, **kwargs) + if not self.has_object_permissions(request, staff_request): + return Response(status=HTTP_404_NOT_FOUND) + + try: + staff_request.revoke() + except DjangoValidationError as e: + raise ValidationError(e.message) + + serializer = self.get_serializer_class()( + staff_request, context={"request": request} + ) + + return Response(status=HTTP_200_OK, data=serializer.data) + + +class BadgeUserConfirmStaffRequest(BaseEntityDetailView, BaseRedirectView): + schema = None + permission_classes = (permissions.AllowAny,) + v1_serializer_class = BaseSerializer + v2_serializer_class = BaseSerializerV2 + + def get(self, request, **kwargs): + """ + Redirect to the issuer staff member page after the user logs in + """ + badgrapp_id = request.query_params.get("a") + badgrapp = BadgrApp.objects.get_by_id_or_default(badgrapp_id) + + issuer_id = kwargs.get("entity_id") + intended_redirect = f"/issuer/issuers/{issuer_id}/staff" + + return self._prepare_redirect(request, badgrapp, intended_redirect) + + +class ConfirmNetworkInvitation(BaseEntityDetailView, BaseRedirectView): + schema = None + permission_classes = (permissions.AllowAny,) + v1_serializer_class = BaseSerializer + v2_serializer_class = BaseSerializerV2 + + def get(self, request, **kwargs): + """ + Redirect to frontend to confirm network invitation + """ + badgrapp_id = request.query_params.get("a") + badgrapp = BadgrApp.objects.get_by_id_or_default(badgrapp_id) + inviteSlug = kwargs.get("inviteSlug") + try: + invitation = NetworkInvite.objects.get(entity_id=inviteSlug) + except NetworkInvite.DoesNotExist: + pass + if invitation.status == invitation.Status.APPROVED: + intended_redirect = f"/issuer/networks/invite/{inviteSlug}?confirmed=true" + else: + intended_redirect = f"/issuer/networks/invite/{inviteSlug}" + + return self._prepare_redirect(request, badgrapp, intended_redirect) diff --git a/apps/badgeuser/api_v1.py b/apps/badgeuser/api_v1.py index 8cda3193f..c22bc625c 100644 --- a/apps/badgeuser/api_v1.py +++ b/apps/badgeuser/api_v1.py @@ -3,8 +3,12 @@ import datetime -from apispec_drf.decorators import apispec_list_operation, apispec_operation, \ - apispec_get_operation, apispec_delete_operation, apispec_put_operation +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + OpenApiParameter, + OpenApiExample, +) from rest_framework import permissions, status from rest_framework.response import Response from rest_framework.views import APIView @@ -15,34 +19,39 @@ RATE_LIMIT_DELTA = datetime.timedelta(minutes=5) + +@extend_schema_view( + get=extend_schema( + summary="Get a list of user's registered emails", + tags=["BadgeUsers"], + responses=EmailSerializerV1(many=True), + ), + post=extend_schema( + summary="Register a new unverified email", + tags=["BadgeUsers"], + request=EmailSerializerV1, # drf-spectacular infers "email" field + responses=EmailSerializerV1, + examples=[ + OpenApiExample( + "Example request", + value={"email": "user@example.com"}, + ) + ], + ), +) class BadgeUserEmailList(APIView): permission_classes = (permissions.IsAuthenticated,) - @apispec_list_operation('BadgeUserEmail', - summary="Get a list of user's registered emails", - tags=['BadgeUsers'] - ) def get(self, request, **kwargs): instances = request.user.cached_emails() - serializer = EmailSerializerV1(instances, many=True, context={'request': request}) + serializer = EmailSerializerV1( + instances, many=True, context={"request": request} + ) return Response(serializer.data) - @apispec_operation( - summary="Register a new unverified email", - tags=['BadgeUsers'], - properties=[ - { - 'in': 'formData', - 'name': "email", - 'type': "string", - 'format': "email", - 'description': 'The email to register' - } - ] - ) @throttleable def post(self, request, **kwargs): - serializer = EmailSerializerV1(data=request.data, context={'request': request}) + serializer = EmailSerializerV1(data=request.data, context={"request": request}) serializer.is_valid(raise_exception=True) email_address = serializer.save(user=request.user) @@ -62,25 +71,39 @@ def get_email(self, **kwargs): else: return email_address + +@extend_schema_view( + get=extend_schema( + summary="Get detail for one registered email", + tags=["BadgeUsers"], + parameters=[OpenApiParameter("id", int, OpenApiParameter.PATH)], + responses=EmailSerializerV1, + ), + delete=extend_schema( + summary="Remove a registered email for the current user", + tags=["BadgeUsers"], + parameters=[OpenApiParameter("id", int, OpenApiParameter.PATH)], + responses={204: None, 400: dict, 403: dict, 404: None}, + ), + put=extend_schema( + summary="Update a registered email for the current user", + tags=["BadgeUsers"], + parameters=[OpenApiParameter("id", int, OpenApiParameter.PATH)], + request=EmailSerializerV1, + responses=EmailSerializerV1, + ), +) class BadgeUserEmailDetail(BadgeUserEmailView): model = CachedEmailAddress - @apispec_get_operation('BadgeUserEmail', - summary="Get detail for one registered email", - tags=['BadgeUsers'] - ) def get(self, request, id, **kwargs): email_address = self.get_email(pk=id) if email_address is None or email_address.user_id != self.request.user.id: return Response(status=status.HTTP_404_NOT_FOUND) - serializer = EmailSerializerV1(email_address, context={'request': request}) + serializer = EmailSerializerV1(email_address, context={"request": request}) return Response(serializer.data) - @apispec_delete_operation('BadgeUserEmail', - summary="Remove a registered email for the current user", - tags=['BadgeUsers'] - ) def delete(self, request, id, **kwargs): email_address = self.get_email(pk=id) if email_address is None: @@ -89,18 +112,20 @@ def delete(self, request, id, **kwargs): return Response(status=status.HTTP_403_FORBIDDEN) if email_address.primary: - return Response({'error': "Can not remove primary email address"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Can not remove primary email address"}, + status=status.HTTP_400_BAD_REQUEST, + ) if self.request.user.emailaddress_set.count() == 1: - return Response({'error': "Can not remove only email address"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Can not remove only email address"}, + status=status.HTTP_400_BAD_REQUEST, + ) email_address.delete() return Response(status.HTTP_200_OK) - @apispec_put_operation('BadgeUserEmail', - summary='Update a registered email for the current user', - tags=['BadgeUsers'] - ) def put(self, request, id, **kwargs): email_address = self.get_email(pk=id) if email_address is None: @@ -109,17 +134,19 @@ def put(self, request, id, **kwargs): return Response(status=status.HTTP_403_FORBIDDEN) if email_address.verified: - if request.data.get('primary'): + if request.data.get("primary"): email_address.set_as_primary() email_address.publish() else: - if request.data.get('resend'): + if request.data.get("resend"): send_confirmation = False current_time = datetime.datetime.now() last_request_time = email_address.get_last_verification_sent_time() if last_request_time is None: - email_address.set_last_verification_sent_time(datetime.datetime.now()) + email_address.set_last_verification_sent_time( + datetime.datetime.now() + ) send_confirmation = True else: time_delta = current_time - last_request_time @@ -128,17 +155,25 @@ def put(self, request, id, **kwargs): if send_confirmation: email_address.send_confirmation(request=request) - email_address.set_last_verification_sent_time(datetime.datetime.now()) + email_address.set_last_verification_sent_time( + datetime.datetime.now() + ) else: - remaining_time_obj = RATE_LIMIT_DELTA - (datetime.datetime.now() - last_request_time) - remaining_min = (remaining_time_obj.seconds//60)%60 - remaining_sec = remaining_time_obj.seconds%60 - remaining_time_rep = "{} minutes and {} seconds".format(remaining_min, remaining_sec) - - return Response("Will be able to re-send verification email in %s." % (str(remaining_time_rep)), - status=status.HTTP_429_TOO_MANY_REQUESTS) - - - serializer = EmailSerializerV1(email_address, context={'request': request}) + remaining_time_obj = RATE_LIMIT_DELTA - ( + datetime.datetime.now() - last_request_time + ) + remaining_min = (remaining_time_obj.seconds // 60) % 60 + remaining_sec = remaining_time_obj.seconds % 60 + remaining_time_rep = "{} minutes and {} seconds".format( + remaining_min, remaining_sec + ) + + return Response( + "Will be able to re-send verification email in %s." + % (str(remaining_time_rep)), + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + serializer = EmailSerializerV1(email_address, context={"request": request}) serialized = serializer.data return Response(serialized, status=status.HTTP_200_OK) diff --git a/apps/badgeuser/api_v3.py b/apps/badgeuser/api_v3.py new file mode 100644 index 000000000..61a05f509 --- /dev/null +++ b/apps/badgeuser/api_v3.py @@ -0,0 +1,78 @@ +from django_filters import CharFilter +from rest_framework.response import Response +from badgeuser.serializers_v3 import PreferenceSerializerV3 +from badgeuser.models import UserPreference +from entity.api_v3 import EntityFilter, EntityViewSet +from issuer.permissions import BadgrOAuthTokenHasScope +from mainsite.permissions import AuthenticatedWithVerifiedIdentifier +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter +from drf_spectacular.types import OpenApiTypes + + +class PreferencesFilter(EntityFilter): + key = CharFilter(field_name="key", lookup_expr="iexact") + + +@extend_schema_view( + retrieve=extend_schema( + parameters=[ + OpenApiParameter( + name="key", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The preference key", + ) + ] + ), + destroy=extend_schema( + parameters=[ + OpenApiParameter( + name="key", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The preference key", + ) + ] + ), +) +class Preferences(EntityViewSet): + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + BadgrOAuthTokenHasScope, + ) + valid_scopes = ["rw:profile"] + filterset_class = PreferencesFilter + serializer_class = PreferenceSerializerV3 + lookup_field = "key" + http_method_names = ["get", "head", "options", "post", "delete"] + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return UserPreference.objects.none() + return UserPreference.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def create(self, request, *args, **kwargs): + # Get the unique identifier from request data + lookup_field = self.lookup_field + lookup_value = request.data.get(lookup_field) + + if lookup_value: + try: + # Try to get the existing instance by lookup field + instance = self.get_queryset().get(**{lookup_field: lookup_value}) + # If found, update it using the update method + serializer = self.get_serializer( + instance, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + return Response(serializer.data) + except UserPreference.DoesNotExist: + # Instance not found, so create new + pass + + # If no lookup value or instance not found, fallback to create new + return super().create(request, *args, **kwargs) diff --git a/apps/badgeuser/apps.py b/apps/badgeuser/apps.py index 7d613e3b7..fb423c070 100644 --- a/apps/badgeuser/apps.py +++ b/apps/badgeuser/apps.py @@ -7,25 +7,25 @@ class BadgeUserConfig(AppConfig): - name = 'badgeuser' + name = "badgeuser" def ready(self): - user_signed_up.connect(log_user_signed_up, - dispatch_uid="user_signed_up") - email_confirmed.connect(log_email_confirmed, - dispatch_uid="email_confirmed") + user_signed_up.connect(log_user_signed_up, dispatch_uid="user_signed_up") + email_confirmed.connect(log_email_confirmed, dispatch_uid="email_confirmed") from allauth.account.models import EmailAddress - post_save.connect(handle_email_created, - sender=EmailAddress, - dispatch_uid="email_created") + + post_save.connect( + handle_email_created, sender=EmailAddress, dispatch_uid="email_created" + ) from mainsite.signals import handle_token_save from mainsite.models import AccessTokenProxy from oauth2_provider.models import AccessToken - post_save.connect(handle_token_save, - sender=AccessToken, - dispatch_uid="token_saved") - post_save.connect(handle_token_save, - sender=AccessTokenProxy, - dispatch_uid="token_proxy_saved") + + post_save.connect( + handle_token_save, sender=AccessToken, dispatch_uid="token_saved" + ) + post_save.connect( + handle_token_save, sender=AccessTokenProxy, dispatch_uid="token_proxy_saved" + ) diff --git a/apps/badgeuser/authcode.py b/apps/badgeuser/authcode.py index 756e908f2..1495370af 100644 --- a/apps/badgeuser/authcode.py +++ b/apps/badgeuser/authcode.py @@ -13,7 +13,9 @@ def authcode_for_accesstoken(accesstoken, expires_seconds=None, secret_key=None): payload = accesstoken.pk - return encrypt_authcode(payload, expires_seconds=expires_seconds, secret_key=secret_key) + return encrypt_authcode( + payload, expires_seconds=expires_seconds, secret_key=secret_key + ) def accesstoken_for_authcode(authcode, secret_key=None): @@ -22,6 +24,7 @@ def accesstoken_for_authcode(authcode, secret_key=None): accesstoken_pk = decrypt_authcode(authcode, secret_key=secret_key) from mainsite.models import AccessTokenProxy + try: return AccessTokenProxy.objects.get(pk=accesstoken_pk) except ObjectDoesNotExist: @@ -30,46 +33,44 @@ def accesstoken_for_authcode(authcode, secret_key=None): def encrypt_authcode(payload, expires_seconds=None, secret_key=None): if expires_seconds is None: - expires_seconds = getattr(settings, 'AUTHCODE_EXPIRES_SECONDS', 10) + expires_seconds = getattr(settings, "AUTHCODE_EXPIRES_SECONDS", 10) if secret_key is None: - secret_key = getattr(settings, 'AUTHCODE_SECRET_KEY', None) + secret_key = getattr(settings, "AUTHCODE_SECRET_KEY", None) if secret_key is None: raise ValueError("must specify a secret key") crypto = cryptography.fernet.Fernet(secret_key) - digest = crypto.encrypt(_marshall(payload, expires_seconds).encode('utf-8')) - return str(digest, 'utf-8') + digest = crypto.encrypt(_marshall(payload, expires_seconds).encode("utf-8")) + return str(digest, "utf-8") def decrypt_authcode(cipher, secret_key=None): if secret_key is None: - secret_key = getattr(settings, 'AUTHCODE_SECRET_KEY', None) + secret_key = getattr(settings, "AUTHCODE_SECRET_KEY", None) if secret_key is None: raise ValueError("must specify a secret key") crypto = cryptography.fernet.Fernet(secret_key) try: - decrypted = crypto.decrypt(cipher.encode('utf-8')) - except (cryptography.fernet.InvalidToken, UnicodeEncodeError, UnicodeDecodeError) as e: + decrypted = crypto.decrypt(cipher.encode("utf-8")) + except (cryptography.fernet.InvalidToken, UnicodeEncodeError, UnicodeDecodeError): return None message = _unmarshall(decrypted) - if message and 'expires' in message: - expires = dateutil.parser.parse(message.get('expires')) + if message and "expires" in message: + expires = dateutil.parser.parse(message.get("expires")) if expires > timezone.now(): - payload = message.get('payload') + payload = message.get("payload") return payload # helper functions for {encrypt,decrypt}_authcode + def _marshall(payload, expires_seconds): expires_at = timezone.now() + datetime.timedelta(seconds=expires_seconds) - return json.dumps(dict( - expires=expires_at.isoformat(), - payload=payload - )) + return json.dumps(dict(expires=expires_at.isoformat(), payload=payload)) def _unmarshall(digest): diff --git a/apps/badgeuser/backends.py b/apps/badgeuser/backends.py index dfca4cab1..6ce4bf4c8 100644 --- a/apps/badgeuser/backends.py +++ b/apps/badgeuser/backends.py @@ -15,4 +15,3 @@ def get_user(self, user_id): class CachedAuthenticationBackend(CachedModelBackend, AuthenticationBackend): pass - diff --git a/apps/badgeuser/forms.py b/apps/badgeuser/forms.py index cbbd32520..51f83e652 100644 --- a/apps/badgeuser/forms.py +++ b/apps/badgeuser/forms.py @@ -1,5 +1,4 @@ from django.contrib.auth.forms import UserCreationForm, UserChangeForm -from django import forms from .models import BadgeUser @@ -27,5 +26,3 @@ class BadgeUserChangeForm(UserChangeForm): class Meta: model = BadgeUser exclude = [] - - diff --git a/apps/badgeuser/management/__init__.py b/apps/badgeuser/management/__init__.py index cc56a32c4..5d05efd04 100644 --- a/apps/badgeuser/management/__init__.py +++ b/apps/badgeuser/management/__init__.py @@ -1,4 +1,2 @@ # encoding: utf-8 from __future__ import unicode_literals - - diff --git a/apps/badgeuser/management/commands/__init__.py b/apps/badgeuser/management/commands/__init__.py index cc56a32c4..5d05efd04 100644 --- a/apps/badgeuser/management/commands/__init__.py +++ b/apps/badgeuser/management/commands/__init__.py @@ -1,4 +1,2 @@ # encoding: utf-8 from __future__ import unicode_literals - - diff --git a/apps/badgeuser/management/commands/delete_superseded_users.py b/apps/badgeuser/management/commands/delete_superseded_users.py index 31763192b..40facbf03 100644 --- a/apps/badgeuser/management/commands/delete_superseded_users.py +++ b/apps/badgeuser/management/commands/delete_superseded_users.py @@ -6,11 +6,11 @@ from badgeuser.models import BadgeUser, CachedEmailAddress + class Command(BaseCommand): def handle(self, *args, **options): - - self.log("started delete_superseded_users at {}".format( - datetime.datetime.now()) + self.log( + "started delete_superseded_users at {}".format(datetime.datetime.now()) ) # All verified emails cached = CachedEmailAddress.objects.filter(verified=True) @@ -22,46 +22,70 @@ def handle(self, *args, **options): continue_processing = True while continue_processing: start = start_index - end = start_index+chunk_size + end = start_index + chunk_size # All non-unique emails - dup_emails = (BadgeUser.objects.values('email') - .annotate(Count('email')) - .filter(email__count__gt=1) - )[start:end] + dup_emails = ( + BadgeUser.objects.values("email") + .annotate(Count("email")) + .filter(email__count__gt=1) + )[start:end] - self.log('----------------------------------------') - self.log('Processing chunk {}. Duplicates: {}'.format(processing_index, len(dup_emails))) - self.log('----------------------------------------') + self.log("----------------------------------------") + self.log( + "Processing chunk {}. Duplicates: {}".format( + processing_index, len(dup_emails) + ) + ) + self.log("----------------------------------------") for dup in dup_emails: try: # Find the verified cached email for this user email - verified = cached.get(email=dup['email']) - self.log("Verified email {0}: {1} {2} {3}".format( - verified.email, verified.user_id, verified.user.first_name, verified.user.last_name, )) + verified = cached.get(email=dup["email"]) + self.log( + "Verified email {0}: {1} {2} {3}".format( + verified.email, + verified.user_id, + verified.user.first_name, + verified.user.last_name, + ) + ) # get all the users with this email expect the verified id, delete them - users = (BadgeUser.objects - .filter(email=verified.email) - .exclude(id=verified.user_id)) + users = BadgeUser.objects.filter(email=verified.email).exclude( + id=verified.user_id + ) for user in users: if not user.cached_emails(): - self.log(" Deleting duplicate {0}: {1} {2} {3}".format( - user.email, user.id, user.first_name, user.last_name)) + self.log( + " Deleting duplicate {0}: {1} {2} {3}".format( + user.email, user.id, user.first_name, user.last_name + ) + ) BadgeUser.delete(user) except MultipleObjectsReturned: - self.log("[ERROR] More then one verified CachedEmail found for: {}".format(dup['email'])) + self.log( + "[ERROR] More then one verified CachedEmail found for: {}".format( + dup["email"] + ) + ) except ObjectDoesNotExist: - self.log("[ERROR] No verified CachedEmail found for: {}".format(dup['email'])) + self.log( + "[ERROR] No verified CachedEmail found for: {}".format( + dup["email"] + ) + ) processing_index = processing_index + 1 continue_processing = len(dup_emails) >= chunk_size start_index += chunk_size - self.log("finished delete_superseded_users at {}".format(datetime.datetime.now())) + self.log( + "finished delete_superseded_users at {}".format(datetime.datetime.now()) + ) def log(self, message): self.stdout.write(message) diff --git a/apps/badgeuser/management/commands/export_emails.py b/apps/badgeuser/management/commands/export_emails.py new file mode 100644 index 000000000..6846657aa --- /dev/null +++ b/apps/badgeuser/management/commands/export_emails.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand +from badgeuser.models import BadgeUser +import os +import csv + + +class Command(BaseCommand): + def handle(self, *args, **options): + users = BadgeUser.objects.all() + file_path = os.path.join(os.getcwd(), "user_emails.csv") + try: + with open(file_path, "w", newline="") as csvfile: + writer = csv.writer(csvfile) + writer.writerow(["Vorname", "Nachname", "E-Mail"]) + + for user in users: + writer.writerow( + [user.first_name, user.last_name, user.primary_email] + ) + self.stdout.write( + self.style.SUCCESS(f"Successfully exported emails to {file_path}") + ) + except Exception as e: + self.stdout.write(self.style.ERROR(f"An error occurred: {str(e)}")) diff --git a/apps/badgeuser/managers.py b/apps/badgeuser/managers.py index 83011d729..c23a32212 100644 --- a/apps/badgeuser/managers.py +++ b/apps/badgeuser/managers.py @@ -16,19 +16,22 @@ class BadgeUserManager(UserManager): - duplicate_email_error = 'Account could not be created. An account with this email address may already exist.' - - def create(self, - email, - first_name, - last_name, - request=None, - plaintext_password=None, - send_confirmation=True, - create_email_address=True, - marketing_opt_in=False, - source='' - ): + duplicate_email_error = "Account could not be created. An account with this email address may already exist." + + def create( + self, + email, + first_name, + last_name, + request=None, + plaintext_password=None, + send_confirmation=True, + create_email_address=True, + marketing_opt_in=False, + zip_code=None, + secure_password_set=True, + source="", + ): from badgeuser.models import CachedEmailAddress, TermsVersion user = None @@ -41,7 +44,11 @@ def create(self, # nope pass else: - if plaintext_password and not existing_email.user.password and not existing_email.verified: + if ( + plaintext_password + and not existing_email.user.password + and not existing_email.verified + ): # yes, it's owned by an auto-created user trying to set a password user = existing_email.user elif plaintext_password and not existing_email.user.password: @@ -56,12 +63,17 @@ def create(self, badgrapp_id=badgrapp.id, marketing_opt_in=marketing_opt_in, plaintext_password=plaintext_password, - source=source + source=source, ) return self.model(email=email) elif existing_email.verified: raise ValidationError(self.duplicate_email_error) - else: + # Only if at least the email or the user *really* exists, delete it. + # Otherwise it was likely deleted via the staff interface. + elif ( + CachedEmailAddress.objects.filter(pk=existing_email.pk).exists() + or self.model.objects.filter(pk=existing_email.user.pk).exists() + ): # yes, it's an unverified email address owned by a claimed user # remove the email existing_email.delete() @@ -76,43 +88,52 @@ def create(self, user.last_name = last_name user.badgrapp = badgrapp user.marketing_opt_in = marketing_opt_in + user.zip_code = zip_code + user.secure_password_set = secure_password_set user.agreed_terms_version = TermsVersion.cached.latest_version() if plaintext_password: user.set_password(plaintext_password) user.save() - # create email address record as needed if create_email_address: - CachedEmailAddress.objects.add_email(user, email, request=request, signup=True, confirm=send_confirmation) + CachedEmailAddress.objects.add_email( + user, email, request=request, signup=True, confirm=send_confirmation + ) return user @staticmethod def send_account_confirmation(**kwargs): - if not kwargs.get('email'): + if not kwargs.get("email"): return - email = kwargs['email'] - source = kwargs['source'] - expires_seconds = getattr(settings, 'AUTH_TIMEOUT_SECONDS', 7 * 86400) + email = kwargs["email"] + source = kwargs["source"] + expires_seconds = getattr(settings, "AUTH_TIMEOUT_SECONDS", 7 * 86400) payload = kwargs.copy() - payload['nonce'] = ''.join(random.choice(string.ascii_uppercase) for _ in range(random.randint(20, 30))) + payload["nonce"] = "".join( + random.choice(string.ascii_uppercase) for _ in range(random.randint(20, 30)) + ) payload = json.dumps(payload) authcode = encrypt_authcode(payload, expires_seconds=expires_seconds) confirmation_url = "{origin}{path}".format( origin=OriginSetting.HTTP, - path=reverse('v2_api_account_confirm', kwargs=dict(authcode=authcode)), + path=reverse("v2_api_account_confirm", kwargs=dict(authcode=authcode)), ) if source: confirmation_url = set_url_query_params(confirmation_url, source=source) - get_adapter().send_mail('account/email/email_confirmation_signup', email, { - 'HTTP_ORIGIN': settings.HTTP_ORIGIN, - 'STATIC_URL': settings.STATIC_URL, - 'MEDIA_URL': settings.MEDIA_URL, - 'activate_url': confirmation_url, - 'email': email, - }) + get_adapter().send_mail( + "account/email/email_confirmation_signup", + email, + { + "HTTP_ORIGIN": settings.HTTP_ORIGIN, + "STATIC_URL": settings.STATIC_URL, + "MEDIA_URL": settings.MEDIA_URL, + "activate_url": confirmation_url, + "email": email, + }, + ) class CachedEmailAddressManager(EmailAddressManager): @@ -126,7 +147,10 @@ def add_email(self, user, email, request=None, confirm=False, signup=False): email_address.send_confirmation(request=request, signup=signup) # Add variant if it does not exist - if email_address.email.lower() == email.lower() and email_address.email != email: + if ( + email_address.email.lower() == email.lower() + and email_address.email != email + ): self.model.add_variant(email) return email_address diff --git a/apps/badgeuser/middleware.py b/apps/badgeuser/middleware.py index 278b92503..c553f4fc1 100644 --- a/apps/badgeuser/middleware.py +++ b/apps/badgeuser/middleware.py @@ -6,14 +6,17 @@ class InactiveUserMiddleware(deprecation.MiddlewareMixin): def process_request(self, request): - if not hasattr(request, 'user'): + if not hasattr(request, "user"): raise ImproperlyConfigured( "The Django remote user auth middleware requires the" " authentication middleware to be installed. Edit your" " MIDDLEWARE_CLASSES setting to insert" " 'django.contrib.auth.middleware.AuthenticationMiddleware'" - " before the InactiveAccountMiddleware class.") - if (request.user.is_authenticated and - request.user.is_active == False and - request.path != reverse('account_enabled')): - return HttpResponseRedirect(reverse('account_enabled')) + " before the InactiveAccountMiddleware class." + ) + if ( + request.user.is_authenticated + and not request.user.is_active + and request.path != reverse("account_enabled") + ): + return HttpResponseRedirect(reverse("account_enabled")) diff --git a/apps/badgeuser/migrations/0001_initial.py b/apps/badgeuser/migrations/0001_initial.py index 1c2ee6908..1edbff8fa 100644 --- a/apps/badgeuser/migrations/0001_initial.py +++ b/apps/badgeuser/migrations/0001_initial.py @@ -7,33 +7,119 @@ class Migration(migrations.Migration): - dependencies = [ - ('auth', '0001_initial'), + ("auth", "0001_initial"), ] operations = [ migrations.CreateModel( - name='BadgeUser', + name="BadgeUser", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, max_length=30, verbose_name='username', validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username.', 'invalid')])), - ('first_name', models.CharField(max_length=30, verbose_name='first name', blank=True)), - ('last_name', models.CharField(max_length=30, verbose_name='last name', blank=True)), - ('email', models.EmailField(max_length=75, verbose_name='email address', blank=True)), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of his/her group.', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Permission', blank=True, help_text='Specific permissions for this user.', verbose_name='user permissions')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + help_text="Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.", + unique=True, + max_length=30, + verbose_name="username", + validators=[ + django.core.validators.RegexValidator( + "^[\\w.@+-]+$", "Enter a valid username.", "invalid" + ) + ], + ), + ), + ( + "first_name", + models.CharField( + max_length=30, verbose_name="first name", blank=True + ), + ), + ( + "last_name", + models.CharField( + max_length=30, verbose_name="last name", blank=True + ), + ), + ( + "email", + models.EmailField( + max_length=75, verbose_name="email address", blank=True + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ( + "groups", + models.ManyToManyField( + related_query_name="user", + related_name="user_set", + to="auth.Group", + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of his/her group.", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + related_query_name="user", + related_name="user_set", + to="auth.Permission", + blank=True, + help_text="Specific permissions for this user.", + verbose_name="user permissions", + ), + ), ], options={ - 'db_table': 'users', - 'verbose_name': 'badgeuser', - 'verbose_name_plural': 'badgeusers', + "db_table": "users", + "verbose_name": "badgeuser", + "verbose_name_plural": "badgeusers", }, bases=(models.Model,), ), diff --git a/apps/badgeuser/migrations/0002_cachedemailaddress.py b/apps/badgeuser/migrations/0002_cachedemailaddress.py index 51471a252..f813e8dce 100644 --- a/apps/badgeuser/migrations/0002_cachedemailaddress.py +++ b/apps/badgeuser/migrations/0002_cachedemailaddress.py @@ -5,20 +5,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('account', '0002_email_max_length'), - ('badgeuser', '0001_initial'), + ("account", "0002_email_max_length"), + ("badgeuser", "0001_initial"), ] operations = [ migrations.CreateModel( - name='CachedEmailAddress', - fields=[ - ], + name="CachedEmailAddress", + fields=[], options={ - 'proxy': True, + "proxy": True, }, - bases=('account.emailaddress', models.Model), + bases=("account.emailaddress", models.Model), ), ] diff --git a/apps/badgeuser/migrations/0003_auto_20160210_0313.py b/apps/badgeuser/migrations/0003_auto_20160210_0313.py index f421a0e0c..b71e0379c 100644 --- a/apps/badgeuser/migrations/0003_auto_20160210_0313.py +++ b/apps/badgeuser/migrations/0003_auto_20160210_0313.py @@ -1,18 +1,20 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0002_cachedemailaddress'), + ("badgeuser", "0002_cachedemailaddress"), ] operations = [ migrations.AlterModelOptions( - name='badgeuser', - options={'verbose_name': 'badge user', 'verbose_name_plural': 'badge users'}, + name="badgeuser", + options={ + "verbose_name": "badge user", + "verbose_name_plural": "badge users", + }, ), ] diff --git a/apps/badgeuser/migrations/0003_auto_20160309_0820.py b/apps/badgeuser/migrations/0003_auto_20160309_0820.py index f421a0e0c..b71e0379c 100644 --- a/apps/badgeuser/migrations/0003_auto_20160309_0820.py +++ b/apps/badgeuser/migrations/0003_auto_20160309_0820.py @@ -1,18 +1,20 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0002_cachedemailaddress'), + ("badgeuser", "0002_cachedemailaddress"), ] operations = [ migrations.AlterModelOptions( - name='badgeuser', - options={'verbose_name': 'badge user', 'verbose_name_plural': 'badge users'}, + name="badgeuser", + options={ + "verbose_name": "badge user", + "verbose_name_plural": "badge users", + }, ), ] diff --git a/apps/badgeuser/migrations/0004_merge.py b/apps/badgeuser/migrations/0004_merge.py index b57445568..f685bd965 100644 --- a/apps/badgeuser/migrations/0004_merge.py +++ b/apps/badgeuser/migrations/0004_merge.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0003_auto_20160210_0313'), - ('badgeuser', '0003_auto_20160309_0820'), + ("badgeuser", "0003_auto_20160210_0313"), + ("badgeuser", "0003_auto_20160309_0820"), ] - operations = [ - ] + operations = [] diff --git a/apps/badgeuser/migrations/0005_auto_20160901_1537.py b/apps/badgeuser/migrations/0005_auto_20160901_1537.py index 2668f814a..49750160f 100644 --- a/apps/badgeuser/migrations/0005_auto_20160901_1537.py +++ b/apps/badgeuser/migrations/0005_auto_20160901_1537.py @@ -6,37 +6,51 @@ class Migration(migrations.Migration): - dependencies = [ - ('account', '0002_email_max_length'), - ('badgeuser', '0004_merge'), + ("account", "0002_email_max_length"), + ("badgeuser", "0004_merge"), ] operations = [ migrations.CreateModel( - name='EmailAddressVariant', + name="EmailAddressVariant", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('email', models.EmailField(max_length=75)), - ('canonical_email', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='badgeuser.CachedEmailAddress')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("email", models.EmailField(max_length=75)), + ( + "canonical_email", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="badgeuser.CachedEmailAddress", + ), + ), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( - name='ProxyEmailConfirmation', - fields=[ - ], + name="ProxyEmailConfirmation", + fields=[], options={ - 'verbose_name': 'email confirmation', - 'proxy': True, - 'verbose_name_plural': 'email confirmations', + "verbose_name": "email confirmation", + "proxy": True, + "verbose_name_plural": "email confirmations", }, - bases=('account.emailconfirmation',), + bases=("account.emailconfirmation",), ), migrations.AlterModelOptions( - name='cachedemailaddress', - options={'verbose_name': 'email address', 'verbose_name_plural': 'email addresses'}, + name="cachedemailaddress", + options={ + "verbose_name": "email address", + "verbose_name_plural": "email addresses", + }, ), ] diff --git a/apps/badgeuser/migrations/0006_auto_20161128_0938.py b/apps/badgeuser/migrations/0006_auto_20161128_0938.py index 3134b8539..f459c121e 100644 --- a/apps/badgeuser/migrations/0006_auto_20161128_0938.py +++ b/apps/badgeuser/migrations/0006_auto_20161128_0938.py @@ -1,11 +1,7 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations, IntegrityError, transaction -from allauth.account.adapter import get_adapter -from allauth.account.models import EmailConfirmation - -from badgeuser.models import CachedEmailAddress, BadgeUser, EmailConfirmation +from django.db import migrations def do_nothing(apps, schema_editor): @@ -16,11 +12,8 @@ def do_nothing(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0005_auto_20160901_1537'), + ("badgeuser", "0005_auto_20160901_1537"), ] - operations = [ - migrations.RunPython(do_nothing) - ] + operations = [migrations.RunPython(do_nothing)] diff --git a/apps/badgeuser/migrations/0007_auto_20170427_0724.py b/apps/badgeuser/migrations/0007_auto_20170427_0724.py index 652d3393e..0e48fda24 100644 --- a/apps/badgeuser/migrations/0007_auto_20170427_0724.py +++ b/apps/badgeuser/migrations/0007_auto_20170427_0724.py @@ -7,41 +7,64 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0006_auto_20161128_0938'), + ("badgeuser", "0006_auto_20161128_0938"), ] operations = [ migrations.AlterModelManagers( - name='badgeuser', + name="badgeuser", managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ("objects", django.contrib.auth.models.UserManager()), ], ), migrations.AlterField( - model_name='badgeuser', - name='email', - field=models.EmailField(max_length=254, verbose_name='email address', blank=True), + model_name="badgeuser", + name="email", + field=models.EmailField( + max_length=254, verbose_name="email address", blank=True + ), ), migrations.AlterField( - model_name='badgeuser', - name='groups', - field=models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', verbose_name='groups'), + model_name="badgeuser", + name="groups", + field=models.ManyToManyField( + related_query_name="user", + related_name="user_set", + to="auth.Group", + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + verbose_name="groups", + ), ), migrations.AlterField( - model_name='badgeuser', - name='last_login', - field=models.DateTimeField(null=True, verbose_name='last login', blank=True), + model_name="badgeuser", + name="last_login", + field=models.DateTimeField( + null=True, verbose_name="last login", blank=True + ), ), migrations.AlterField( - model_name='badgeuser', - name='username', - field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=30, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, verbose_name='username'), + model_name="badgeuser", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + max_length=30, + validators=[ + django.core.validators.RegexValidator( + "^[\\w.@+-]+$", + "Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.", + "invalid", + ) + ], + help_text="Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.", + unique=True, + verbose_name="username", + ), ), migrations.AlterField( - model_name='emailaddressvariant', - name='email', + model_name="emailaddressvariant", + name="email", field=models.EmailField(max_length=254), ), ] diff --git a/apps/badgeuser/migrations/0007_auto_20170427_0957.py b/apps/badgeuser/migrations/0007_auto_20170427_0957.py index 02e04e738..bb30930c6 100644 --- a/apps/badgeuser/migrations/0007_auto_20170427_0957.py +++ b/apps/badgeuser/migrations/0007_auto_20170427_0957.py @@ -4,27 +4,25 @@ from django.db import models, migrations from entity.db.migrations import PopulateEntityIdsMigration -from mainsite.utils import generate_entity_uri class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0006_auto_20161128_0938'), + ("badgeuser", "0006_auto_20161128_0938"), ] operations = [ migrations.AddField( - model_name='badgeuser', - name='entity_id', + model_name="badgeuser", + name="entity_id", field=models.CharField(default=None, null=True, max_length=254), preserve_default=True, ), migrations.AddField( - model_name='badgeuser', - name='entity_version', + model_name="badgeuser", + name="entity_version", field=models.PositiveIntegerField(default=1), preserve_default=True, ), - PopulateEntityIdsMigration('badgeuser', 'BadgeUser') + PopulateEntityIdsMigration("badgeuser", "BadgeUser"), ] diff --git a/apps/badgeuser/migrations/0008_auto_20170427_1508.py b/apps/badgeuser/migrations/0008_auto_20170427_1508.py index 4694ea503..7086a4e6a 100644 --- a/apps/badgeuser/migrations/0008_auto_20170427_1508.py +++ b/apps/badgeuser/migrations/0008_auto_20170427_1508.py @@ -1,17 +1,16 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations from entity.db.migrations import PopulateEntityIdsMigration class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0007_auto_20170427_0957'), + ("badgeuser", "0007_auto_20170427_0957"), ] operations = [ - PopulateEntityIdsMigration('badgeuser', 'BadgeUser'), + PopulateEntityIdsMigration("badgeuser", "BadgeUser"), ] diff --git a/apps/badgeuser/migrations/0009_auto_20170427_1509.py b/apps/badgeuser/migrations/0009_auto_20170427_1509.py index 44446bd6b..43e02d5d0 100644 --- a/apps/badgeuser/migrations/0009_auto_20170427_1509.py +++ b/apps/badgeuser/migrations/0009_auto_20170427_1509.py @@ -5,15 +5,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0008_auto_20170427_1508'), + ("badgeuser", "0008_auto_20170427_1508"), ] operations = [ migrations.AlterField( - model_name='badgeuser', - name='entity_id', + model_name="badgeuser", + name="entity_id", field=models.CharField(default=None, unique=True, max_length=254), preserve_default=True, ), diff --git a/apps/badgeuser/migrations/0010_merge.py b/apps/badgeuser/migrations/0010_merge.py index 777c7609b..8ed03c74f 100644 --- a/apps/badgeuser/migrations/0010_merge.py +++ b/apps/badgeuser/migrations/0010_merge.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- -from django.db import migrations, models +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0007_auto_20170427_0724'), - ('badgeuser', '0009_auto_20170427_1509'), + ("badgeuser", "0007_auto_20170427_0724"), + ("badgeuser", "0009_auto_20170427_1509"), ] - operations = [ - ] + operations = [] diff --git a/apps/badgeuser/migrations/0011_auto_20170619_2301.py b/apps/badgeuser/migrations/0011_auto_20170619_2301.py index f671c8541..5a0740313 100644 --- a/apps/badgeuser/migrations/0011_auto_20170619_2301.py +++ b/apps/badgeuser/migrations/0011_auto_20170619_2301.py @@ -1,21 +1,20 @@ # -*- coding: utf-8 -*- -from django.db import migrations, models +from django.db import migrations import badgeuser.managers class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0010_merge'), + ("badgeuser", "0010_merge"), ] operations = [ migrations.AlterModelManagers( - name='badgeuser', + name="badgeuser", managers=[ - ('objects', badgeuser.managers.BadgeUserManager()), + ("objects", badgeuser.managers.BadgeUserManager()), ], ), ] diff --git a/apps/badgeuser/migrations/0012_auto_20170711_1326.py b/apps/badgeuser/migrations/0012_auto_20170711_1326.py index a38ce836a..7d980c247 100644 --- a/apps/badgeuser/migrations/0012_auto_20170711_1326.py +++ b/apps/badgeuser/migrations/0012_auto_20170711_1326.py @@ -7,15 +7,26 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0011_auto_20170619_2301'), + ("badgeuser", "0011_auto_20170619_2301"), ] operations = [ migrations.AlterField( - model_name='badgeuser', - name='username', - field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=30, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.')], verbose_name='username'), + model_name="badgeuser", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=30, + unique=True, + validators=[ + django.core.validators.RegexValidator( + "^[\\w.@+-]+$", + "Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.", + ) + ], + verbose_name="username", + ), ), ] diff --git a/apps/badgeuser/migrations/0013_auto_20170725_1305.py b/apps/badgeuser/migrations/0013_auto_20170725_1305.py index db989bbcb..968d2fc9a 100644 --- a/apps/badgeuser/migrations/0013_auto_20170725_1305.py +++ b/apps/badgeuser/migrations/0013_auto_20170725_1305.py @@ -7,15 +7,21 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0012_auto_20170711_1326'), + ("badgeuser", "0012_auto_20170711_1326"), ] operations = [ migrations.AlterField( - model_name='badgeuser', - name='username', - field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.ASCIIUsernameValidator()], verbose_name='username'), + model_name="badgeuser", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[django.contrib.auth.validators.ASCIIUsernameValidator()], + verbose_name="username", + ), ), ] diff --git a/apps/badgeuser/migrations/0014_badgraccesstoken.py b/apps/badgeuser/migrations/0014_badgraccesstoken.py index 2147f60e1..3347f66d6 100644 --- a/apps/badgeuser/migrations/0014_badgraccesstoken.py +++ b/apps/badgeuser/migrations/0014_badgraccesstoken.py @@ -6,20 +6,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('oauth2_provider', '0001_initial'), - ('badgeuser', '0013_auto_20170725_1305'), + ("oauth2_provider", "0001_initial"), + ("badgeuser", "0013_auto_20170725_1305"), ] operations = [ migrations.CreateModel( - name='BadgrAccessToken', - fields=[ - ], + name="BadgrAccessToken", + fields=[], options={ - 'proxy': True, + "proxy": True, }, - bases=('oauth2_provider.accesstoken',), + bases=("oauth2_provider.accesstoken",), ), ] diff --git a/apps/badgeuser/migrations/0015_badgeuser_badgrapp.py b/apps/badgeuser/migrations/0015_badgeuser_badgrapp.py index badd97e4d..062e6f090 100644 --- a/apps/badgeuser/migrations/0015_badgeuser_badgrapp.py +++ b/apps/badgeuser/migrations/0015_badgeuser_badgrapp.py @@ -7,16 +7,21 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0012_badgrapp_public_pages_redirect'), - ('badgeuser', '0014_badgraccesstoken'), + ("mainsite", "0012_badgrapp_public_pages_redirect"), + ("badgeuser", "0014_badgraccesstoken"), ] operations = [ migrations.AddField( - model_name='badgeuser', - name='badgrapp', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='mainsite.BadgrApp'), + model_name="badgeuser", + name="badgrapp", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="mainsite.BadgrApp", + ), ), ] diff --git a/apps/badgeuser/migrations/0016_auto_20180611_0802.py b/apps/badgeuser/migrations/0016_auto_20180611_0802.py index 756bd2dab..286b6a9de 100644 --- a/apps/badgeuser/migrations/0016_auto_20180611_0802.py +++ b/apps/badgeuser/migrations/0016_auto_20180611_0802.py @@ -8,69 +8,110 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0015_badgeuser_badgrapp'), + ("badgeuser", "0015_badgeuser_badgrapp"), ] operations = [ migrations.CreateModel( - name='TermsAgreement', + name="TermsAgreement", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('terms_version', models.PositiveIntegerField()), - ('agreed', models.BooleanField(default=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("terms_version", models.PositiveIntegerField()), + ("agreed", models.BooleanField(default=True)), ], options={ - 'ordering': ('-terms_version',), + "ordering": ("-terms_version",), }, ), migrations.CreateModel( - name='TermsVersion', + name="TermsVersion", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('version', models.PositiveIntegerField(unique=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("version", models.PositiveIntegerField(unique=True)), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.AddField( - model_name='badgeuser', - name='marketing_opt_in', + model_name="badgeuser", + name="marketing_opt_in", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='termsversion', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="termsversion", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='termsversion', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="termsversion", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='termsagreement', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="termsagreement", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='termsagreement', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="termsagreement", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='termsagreement', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="termsagreement", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), ), migrations.AlterUniqueTogether( - name='termsagreement', - unique_together=set([('user', 'terms_version')]), + name="termsagreement", + unique_together=set([("user", "terms_version")]), ), ] diff --git a/apps/badgeuser/migrations/0017_auto_20180611_0819.py b/apps/badgeuser/migrations/0017_auto_20180611_0819.py index 99c7978b1..350a2f27e 100644 --- a/apps/badgeuser/migrations/0017_auto_20180611_0819.py +++ b/apps/badgeuser/migrations/0017_auto_20180611_0819.py @@ -7,21 +7,20 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0016_auto_20180611_0802'), + ("badgeuser", "0016_auto_20180611_0802"), ] operations = [ migrations.AlterModelManagers( - name='termsversion', + name="termsversion", managers=[ - ('cached', django.db.models.manager.Manager()), + ("cached", django.db.models.manager.Manager()), ], ), migrations.AddField( - model_name='termsversion', - name='is_active', + model_name="termsversion", + name="is_active", field=models.BooleanField(default=True), ), ] diff --git a/apps/badgeuser/migrations/0018_termsversion_short_description.py b/apps/badgeuser/migrations/0018_termsversion_short_description.py index 7daf22ffd..d944a3c64 100644 --- a/apps/badgeuser/migrations/0018_termsversion_short_description.py +++ b/apps/badgeuser/migrations/0018_termsversion_short_description.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0017_auto_20180611_0819'), + ("badgeuser", "0017_auto_20180611_0819"), ] operations = [ migrations.AddField( - model_name='termsversion', - name='short_description', + model_name="termsversion", + name="short_description", field=models.TextField(blank=True), ), ] diff --git a/apps/badgeuser/migrations/0019_auto_20181102_1438.py b/apps/badgeuser/migrations/0019_auto_20181102_1438.py index 11a4315ef..0d578686c 100644 --- a/apps/badgeuser/migrations/0019_auto_20181102_1438.py +++ b/apps/badgeuser/migrations/0019_auto_20181102_1438.py @@ -6,20 +6,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0018_termsversion_short_description'), + ("badgeuser", "0018_termsversion_short_description"), ] operations = [ migrations.AlterField( - model_name='termsagreement', - name='created_at', + model_name="termsagreement", + name="created_at", field=models.DateTimeField(auto_now_add=True, db_index=True), ), migrations.AlterField( - model_name='termsversion', - name='created_at', + model_name="termsversion", + name="created_at", field=models.DateTimeField(auto_now_add=True, db_index=True), ), ] diff --git a/apps/badgeuser/migrations/0020_userrecipientidentifier.py b/apps/badgeuser/migrations/0020_userrecipientidentifier.py index cfbe8d625..8a65b2e88 100644 --- a/apps/badgeuser/migrations/0020_userrecipientidentifier.py +++ b/apps/badgeuser/migrations/0020_userrecipientidentifier.py @@ -8,24 +8,42 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0019_auto_20181102_1438'), + ("badgeuser", "0019_auto_20181102_1438"), ] operations = [ migrations.CreateModel( - name='UserRecipientIdentifier', + name="UserRecipientIdentifier", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('format', models.CharField(choices=[('url', 'URL')], default='url', max_length=3)), - ('identifier', models.CharField(max_length=255)), - ('verified', models.BooleanField(default=False)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "format", + models.CharField( + choices=[("url", "URL")], default="url", max_length=3 + ), + ), + ("identifier", models.CharField(max_length=255)), + ("verified", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.AlterUniqueTogether( - name='userrecipientidentifier', - unique_together=set([('user', 'format', 'identifier')]), + name="userrecipientidentifier", + unique_together=set([("user", "format", "identifier")]), ), ] diff --git a/apps/badgeuser/migrations/0021_auto_20190405_0921.py b/apps/badgeuser/migrations/0021_auto_20190405_0921.py index 67fe61a46..fe6fa07fc 100644 --- a/apps/badgeuser/migrations/0021_auto_20190405_0921.py +++ b/apps/badgeuser/migrations/0021_auto_20190405_0921.py @@ -6,19 +6,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0020_userrecipientidentifier'), + ("badgeuser", "0020_userrecipientidentifier"), ] operations = [ migrations.RenameField( - model_name='userrecipientidentifier', - old_name='format', - new_name='type', + model_name="userrecipientidentifier", + old_name="format", + new_name="type", ), migrations.AlterUniqueTogether( - name='userrecipientidentifier', - unique_together=set([('user', 'type', 'identifier')]), + name="userrecipientidentifier", + unique_together=set([("user", "type", "identifier")]), ), ] diff --git a/apps/badgeuser/migrations/0022_auto_20190405_1344.py b/apps/badgeuser/migrations/0022_auto_20190405_1344.py index eecaad21c..71926854b 100644 --- a/apps/badgeuser/migrations/0022_auto_20190405_1344.py +++ b/apps/badgeuser/migrations/0022_auto_20190405_1344.py @@ -6,15 +6,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0021_auto_20190405_0921'), + ("badgeuser", "0021_auto_20190405_0921"), ] operations = [ migrations.AlterField( - model_name='userrecipientidentifier', - name='type', - field=models.CharField(choices=[('url', 'URL'), ('telephone', 'Phone Number')], default='url', max_length=9), + model_name="userrecipientidentifier", + name="type", + field=models.CharField( + choices=[("url", "URL"), ("telephone", "Phone Number")], + default="url", + max_length=9, + ), ), ] diff --git a/apps/badgeuser/migrations/0023_delete_badgraccesstoken.py b/apps/badgeuser/migrations/0023_delete_badgraccesstoken.py index c6c9f6e9b..261cda659 100644 --- a/apps/badgeuser/migrations/0023_delete_badgraccesstoken.py +++ b/apps/badgeuser/migrations/0023_delete_badgraccesstoken.py @@ -6,13 +6,12 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0022_auto_20190405_1344'), + ("badgeuser", "0022_auto_20190405_1344"), ] operations = [ migrations.DeleteModel( - name='BadgrAccessToken', + name="BadgrAccessToken", ), ] diff --git a/apps/badgeuser/migrations/0024_auto_20200106_1621.py b/apps/badgeuser/migrations/0024_auto_20200106_1621.py index 52ec0dde2..901744dc9 100644 --- a/apps/badgeuser/migrations/0024_auto_20200106_1621.py +++ b/apps/badgeuser/migrations/0024_auto_20200106_1621.py @@ -6,20 +6,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0023_delete_badgraccesstoken'), + ("badgeuser", "0023_delete_badgraccesstoken"), ] operations = [ migrations.AlterField( - model_name='termsagreement', - name='updated_at', + model_name="termsagreement", + name="updated_at", field=models.DateTimeField(auto_now=True, db_index=True), ), migrations.AlterField( - model_name='termsversion', - name='updated_at', + model_name="termsversion", + name="updated_at", field=models.DateTimeField(auto_now=True, db_index=True), ), ] diff --git a/apps/badgeuser/migrations/0025_auto_20200608_0452.py b/apps/badgeuser/migrations/0025_auto_20200608_0452.py index cf81e2de6..9f2ac1542 100644 --- a/apps/badgeuser/migrations/0025_auto_20200608_0452.py +++ b/apps/badgeuser/migrations/0025_auto_20200608_0452.py @@ -6,25 +6,39 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0024_auto_20200106_1621'), + ("badgeuser", "0024_auto_20200106_1621"), ] operations = [ migrations.AlterField( - model_name='badgeuser', - name='badgrapp', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='mainsite.BadgrApp'), + model_name="badgeuser", + name="badgrapp", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="mainsite.BadgrApp", + ), ), migrations.AlterField( - model_name='badgeuser', - name='last_name', - field=models.CharField(blank=True, max_length=150, verbose_name='last name'), + model_name="badgeuser", + name="last_name", + field=models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), ), migrations.AlterField( - model_name='badgeuser', - name='username', - field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'), + model_name="badgeuser", + name="username", + field=models.CharField( + error_messages={"unique": "A user with that username already exists."}, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], + verbose_name="username", + ), ), ] diff --git a/apps/badgeuser/migrations/0026_auto_20200817_1538.py b/apps/badgeuser/migrations/0026_auto_20200817_1538.py index 3bcc8a31c..647ce3453 100644 --- a/apps/badgeuser/migrations/0026_auto_20200817_1538.py +++ b/apps/badgeuser/migrations/0026_auto_20200817_1538.py @@ -6,20 +6,31 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgeuser', '0025_auto_20200608_0452'), + ("badgeuser", "0025_auto_20200608_0452"), ] operations = [ migrations.AlterField( - model_name='termsversion', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="termsversion", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterField( - model_name='termsversion', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="termsversion", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/apps/badgeuser/migrations/0027_alter_badgeuser_first_name.py b/apps/badgeuser/migrations/0027_alter_badgeuser_first_name.py new file mode 100644 index 000000000..3e9dfccbe --- /dev/null +++ b/apps/badgeuser/migrations/0027_alter_badgeuser_first_name.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2 on 2024-05-14 11:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("badgeuser", "0026_auto_20200817_1538"), + ] + + operations = [ + migrations.AlterField( + model_name="badgeuser", + name="first_name", + field=models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ] diff --git a/apps/badgeuser/migrations/0028_badgeuser_secure_password_set.py b/apps/badgeuser/migrations/0028_badgeuser_secure_password_set.py new file mode 100644 index 000000000..07358acc0 --- /dev/null +++ b/apps/badgeuser/migrations/0028_badgeuser_secure_password_set.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2 on 2025-07-31 07:38 + +from django.db import migrations, models +from badgeuser.models import BadgeUser + + +def update_existing_users(apps, schema_editor): + """ + Set secure_password_set field to false for existing users + """ + BadgeUser.objects.all().update(secure_password_set=False) + + +def reverse_update_users(apps, schema_editor): + """Reverse operation - field didn't exist before, so no action needed""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("badgeuser", "0027_alter_badgeuser_first_name"), + ] + + operations = [ + migrations.AddField( + model_name="badgeuser", + name="secure_password_set", + field=models.BooleanField(default=True), + ), + migrations.RunPython(update_existing_users, reverse_update_users), + ] diff --git a/apps/externaltools/migrations/0002_externaltooluseractivation.py b/apps/badgeuser/migrations/0029_userpreference.py similarity index 69% rename from apps/externaltools/migrations/0002_externaltooluseractivation.py rename to apps/badgeuser/migrations/0029_userpreference.py index b8ce2c314..e2a174205 100644 --- a/apps/externaltools/migrations/0002_externaltooluseractivation.py +++ b/apps/badgeuser/migrations/0029_userpreference.py @@ -1,6 +1,4 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.7 on 2018-03-08 16:08 - +# Generated by Django 3.2 on 2025-09-18 12:31 from django.conf import settings from django.db import migrations, models @@ -10,20 +8,19 @@ class Migration(migrations.Migration): dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('externaltools', '0001_initial'), + ('badgeuser', '0028_badgeuser_secure_password_set'), ] operations = [ migrations.CreateModel( - name='ExternalToolUserActivation', + name='UserPreference', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('is_active', models.BooleanField(db_index=True, default=True)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('key', models.CharField(db_index=True, max_length=255)), + ('value', models.TextField()), ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), - ('externaltool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='externaltools.ExternalTool')), ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], diff --git a/apps/badgeuser/migrations/0029_userpreference_squashed_0030_alter_userpreference_unique_together.py b/apps/badgeuser/migrations/0029_userpreference_squashed_0030_alter_userpreference_unique_together.py new file mode 100644 index 000000000..ebb079206 --- /dev/null +++ b/apps/badgeuser/migrations/0029_userpreference_squashed_0030_alter_userpreference_unique_together.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2 on 2025-09-18 14:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [('badgeuser', '0029_userpreference'), ('badgeuser', '0030_alter_userpreference_unique_together')] + + dependencies = [ + ('badgeuser', '0028_badgeuser_secure_password_set'), + ] + + operations = [ + migrations.CreateModel( + name='UserPreference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True)), + ('key', models.CharField(db_index=True, max_length=255)), + ('value', models.TextField()), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'unique_together': {('user', 'key')}, + }, + ), + ] diff --git a/apps/badgeuser/migrations/0030_alter_userpreference_unique_together.py b/apps/badgeuser/migrations/0030_alter_userpreference_unique_together.py new file mode 100644 index 000000000..a338024e9 --- /dev/null +++ b/apps/badgeuser/migrations/0030_alter_userpreference_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2025-09-18 13:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('badgeuser', '0029_userpreference'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='userpreference', + unique_together={('user', 'key')}, + ), + ] diff --git a/apps/badgeuser/migrations/0030_badgeuser_zip_code.py b/apps/badgeuser/migrations/0030_badgeuser_zip_code.py new file mode 100644 index 000000000..ac0e4d36d --- /dev/null +++ b/apps/badgeuser/migrations/0030_badgeuser_zip_code.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2025-11-04 16:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('badgeuser', '0029_userpreference_squashed_0030_alter_userpreference_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='badgeuser', + name='zip_code', + field=models.CharField(blank=True, default=None, max_length=100), + ), + ] diff --git a/apps/badgeuser/migrations/0030_badgeuser_zip_code_squashed_0031_alter_badgeuser_zip_code.py b/apps/badgeuser/migrations/0030_badgeuser_zip_code_squashed_0031_alter_badgeuser_zip_code.py new file mode 100644 index 000000000..7f6be04ed --- /dev/null +++ b/apps/badgeuser/migrations/0030_badgeuser_zip_code_squashed_0031_alter_badgeuser_zip_code.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2 on 2025-11-04 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + replaces = [('badgeuser', '0030_badgeuser_zip_code'), ('badgeuser', '0031_alter_badgeuser_zip_code')] + + dependencies = [ + ('badgeuser', '0029_userpreference_squashed_0030_alter_userpreference_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='badgeuser', + name='zip_code', + field=models.CharField(blank=True, default=None, max_length=100, null=True), + ), + ] diff --git a/apps/badgeuser/migrations/0031_alter_badgeuser_zip_code.py b/apps/badgeuser/migrations/0031_alter_badgeuser_zip_code.py new file mode 100644 index 000000000..6b7108a89 --- /dev/null +++ b/apps/badgeuser/migrations/0031_alter_badgeuser_zip_code.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2025-11-04 16:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('badgeuser', '0030_badgeuser_zip_code'), + ] + + operations = [ + migrations.AlterField( + model_name='badgeuser', + name='zip_code', + field=models.CharField(blank=True, default=None, max_length=100, null=True), + ), + ] diff --git a/apps/badgeuser/models.py b/apps/badgeuser/models.py index f342583ad..215277187 100644 --- a/apps/badgeuser/models.py +++ b/apps/badgeuser/models.py @@ -1,11 +1,6 @@ - - -import base64 -import re from itertools import chain import cachemodel -import datetime from allauth.account.models import EmailAddress, EmailConfirmation from basic_models.models import IsActive from django.core.cache import cache @@ -15,20 +10,27 @@ from django.core.mail import send_mail from django.core.validators import URLValidator, RegexValidator from django.db import models, transaction -from django.utils.translation import ugettext_lazy as _ -from oauth2_provider.models import Application +from django.utils.translation import gettext_lazy as _ from rest_framework.authtoken.models import Token from backpack.models import BackpackCollection -from badgeuser.tasks import process_post_recipient_id_deletion, process_post_recipient_id_verification_change +from badgeuser.tasks import ( + process_post_recipient_id_deletion, + process_post_recipient_id_verification_change, +) from entity.models import BaseVersionedEntity -from issuer.models import Issuer, BadgeInstance, BaseAuditedModel, BaseAuditedModelDeletedWithUser +from issuer.models import ( + Issuer, + BadgeInstance, + BaseAuditedModel, + BaseAuditedModelDeletedWithUser, +) from badgeuser.managers import CachedEmailAddressManager, BadgeUserManager from badgeuser.utils import generate_badgr_username from mainsite.models import ApplicationInfo -AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') +AUTH_USER_MODEL = getattr(settings, "AUTH_USER_MODEL", "auth.User") class CachedEmailAddress(EmailAddress, cachemodel.CacheModel): @@ -61,13 +63,13 @@ def set_last_verification_sent_time(self, new_datetime): def publish(self): super(CachedEmailAddress, self).publish() - self.publish_by('email') + self.publish_by("email") self.user.publish() def delete(self, *args, **kwargs): user = self.user - self.publish_delete('email') - self.publish_delete('pk') + self.publish_delete("email") + self.publish_delete("pk") process_post_recipient_id_deletion.delay(self.email) super(CachedEmailAddress, self).delete(*args, **kwargs) user.publish() @@ -84,8 +86,13 @@ def set_as_primary(self, conditional=False): def save(self, *args, **kwargs): super(CachedEmailAddress, self).save(*args, **kwargs) - process_post_recipient_id_verification_change.delay(self.email, 'email', self.verified) - if not self.emailaddressvariant_set.exists() and self.email != self.email.lower(): + process_post_recipient_id_verification_change.delay( + self.email, "email", self.verified + ) + if ( + not self.emailaddressvariant_set.exists() + and self.email != self.email.lower() + ): self.add_variant(self.email.lower()) @cachemodel.cached_method(auto_publish=True) @@ -104,7 +111,9 @@ def add_variant(self, email_variation): canonical_email=self, email=email_variation ) else: - raise ValidationError("Email variant {} already exists".format(email_variation)) + raise ValidationError( + "Email variant {} already exists".format(email_variation) + ) class ProxyEmailConfirmation(EmailConfirmation): @@ -116,8 +125,9 @@ class Meta: class EmailAddressVariant(models.Model): email = models.EmailField(blank=False) - canonical_email = models.ForeignKey(CachedEmailAddress, blank=False, - on_delete=models.CASCADE) + canonical_email = models.ForeignKey( + CachedEmailAddress, blank=False, on_delete=models.CASCADE + ) def save(self, *args, **kwargs): self.is_valid(raise_exception=True) @@ -159,27 +169,30 @@ class UserRecipientIdentifier(cachemodel.CacheModel): In the long term, this should be extended to support email address identifiers as well. """ - IDENTIFIER_TYPE_URL = 'url' - IDENTIFIER_TYPE_TELEPHONE = 'telephone' + IDENTIFIER_TYPE_URL = "url" + IDENTIFIER_TYPE_TELEPHONE = "telephone" IDENTIFIER_TYPE_CHOICES = ( - (IDENTIFIER_TYPE_URL, 'URL'), - (IDENTIFIER_TYPE_TELEPHONE, 'Phone Number'), + (IDENTIFIER_TYPE_URL, "URL"), + (IDENTIFIER_TYPE_TELEPHONE, "Phone Number"), ) IDENTIFIER_VALIDATORS = { IDENTIFIER_TYPE_URL: (URLValidator(),), - IDENTIFIER_TYPE_TELEPHONE: (RegexValidator( - regex=r"^\+[1-9]\d{1,14}$", - message="Enter a valid Phone Number in E.164 format, like +12225553333" - ),), + IDENTIFIER_TYPE_TELEPHONE: ( + RegexValidator( + regex=r"^\+[1-9]\d{1,14}$", + message="Enter a valid Phone Number in E.164 format, like +12225553333", + ), + ), } - type = models.CharField(max_length=9, choices=IDENTIFIER_TYPE_CHOICES, default=IDENTIFIER_TYPE_URL) + type = models.CharField( + max_length=9, choices=IDENTIFIER_TYPE_CHOICES, default=IDENTIFIER_TYPE_URL + ) identifier = models.CharField(max_length=255) - user = models.ForeignKey(AUTH_USER_MODEL, - on_delete=models.CASCADE) + user = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE) verified = models.BooleanField(default=False) class Meta: - unique_together = ('user', 'type', 'identifier') + unique_together = ("user", "type", "identifier") def get_identifier_validators(self): return UserRecipientIdentifier.IDENTIFIER_VALIDATORS[self.type] @@ -190,11 +203,15 @@ def validate_identifier(self): validator(self.identifier) # regardless of format, only one user may have verified a given identifier - if self.verified and UserRecipientIdentifier.objects\ - .filter(identifier=self.identifier, type=self.type, verified=True)\ - .exclude(pk=self.pk)\ - .exists(): - raise ValidationError('Identifier already verified by another user.') + if ( + self.verified + and UserRecipientIdentifier.objects.filter( + identifier=self.identifier, type=self.type, verified=True + ) + .exclude(pk=self.pk) + .exists() + ): + raise ValidationError("Identifier already verified by another user.") def clean_fields(self, exclude=None): super(UserRecipientIdentifier, self).clean_fields(exclude=exclude) @@ -203,15 +220,16 @@ def clean_fields(self, exclude=None): def save(self, *args, **kwargs): self.validate_identifier() super(UserRecipientIdentifier, self).save(*args, **kwargs) - process_post_recipient_id_verification_change.delay(self.identifier, self.type, self.verified) - + process_post_recipient_id_verification_change.delay( + self.identifier, self.type, self.verified + ) def publish(self): super(UserRecipientIdentifier, self).publish() self.user.publish() def delete(self): - self.publish_delete('identifier') + self.publish_delete("identifier") super(UserRecipientIdentifier, self).delete() process_post_recipient_id_deletion.delay(self.identifier) @@ -220,24 +238,37 @@ class BadgeUser(BaseVersionedEntity, AbstractUser, cachemodel.CacheModel): """ A full-featured user model that can be an Earner, Issuer, or Consumer of Open Badges """ - entity_class_name = 'BadgeUser' - USERNAME_FIELD = 'username' - REQUIRED_FIELDS = ['email'] + entity_class_name = "BadgeUser" - badgrapp = models.ForeignKey('mainsite.BadgrApp', blank=True, null=True, default=None, on_delete=models.SET_NULL) + USERNAME_FIELD = "username" + REQUIRED_FIELDS = ["email"] + + badgrapp = models.ForeignKey( + "mainsite.BadgrApp", + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + ) marketing_opt_in = models.BooleanField(default=False) + secure_password_set = models.BooleanField(default=True) + + zip_code = models.CharField(max_length=100, blank=True, default=None, null=True) + objects = BadgeUserManager() class Meta: - verbose_name = _('badge user') - verbose_name_plural = _('badge users') - db_table = 'users' + verbose_name = _("badge user") + verbose_name_plural = _("badge users") + db_table = "users" def __str__(self): - primary_identifier = self.email or next((e for e in self.all_verified_recipient_identifiers), '') + primary_identifier = self.email or next( + (e for e in self.all_verified_recipient_identifiers), "" + ) return "{} ({})".format(self.get_full_name(), primary_identifier) def get_full_name(self): @@ -251,32 +282,53 @@ def email_user(self, subject, message, from_email=None, **kwargs): def publish(self): super(BadgeUser, self).publish() - self.publish_by('username') + self.publish_by("username") def delete(self, *args, **kwargs): + # If the user is staff for an institution, the ownership needs to be transferred. + # Therefore we remember the pks of the relevant institutions + staff_contact_pks = [ + o.pk for o in self.issuerstaff_set.all() if o.is_staff_contact() + ] + staff_issuers = Issuer.objects.filter(issuerstaff__in=staff_contact_pks) + staff_issuers_pks = [o.pk for o in staff_issuers] + cached_emails = self.cached_emails() if cached_emails.exists(): for email in cached_emails: email.delete() super(BadgeUser, self).delete(*args, **kwargs) - self.publish_delete('username') + + # Here we transfer the ownership by restoring a valid contact email + # (which also makes sure ownership exists) + staff_issuers = Issuer.objects.filter(pk__in=staff_issuers_pks) + for staff_issuer in staff_issuers: + staff_issuer.new_contact_email() + + self.publish_delete("username") @cachemodel.cached_method(auto_publish=True) def cached_verified_urls(self): return [ - r.identifier for r in - self.userrecipientidentifier_set.filter( - verified=True, type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL)] + r.identifier + for r in self.userrecipientidentifier_set.filter( + verified=True, type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL + ) + ] @cachemodel.cached_method(auto_publish=True) def cached_verified_phone_numbers(self): return [ - r.identifier for r in - self.userrecipientidentifier_set.filter( - verified=True, type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE)] + r.identifier + for r in self.userrecipientidentifier_set.filter( + verified=True, type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE + ) + ] @cachemodel.cached_method(auto_publish=True) def cached_emails(self): + if not self.pk: + return [] return CachedEmailAddress.objects.filter(user=self) @cachemodel.cached_method(auto_publish=True) @@ -296,40 +348,44 @@ def email_items(self, value): """ return self.set_email_items(value) - def set_email_items(self, value, send_confirmations=True, allow_verify=False, is_privileged_user=False): + def set_email_items( + self, + value, + send_confirmations=True, + allow_verify=False, + is_privileged_user=False, + ): """ - If value is empty and the user is privileged, assume they meant to remove the users email and allow it. - E.g. They are preforming some sort of ELT use case + If value is empty and the user is privileged, assume they meant to remove the users email and allow it. + E.g. They are preforming some sort of ELT use case """ if is_privileged_user and len(value) == 0: for email_address in self.email_items: email_address.delete() - self.email = '' + self.email = "" self.save() else: if len(value) < 1: raise ValidationError("Must have at least 1 email") - new_email_idx = {d['email']: d for d in value} + new_email_idx = {d["email"]: d for d in value} - primary_count = sum(1 if d.get('primary', False) else 0 for d in value) + primary_count = sum(1 if d.get("primary", False) else 0 for d in value) if primary_count != 1: raise ValidationError("Must have exactly 1 primary email") - requested_primary = [d for d in value if d.get('primary', False)][0] + requested_primary = [d for d in value if d.get("primary", False)][0] with transaction.atomic(): # add or update existing items for email_data in value: - primary = email_data.get('primary', False) - verified = email_data.get('verified', False) + primary = email_data.get("primary", False) + verified = email_data.get("verified", False) emailaddress, created = CachedEmailAddress.cached.get_or_create( - email=email_data['email'], - defaults={ - 'user': self, - 'primary': primary - }) + email=email_data["email"], + defaults={"user": self, "primary": primary}, + ) if not created: dirty = False @@ -345,7 +401,11 @@ def set_email_items(self, value, send_confirmations=True, allow_verify=False, is emailaddress.send_confirmation() else: # existing email address used by someone else - raise ValidationError("Email '{}' may already be in use".format(email_data.get('email'))) + raise ValidationError( + "Email '{}' may already be in use".format( + email_data.get("email") + ) + ) if allow_verify and verified != emailaddress.verified: emailaddress.verified = verified @@ -355,17 +415,21 @@ def set_email_items(self, value, send_confirmations=True, allow_verify=False, is emailaddress.save() else: # email is new - if allow_verify and email_data.get('verified') is True: + if allow_verify and email_data.get("verified") is True: emailaddress.verified = True emailaddress.save() - if emailaddress.verified is False and created is True and send_confirmations is True: + if ( + emailaddress.verified is False + and created is True + and send_confirmations is True + ): # new email address send a confirmation emailaddress.send_confirmation() if not emailaddress.verified: continue # only verified email addresses may have variants. Don't bother trying otherwise. - requested_variants = email_data.get('cached_variant_emails', []) + requested_variants = email_data.get("cached_variant_emails", []) existing_variant_emails = emailaddress.cached_variant_emails() for requested_variant in requested_variants: if requested_variant not in existing_variant_emails: @@ -375,26 +439,35 @@ def set_email_items(self, value, send_confirmations=True, allow_verify=False, is # remove old items for emailaddress in self.email_items: - if emailaddress.email.lower() not in (lower_case_idx.lower() for lower_case_idx in new_email_idx): + if emailaddress.email.lower() not in ( + lower_case_idx.lower() for lower_case_idx in new_email_idx + ): emailaddress.delete() if self.email != requested_primary: - self.email = requested_primary['email'] + self.email = requested_primary["email"] self.save() - def cached_email_variants(self): - return chain.from_iterable(email.cached_variants() for email in self.cached_emails()) + return chain.from_iterable( + email.cached_variants() for email in self.cached_emails() + ) def can_add_variant(self, email): try: - canonical_email = CachedEmailAddress.objects.get(email=email, user=self, verified=True) + canonical_email = CachedEmailAddress.objects.get( + email=email, user=self, verified=True + ) except CachedEmailAddress.DoesNotExist: return False - if email != canonical_email.email \ - and email not in [e.email for e in canonical_email.cached_variants()] \ - and EmailAddressVariant(email=email, canonical_email=canonical_email).is_valid(): + if ( + email != canonical_email.email + and email not in [e.email for e in canonical_email.cached_variants()] + and EmailAddressVariant( + email=email, canonical_email=canonical_email + ).is_valid() + ): return True return False @@ -421,17 +494,21 @@ def verified(self): @property def all_recipient_identifiers(self): - return [e.email for e in self.cached_emails()] + \ - [e.email for e in self.cached_email_variants()] + \ - self.cached_verified_urls() + \ - self.cached_verified_phone_numbers() + return ( + [e.email for e in self.cached_emails()] + + [e.email for e in self.cached_email_variants()] + + self.cached_verified_urls() + + self.cached_verified_phone_numbers() + ) @property def all_verified_recipient_identifiers(self): - return ([e.email for e in self.cached_emails() if e.verified] - + [e.email for e in self.cached_email_variants()] - + self.cached_verified_urls() - + self.cached_verified_phone_numbers()) + return ( + [e.email for e in self.cached_emails() if e.verified] + + [e.email for e in self.cached_email_variants()] + + self.cached_verified_urls() + + self.cached_verified_phone_numbers() + ) def is_email_verified(self, email): if email in self.all_verified_recipient_identifiers: @@ -455,34 +532,41 @@ def peers(self): """ a BadgeUser is a Peer of another BadgeUser if they appear in an IssuerStaff together """ - return set(chain(*[[s.cached_user for s in i.cached_issuerstaff()] for i in self.cached_issuers()])) + return set( + chain( + *[ + [s.cached_user for s in i.cached_issuerstaff()] + for i in self.cached_issuers() + ] + ) + ) def cached_badgeclasses(self): - return chain.from_iterable(issuer.cached_badgeclasses() for issuer in self.cached_issuers()) + return chain.from_iterable( + issuer.cached_badgeclasses() for issuer in self.cached_issuers() + ) @cachemodel.cached_method(auto_publish=True) def cached_badgeinstances(self): - return BadgeInstance.objects.filter(recipient_identifier__in=self.all_recipient_identifiers) + return BadgeInstance.objects.filter( + recipient_identifier__in=self.all_recipient_identifiers + ) @cachemodel.cached_method(auto_publish=True) def get_badges_from_user(self): - print(BadgeInstance.objects) - return BadgeInstance.objects.filter(recipient_identifier__in=self.all_recipient_identifiers) - - @cachemodel.cached_method(auto_publish=True) - def cached_externaltools(self): - return [a.cached_externaltool for a in self.externaltooluseractivation_set.filter(is_active=True)] + return BadgeInstance.objects.filter( + recipient_identifier__in=self.all_recipient_identifiers + ) @cachemodel.cached_method(auto_publish=True) def cached_token(self): - user_token, created = \ - Token.objects.get_or_create(user=self) + user_token, created = Token.objects.get_or_create(user=self) return user_token.key @cachemodel.cached_method(auto_publish=True) def cached_agreed_terms_version(self): try: - return self.termsagreement_set.all().order_by('-terms_version')[0] + return self.termsagreement_set.all().order_by("-terms_version")[0] except IndexError: pass return None @@ -498,14 +582,20 @@ def agreed_terms_version(self): def agreed_terms_version(self, value): try: value = int(value) - except ValueError as e: + except ValueError: return - if value > self.agreed_terms_version: + # if value > self.agreed_terms_version: + # Only automatically agree to the latest terms version during signup + try: + CachedEmailAddress.cached.get(email=self.primary_email) + except CachedEmailAddress.DoesNotExist: if TermsVersion.active_objects.filter(version=value).exists(): if not self.pk: self.save() - self.termsagreement_set.get_or_create(terms_version=value, defaults=dict(agreed=True)) + self.termsagreement_set.get_or_create( + terms_version=value, defaults=dict(agreed=True) + ) def replace_token(self): Token.objects.filter(user=self).delete() @@ -518,11 +608,15 @@ def save(self, *args, **kwargs): if not self.username: self.username = generate_badgr_username(self.email) - if getattr(settings, 'BADGEUSER_SKIP_LAST_LOGIN_TIME', True): + if getattr(settings, "BADGEUSER_SKIP_LAST_LOGIN_TIME", True): # skip saving last_login to the database - if 'update_fields' in kwargs and kwargs['update_fields'] is not None and 'last_login' in kwargs['update_fields']: - kwargs['update_fields'].remove('last_login') - if len(kwargs['update_fields']) < 1: + if ( + "update_fields" in kwargs + and kwargs["update_fields"] is not None + and "last_login" in kwargs["update_fields"] + ): + kwargs["update_fields"].remove("last_login") + if len(kwargs["update_fields"]) < 1: # nothing to do, abort so we dont call .publish() return return super(BadgeUser, self).save(*args, **kwargs) @@ -539,7 +633,7 @@ def latest_version(self): def latest(self): try: - return self.filter(is_active=True).order_by('-version')[0] + return self.filter(is_active=True).order_by("-version")[0] except IndexError: pass @@ -567,11 +661,19 @@ def publish(self): class TermsAgreement(BaseAuditedModelDeletedWithUser, cachemodel.CacheModel): - user = models.ForeignKey('badgeuser.BadgeUser', - on_delete=models.CASCADE) + user = models.ForeignKey("badgeuser.BadgeUser", on_delete=models.CASCADE) terms_version = models.PositiveIntegerField() agreed = models.BooleanField(default=True) class Meta: - ordering = ('-terms_version',) - unique_together = ('user', 'terms_version') + ordering = ("-terms_version",) + unique_together = ("user", "terms_version") + + +class UserPreference(BaseAuditedModelDeletedWithUser, cachemodel.CacheModel): + user = models.ForeignKey("badgeuser.BadgeUser", on_delete=models.CASCADE) + key = models.CharField(max_length=255, db_index=True) + value = models.TextField() + + class Meta: + unique_together = ("user", "key") diff --git a/apps/badgeuser/serializers_v1.py b/apps/badgeuser/serializers_v1.py index 00293fe87..a2b2e939b 100644 --- a/apps/badgeuser/serializers_v1.py +++ b/apps/badgeuser/serializers_v1.py @@ -1,25 +1,22 @@ -from django.conf import settings from django.contrib.auth.hashers import is_password_usable from rest_framework import serializers -from collections import OrderedDict -from mainsite.models import BadgrApp from mainsite.serializers import StripTagsCharField from mainsite.validators import PasswordValidator +from mainsite.utils import validate_altcha from .models import BadgeUser, CachedEmailAddress, TermsVersion from .utils import notify_on_password_change +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes class BadgeUserTokenSerializerV1(serializers.Serializer): - class Meta: - apispec_definition = ('BadgeUserToken', {}) - def to_representation(self, instance): representation = { - 'username': instance.username, - 'token': instance.cached_token() + "username": instance.username, + "token": instance.cached_token(), } - if self.context.get('tokenReplaced', False): - representation['replace'] = True + if self.context.get("tokenReplaced", False): + representation["replace"] = True return representation def update(self, instance, validated_data): @@ -38,48 +35,60 @@ def to_representation(self, obj): class BadgeUserProfileSerializerV1(serializers.Serializer): first_name = StripTagsCharField(max_length=30, allow_blank=True) last_name = StripTagsCharField(max_length=30, allow_blank=True) - email = serializers.EmailField(source='primary_email', required=False, allow_blank=True, allow_null=True) - url = serializers.ListField(read_only=True, source='cached_verified_urls') - telephone = serializers.ListField(read_only=True, source='cached_verified_phone_numbers') - current_password = serializers.CharField(style={'input_type': 'password'}, write_only=True, required=False) - password = serializers.CharField(style={'input_type': 'password'}, write_only=True, required=False, validators=[PasswordValidator()]) - slug = serializers.CharField(source='entity_id', read_only=True) + email = serializers.EmailField( + source="primary_email", required=False, allow_blank=True, allow_null=True + ) + url = serializers.ListField(read_only=True, source="cached_verified_urls") + telephone = serializers.ListField( + read_only=True, source="cached_verified_phone_numbers" + ) + current_password = serializers.CharField( + style={"input_type": "password"}, write_only=True, required=False + ) + password = serializers.CharField( + style={"input_type": "password"}, + write_only=True, + required=False, + validators=[PasswordValidator()], + ) + slug = serializers.CharField(source="entity_id", read_only=True) + zip_code = serializers.CharField(required=False, allow_blank=True, allow_null=True) agreed_terms_version = serializers.IntegerField(required=False) marketing_opt_in = serializers.BooleanField(required=False) has_password_set = serializers.SerializerMethodField() + secure_password_set = serializers.BooleanField(required=False) source = serializers.CharField(write_only=True, required=False) + @extend_schema_field(OpenApiTypes.BOOL) def get_has_password_set(self, obj): return is_password_usable(obj.password) - class Meta: - apispec_definition = ('BadgeUser', { - 'properties': OrderedDict([ - ('source', { - 'type': "string", - 'format': "string", - 'description': "Ex: mozilla", - }), - ]) - }) - def create(self, validated_data): - user = BadgeUser.objects.create( - email=validated_data.get('primary_email'), - first_name=validated_data['first_name'], - last_name=validated_data['last_name'], - plaintext_password=validated_data['password'], - marketing_opt_in=validated_data.get('marketing_opt_in', False), - request=self.context.get('request', None), - source=validated_data.get('source', ''), - ) - return user + captcha = self.context.get("captcha") + + if captcha is not None: + if validate_altcha(captcha, self.context.get("request", None)): + user = BadgeUser.objects.create( + email=validated_data.get("primary_email"), + first_name=validated_data["first_name"], + last_name=validated_data["last_name"], + plaintext_password=validated_data["password"], + marketing_opt_in=validated_data.get("marketing_opt_in", False), + zip_code=validated_data.get("zip_code", None), + request=self.context.get("request", None), + source=validated_data.get("source", ""), + ) + return user + else: + raise serializers.ValidationError("Invalid captcha") + else: + raise serializers.ValidationError("Captcha required") def update(self, user, validated_data): - first_name = validated_data.get('first_name') - last_name = validated_data.get('last_name') - password = validated_data.get('password') - current_password = validated_data.get('current_password') + first_name = validated_data.get("first_name") + last_name = validated_data.get("last_name") + password = validated_data.get("password") + current_password = validated_data.get("current_password") if first_name: user.first_name = first_name @@ -88,30 +97,42 @@ def update(self, user, validated_data): if password: if not current_password: - raise serializers.ValidationError({'current_password': "Field is required"}) + raise serializers.ValidationError( + {"current_password": "Field is required"} + ) if user.check_password(current_password): user.set_password(password) + user.secure_password_set = True notify_on_password_change(user) else: - raise serializers.ValidationError({'current_password': "Incorrect password"}) + raise serializers.ValidationError( + {"current_password": "Incorrect password"} + ) - if 'agreed_terms_version' in validated_data: - user.agreed_terms_version = validated_data.get('agreed_terms_version') + if "agreed_terms_version" in validated_data: + user.termsagreement_set.get_or_create( + terms_version=validated_data.get("agreed_terms_version") + ) - if 'marketing_opt_in' in validated_data: - user.marketing_opt_in = validated_data.get('marketing_opt_in') + if "marketing_opt_in" in validated_data: + user.marketing_opt_in = validated_data.get("marketing_opt_in") + + if "zip_code" in validated_data: + user.zip_code = validated_data.get("zip_code") user.save() return user def to_representation(self, instance): - representation = super(BadgeUserProfileSerializerV1, self).to_representation(instance) + representation = super(BadgeUserProfileSerializerV1, self).to_representation( + instance + ) latest = TermsVersion.cached.cached_latest() if latest: - representation['latest_terms_version'] = latest.version + representation["latest_terms_version"] = latest.version if latest.version != instance.agreed_terms_version: - representation['latest_terms_description'] = latest.short_description + representation["latest_terms_description"] = latest.short_description return representation @@ -119,20 +140,20 @@ def to_representation(self, instance): class EmailSerializerV1(serializers.ModelSerializer): variants = serializers.ListField( child=serializers.EmailField(required=False), - required=False, source='cached_variants', allow_null=True, read_only=True + required=False, + source="cached_variants", + allow_null=True, + read_only=True, ) email = serializers.EmailField(required=True) class Meta: model = CachedEmailAddress - fields = ('id', 'email', 'verified', 'primary', 'variants') - read_only_fields = ('id', 'verified', 'primary', 'variants') - apispec_definition = ('BadgeUserEmail', { - - }) + fields = ("id", "email", "verified", "primary", "variants") + read_only_fields = ("id", "verified", "primary", "variants") def create(self, validated_data): - new_address = validated_data.get('email') + new_address = validated_data.get("email") created = False try: email = CachedEmailAddress.objects.get(email=new_address) @@ -145,15 +166,19 @@ def create(self, validated_data): email.delete() email = super(EmailSerializerV1, self).create(validated_data) created = True - elif email.user != self.context.get('request').user: + elif email.user != self.context.get("request").user: raise serializers.ValidationError("Could not register email address.") - if new_address != email.email and new_address not in [v.email for v in email.cached_variants()]: + if new_address != email.email and new_address not in [ + v.email for v in email.cached_variants() + ]: email.add_variant(new_address) - raise serializers.ValidationError("Matching address already exists. New case variant registered.") + raise serializers.ValidationError( + "Matching address already exists. New case variant registered." + ) - if validated_data.get('variants'): - for variant in validated_data.get('variants'): + if validated_data.get("variants"): + for variant in validated_data.get("variants"): try: email.add_variant(variant) except serializers.ValidationError: @@ -166,10 +191,10 @@ def create(self, validated_data): class BadgeUserIdentifierFieldV1(serializers.CharField): def __init__(self, *args, **kwargs): - if 'source' not in kwargs: - kwargs['source'] = 'created_by_id' - if 'read_only' not in kwargs: - kwargs['read_only'] = True + if "source" not in kwargs: + kwargs["source"] = "created_by_id" + if "read_only" not in kwargs: + kwargs["read_only"] = True super(BadgeUserIdentifierFieldV1, self).__init__(*args, **kwargs) def to_representation(self, value): @@ -177,4 +202,3 @@ def to_representation(self, value): return BadgeUser.cached.get(pk=value).primary_email except BadgeUser.DoesNotExist: return None - diff --git a/apps/badgeuser/serializers_v2.py b/apps/badgeuser/serializers_v2.py index 4c838cd2a..c01caad6b 100644 --- a/apps/badgeuser/serializers_v2.py +++ b/apps/badgeuser/serializers_v2.py @@ -1,6 +1,3 @@ -import base64 -from collections import OrderedDict - from rest_framework import serializers from django.contrib.auth.hashers import is_password_usable @@ -8,7 +5,11 @@ from badgeuser.utils import notify_on_password_change from entity.serializers import DetailSerializerV2, BaseSerializerV2, ListSerializerV2 from mainsite.models import BadgrApp -from mainsite.serializers import ApplicationInfoSerializer, DateTimeWithUtcZAtEndField, StripTagsCharField +from mainsite.serializers import ( + ApplicationInfoSerializer, + DateTimeWithUtcZAtEndField, + StripTagsCharField, +) from mainsite.validators import PasswordValidator @@ -16,41 +17,40 @@ class BadgeUserEmailSerializerV2(DetailSerializerV2): email = serializers.EmailField() verified = serializers.BooleanField(read_only=True) primary = serializers.BooleanField(required=False, default=False) - caseVariants = serializers.ListField(child=serializers.CharField(), required=False, source='cached_variant_emails') - - class Meta(DetailSerializerV2.Meta): - apispec_definition = ('BadgeUserEmail', { - 'properties': OrderedDict([ - ('email', { - 'type': "string", - 'format': "email", - 'description': "Email address associated with a BadgeUser", - }), - ('verified', { - 'type': "boolean", - 'description': "True if the email address has been verified", - }), - ('primary', { - 'type': "boolean", - 'description': "True for a single email address to receive email notifications", - }), - ]) - }) + caseVariants = serializers.ListField( + child=serializers.CharField(), required=False, source="cached_variant_emails" + ) class BadgeUserSerializerV2(DetailSerializerV2): - firstName = StripTagsCharField(source='first_name', max_length=30, allow_blank=True) - lastName = StripTagsCharField(source='last_name', max_length=150, allow_blank=True) - password = serializers.CharField(style={'input_type': 'password'}, write_only=True, required=False, validators=[PasswordValidator()]) - currentPassword = serializers.CharField(style={'input_type': 'password'}, write_only=True, required=False) - emails = BadgeUserEmailSerializerV2(many=True, source='email_items', required=False) - url = serializers.ListField(read_only=True, source='cached_verified_urls') - telephone = serializers.ListField(read_only=True, source='cached_verified_phone_numbers') - agreedTermsVersion = serializers.IntegerField(source='agreed_terms_version', required=False) + firstName = StripTagsCharField(source="first_name", max_length=30, allow_blank=True) + lastName = StripTagsCharField(source="last_name", max_length=150, allow_blank=True) + password = serializers.CharField( + style={"input_type": "password"}, + write_only=True, + required=False, + validators=[PasswordValidator()], + ) + currentPassword = serializers.CharField( + style={"input_type": "password"}, write_only=True, required=False + ) + emails = BadgeUserEmailSerializerV2(many=True, source="email_items", required=False) + url = serializers.ListField(read_only=True, source="cached_verified_urls") + telephone = serializers.ListField( + read_only=True, source="cached_verified_phone_numbers" + ) + agreedTermsVersion = serializers.IntegerField( + source="agreed_terms_version", required=False + ) hasAgreedToLatestTermsVersion = serializers.SerializerMethodField(read_only=True) - marketingOptIn = serializers.BooleanField(source='marketing_opt_in', required=False) - badgrDomain = serializers.CharField(read_only=True, max_length=255, source='badgrapp') - hasPasswordSet = serializers.SerializerMethodField('get_has_password_set') + marketingOptIn = serializers.BooleanField(source="marketing_opt_in", required=False) + badgrDomain = serializers.CharField( + read_only=True, max_length=255, source="badgrapp" + ) + securePasswordSet = serializers.BooleanField( + source="secure_password_set", required=False + ) + hasPasswordSet = serializers.SerializerMethodField("get_has_password_set") recipient = serializers.SerializerMethodField(read_only=True) def get_has_password_set(self, obj): @@ -59,8 +59,10 @@ def get_has_password_set(self, obj): def get_recipient(self, obj): primary_email = next((e for e in obj.cached_emails() if e.primary), None) if primary_email: - return dict(type='email', identity=primary_email.email) - identifier = obj.userrecipientidentifier_set.filter(verified=True).order_by('pk').first() + return dict(type="email", identity=primary_email.email) + identifier = ( + obj.userrecipientidentifier_set.filter(verified=True).order_by("pk").first() + ) if identifier: return dict(type=identifier.type, identity=identifier.identifier) return None @@ -71,46 +73,34 @@ def get_hasAgreedToLatestTermsVersion(self, obj): class Meta(DetailSerializerV2.Meta): model = BadgeUser - apispec_definition = ('BadgeUser', { - 'properties': OrderedDict([ - ('entityId', { - 'type': "string", - 'format': "string", - 'description': "Unique identifier for this BadgeUser", - }), - ('entityType', { - 'type': "string", - 'format': "string", - 'description': "\"BadgeUser\"", - }), - ('firstName', { - 'type': "string", - 'format': "string", - 'description': "Given name", - }), - ('lastName', { - 'type': "string", - 'format': "string", - 'description': "Family name", - }), - ]), - }) def update(self, instance, validated_data): - password = validated_data.pop('password') if 'password' in validated_data else None - current_password = validated_data.pop('currentPassword') if 'currentPassword' in validated_data else None + password = ( + validated_data.pop("password") if "password" in validated_data else None + ) + current_password = ( + validated_data.pop("currentPassword") + if "currentPassword" in validated_data + else None + ) super(BadgeUserSerializerV2, self).update(instance, validated_data) if password: if not current_password: - raise serializers.ValidationError({'current_password': "Field is required"}) + raise serializers.ValidationError( + {"current_password": "Field is required"} + ) if instance.check_password(current_password): instance.set_password(password) notify_on_password_change(instance) else: - raise serializers.ValidationError({'current_password': "Incorrect password"}) + raise serializers.ValidationError( + {"current_password": "Incorrect password"} + ) - instance.badgrapp = BadgrApp.objects.get_current(request=self.context.get('request', None)) + instance.badgrapp = BadgrApp.objects.get_current( + request=self.context.get("request", None) + ) instance.save() return instance @@ -120,32 +110,23 @@ def to_representation(self, instance): latest = TermsVersion.cached.cached_latest() if latest: - representation['latestTermsVersion'] = latest.version + representation["latestTermsVersion"] = latest.version if latest.version != instance.agreed_terms_version: - representation['latestTermsDescription'] = latest.short_description + representation["latestTermsDescription"] = latest.short_description - if not self.context.get('isSelf'): - fields_shown_only_to_self = ['emails'] + if not self.context.get("isSelf"): + fields_shown_only_to_self = ["emails"] for f in fields_shown_only_to_self: - if f in representation['result'][0]: - del representation['result'][0][f] + if f in representation["result"][0]: + del representation["result"][0][f] return representation class BadgeUserTokenSerializerV2(BaseSerializerV2): - token = serializers.CharField(read_only=True, source='cached_token') + token = serializers.CharField(read_only=True, source="cached_token") class Meta: list_serializer_class = ListSerializerV2 - apispec_definition = ('BadgeUserToken', { - 'properties': OrderedDict([ - ('token', { - 'type': "string", - 'format': "string", - 'description': "Access token to use in the Authorization header", - }), - ]) - }) def update(self, instance, validated_data): # noop @@ -153,18 +134,17 @@ def update(self, instance, validated_data): class AccessTokenSerializerV2(DetailSerializerV2): - application = ApplicationInfoSerializer(source='applicationinfo') + application = ApplicationInfoSerializer(source="applicationinfo") scope = serializers.CharField(read_only=True) expires = DateTimeWithUtcZAtEndField(read_only=True) created = DateTimeWithUtcZAtEndField(read_only=True) class Meta: list_serializer_class = ListSerializerV2 - apispec_definition = ('AccessToken', {}) class TermsVersionSerializerV2(DetailSerializerV2): version = serializers.IntegerField(read_only=True) - shortDescription = serializers.CharField(read_only=True, source='short_description') - created = DateTimeWithUtcZAtEndField(read_only=True, source='created_at') - updated = DateTimeWithUtcZAtEndField(read_only=True, source='updated_at') + shortDescription = serializers.CharField(read_only=True, source="short_description") + created = DateTimeWithUtcZAtEndField(read_only=True, source="created_at") + updated = DateTimeWithUtcZAtEndField(read_only=True, source="updated_at") diff --git a/apps/badgeuser/serializers_v3.py b/apps/badgeuser/serializers_v3.py new file mode 100644 index 000000000..0a4c51aff --- /dev/null +++ b/apps/badgeuser/serializers_v3.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from badgeuser.models import UserPreference + + +class PreferenceSerializerV3(serializers.ModelSerializer): + class Meta: + model = UserPreference + fields = ["key", "value"] diff --git a/apps/badgeuser/signals.py b/apps/badgeuser/signals.py index 65eccf61e..722222ed1 100644 --- a/apps/badgeuser/signals.py +++ b/apps/badgeuser/signals.py @@ -1,14 +1,12 @@ -import badgrlog - -badgrlogger = badgrlog.BadgrLogger() - +import logging +logger = logging.getLogger("Badgr.Events") def log_user_signed_up(sender, **kwargs): - badgrlogger.event(badgrlog.UserSignedUp(**kwargs)) + logger.debug("User '%s' signed up", kwargs.get("user").username) def log_email_confirmed(sender, **kwargs): - badgrlogger.event(badgrlog.EmailConfirmed(**kwargs)) + logger.debug("Confirmed email '%s'", kwargs.get("email_address").email) def handle_email_created(sender, instance=None, created=False, **kwargs): @@ -18,4 +16,4 @@ def handle_email_created(sender, instance=None, created=False, **kwargs): leaves user.cached_emails() empty. """ if created: - instance.user.publish_method('cached_emails') + instance.user.publish_method("cached_emails") diff --git a/apps/badgeuser/tasks.py b/apps/badgeuser/tasks.py index 18e52ebec..fdbb9324b 100644 --- a/apps/badgeuser/tasks.py +++ b/apps/badgeuser/tasks.py @@ -1,43 +1,44 @@ -from celery.utils.log import get_task_logger from django.conf import settings - -import badgrlog from mainsite.celery import app -from django.db import connection - -logger = get_task_logger(__name__) -badgrLogger = badgrlog.BadgrLogger() -email_task_queue_name = getattr(settings, 'BACKGROUND_TASK_QUEUE_NAME', 'default') +email_task_queue_name = getattr(settings, "BACKGROUND_TASK_QUEUE_NAME", "default") @app.task(bind=True, queue=email_task_queue_name) def process_email_verification(self, email_address_id): from badgeuser.models import CachedEmailAddress from issuer.models import BadgeInstance + try: email_address = CachedEmailAddress.cached.get(id=email_address_id) except CachedEmailAddress.DoesNotExist: return user = email_address.user - issuer_instances = BadgeInstance.objects.filter(recipient_identifier=email_address.email) + issuer_instances = BadgeInstance.objects.filter( + recipient_identifier=email_address.email + ) variants = list(email_address.cached_variants()) for i in issuer_instances: - if i.recipient_identifier not in variants and \ - i.recipient_identifier != email_address.email and \ - user.can_add_variant(i.recipient_identifier): + if ( + i.recipient_identifier not in variants + and i.recipient_identifier != email_address.email + and user.can_add_variant(i.recipient_identifier) + ): email_address.add_variant(i.recipient_identifier) @app.task(bind=True, queue=email_task_queue_name) def process_post_recipient_id_verification_change(self, identifier, type, verified): from issuer.models import BadgeInstance, get_user_or_none + if verified: user = get_user_or_none(identifier, type) if user: - BadgeInstance.objects.filter(recipient_identifier=identifier).update(user=user) + BadgeInstance.objects.filter(recipient_identifier=identifier).update( + user=user + ) else: BadgeInstance.objects.filter(recipient_identifier=identifier).update(user=None) for b in BadgeInstance.objects.filter(recipient_identifier=identifier): @@ -47,6 +48,7 @@ def process_post_recipient_id_verification_change(self, identifier, type, verifi @app.task(bind=True, queue=email_task_queue_name) def process_post_recipient_id_deletion(self, identifier): from issuer.models import BadgeInstance + BadgeInstance.objects.filter(recipient_identifier=identifier).update(user=None) for b in BadgeInstance.objects.filter(recipient_identifier=identifier): b.publish() diff --git a/apps/badgeuser/tests/__init__.py b/apps/badgeuser/tests/__init__.py index bca5f6749..e69de29bb 100644 --- a/apps/badgeuser/tests/__init__.py +++ b/apps/badgeuser/tests/__init__.py @@ -1 +0,0 @@ -# encoding: utf-8 diff --git a/apps/badgeuser/tests/test_terms.py b/apps/badgeuser/tests/test_terms.py new file mode 100644 index 000000000..52183d331 --- /dev/null +++ b/apps/badgeuser/tests/test_terms.py @@ -0,0 +1,16 @@ +from badgeuser.models import TermsVersion +from mainsite.tests.base import BadgrTestCase + + +class TermsVersionTests(BadgrTestCase): + def test_get_latest_terms_version(self): + self.assertEqual(TermsVersion.objects.count(), 0) + response = self.client.get("/v2/termsVersions/latest") + self.assertEqual(response.status_code, 404) + + latest = TermsVersion.cached.create(version=1, short_description="test data") + response = self.client.get("/v2/termsVersions/latest") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["result"][0]["shortDescription"], latest.short_description + ) diff --git a/apps/badgeuser/tests/test_user_profile.py b/apps/badgeuser/tests/test_user_profile.py new file mode 100644 index 000000000..009166eb1 --- /dev/null +++ b/apps/badgeuser/tests/test_user_profile.py @@ -0,0 +1,48 @@ +from mainsite.tests.base import BadgrTestCase +from badgeuser.models import EmailAddressVariant +import json + + +class UserProfileTests(BadgrTestCase): + def test_get_user_profile_with_email_variants(self): + user = self.setup_user(email="bobby@example.com", authenticate=True) + response = self.client.get("/v2/users/self") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["result"][0]["emails"][0]["caseVariants"], []) + email = user.cached_emails().first() + EmailAddressVariant.objects.create( + canonical_email=email, email="BOBBY@example.com" + ) + response = self.client.get("/v2/users/self") + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data["result"][0]["emails"][0]["caseVariants"], + ["BOBBY@example.com"], + ) + + def test_can_create_modify_and_delete_user_preference(self): + self.setup_user(email="bobby@example.com", authenticate=True) + + post_response = self.client.post( + "/v3/user/preferences/", + data=json.dumps({"key": "bar", "value": "[1,2,3]"}), + content_type="application/json", + ) + get_response = self.client.get("/v3/user/preferences/bar/") + update_response = self.client.post( + "/v3/user/preferences/", + data=json.dumps({"key": "bar", "value": "[1,2,3,4]"}), + content_type="application/json", + ) + get2_response = self.client.get("/v3/user/preferences/bar/") + delete_response = self.client.delete("/v3/user/preferences/bar/") + get3_response = self.client.get("/v3/user/preferences/bar/") + + self.assertEqual(post_response.status_code, 201) + self.assertEqual(get_response.status_code, 200) + self.assertEqual(get_response.content, b'{"key":"bar","value":"[1,2,3]"}') + self.assertEqual(update_response.status_code, 200) + self.assertEqual(get2_response.status_code, 200) + self.assertEqual(get2_response.content, b'{"key":"bar","value":"[1,2,3,4]"}') + self.assertEqual(delete_response.status_code, 204) + self.assertEqual(get3_response.status_code, 404) diff --git a/apps/badgeuser/tests/test_v2_api.py b/apps/badgeuser/tests/test_v2_api.py deleted file mode 100644 index 52eb08273..000000000 --- a/apps/badgeuser/tests/test_v2_api.py +++ /dev/null @@ -1,56 +0,0 @@ -from django.urls import reverse -from mainsite.tests import SetupIssuerHelper, BadgrTestCase -from django.utils import timezone -from oauth2_provider.models import Application, RefreshToken -from mainsite.models import AccessTokenProxy, ApplicationInfo -from badgeuser.models import TermsVersion, EmailAddressVariant - - -class AccessTokenHandling(SetupIssuerHelper, BadgrTestCase): - def test_token_deletion(self): - staff = self.setup_user(email='staff@example.com', authenticate=True) - client_app_user = self.setup_user(email='clientApp@example.com', token_scope='r:assertions') - app = Application.objects.create( - client_id='clientApp-authcode', client_secret='testsecret', authorization_grant_type='authorization-code', - user=client_app_user) - ApplicationInfo.objects.create(application=app, allowed_scopes='r:assertions', trust_email_verification=True) - - t = AccessTokenProxy.objects.create( - user=staff, scope='rw:issuer r:profile r:backpack', expires=timezone.now() + timezone.timedelta(hours=1), - token='123', application=app - ) - RefreshToken.objects.create(access_token=t, user_id=staff.pk, application_id=app.pk) - url = reverse('v2_api_access_token_list') - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - # Delete tokens in response - url = reverse('v2_api_access_token_detail', kwargs={'entity_id': response.data['result'][0]['entityId']}) - response = self.client.delete(url) - self.assertEqual(response.status_code, 204) - self.assertEqual(len(RefreshToken.objects.all()), 0) - - -class TermsVersionTests(BadgrTestCase): - def test_get_latest_terms_version(self): - self.assertEqual(TermsVersion.objects.count(), 0) - response = self.client.get('/v2/termsVersions/latest') - self.assertEqual(response.status_code, 404) - - latest = TermsVersion.cached.create(version=1, short_description='test data') - response = self.client.get('/v2/termsVersions/latest') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['result'][0]['shortDescription'], latest.short_description) - - -class UserProfileTests(BadgrTestCase): - def test_get_user_profile_with_email_variants(self): - user = self.setup_user(email='bobby@example.com', authenticate=True) - response = self.client.get('/v2/users/self') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['result'][0]['emails'][0]['caseVariants'], []) - email = user.cached_emails().first() - EmailAddressVariant.objects.create(canonical_email=email, email='BOBBY@example.com') - response = self.client.get('/v2/users/self') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['result'][0]['emails'][0]['caseVariants'], ['BOBBY@example.com']) diff --git a/apps/badgeuser/tests/tests.py b/apps/badgeuser/tests/tests.py deleted file mode 100644 index 64305bcf8..000000000 --- a/apps/badgeuser/tests/tests.py +++ /dev/null @@ -1,1056 +0,0 @@ -import random -import re - -import os - -from allauth.account.models import EmailConfirmation -from django.contrib.auth import SESSION_KEY -from django.core import mail -from django.core.cache import cache -from django.core.exceptions import ValidationError -from django.core.files.uploadedfile import SimpleUploadedFile -from django.urls import reverse -from django.test import override_settings -from django.utils import timezone - -from mainsite import TOP_DIR -from mainsite.models import Application, ApplicationInfo -from mock import patch -from rest_framework.authtoken.models import Token - -from badgeuser.models import ( - BadgeUser, UserRecipientIdentifier, EmailAddressVariant, CachedEmailAddress, TermsVersion) -from badgeuser.serializers_v1 import BadgeUserProfileSerializerV1 -from badgeuser.serializers_v2 import BadgeUserSerializerV2 -from issuer.models import BadgeClass, Issuer -from mainsite.models import BadgrApp, ApplicationInfo, AccessTokenProxy -from mainsite.tests.base import BadgrTestCase, SetupIssuerHelper -from mainsite.utils import backoff_cache_key - - - -class AuthTokenTests(BadgrTestCase): - - def test_create_user_auth_token(self): - """ - Ensure that get can create a token for a user that doesn't have one - and that it doesn't modify a token for a user that already has one. - """ - - self.setup_user(authenticate=True) - - response = self.client.get('/v1/user/auth-token') - self.assertEqual(response.status_code, 200) - token = response.data.get('token') - self.assertRegex(token, r'[\da-f]{40}') - - second_response = self.client.get('/v1/user/auth-token') - self.assertEqual(token, second_response.data.get('token')) - - def test_update_user_auth_token(self): - """ - Ensure that a PUT request updates a user token. - """ - # Create a token for the first time. - user = self.setup_user(authenticate=True) - - response = self.client.get('/v1/user/auth-token') - self.assertEqual(response.status_code, 200) - token = response.data.get('token') - self.assertRegex(token, r'[\da-f]{40}') - - # Ensure that token has changed. - second_response = self.client.put('/v1/user/auth-token') - self.assertNotEqual(token, second_response.data.get('token')) - self.assertTrue(second_response.data.get('replace')) - - self.assertEqual(user.cached_token(), second_response.data.get('token')) - self.assertEqual(Token.objects.get(user=user).key, user.cached_token()) - - -class UserCreateTests(BadgrTestCase): - def test_create_user(self): - user_data = { - 'first_name': 'Test', - 'last_name': 'User', - 'email': 'newuniqueuser1@example.com', - 'password': 'secr3t4nds3cur3' - } - - self.badgr_app.email_confirmation_redirect = 'http://test-badgr-ui.example.com/profile/' - self.badgr_app.save() - - response = self.client.post('/v1/user/profile', user_data) - - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - self.assertIn("signup=true", mail.outbox[0].body) - self.assertNotIn("source=mozilla", mail.outbox[0].body) - - launch_url = re.search("(?P/v1/[^\s]+)", mail.outbox[0].body).group("url") - response = self.client.get(launch_url) - self.assertEqual(response.status_code, 302) - redirect_url = response._headers['location'][1] - - self.assertIn('/welcome', redirect_url) - - - def test_create_user_from_mozilla(self): - user_data = { - 'first_name': 'Test', - 'last_name': 'User', - 'email': 'mozillauser@example.com', - 'password': 'secr3t4nds3cur3', - 'source': 'mozilla' - } - - response = self.client.post('/v1/user/profile', user_data) - - self.assertEqual(response.status_code, 201) - self.assertIn("source=mozilla", mail.outbox[0].body) - self.assertIn("signup=true", mail.outbox[0].body) - - def test_user_can_add_secondary_email_without_welcome_query_param(self): - email = "unclaimed3@example.com" - first_user = self.setup_user(authenticate=False) - CachedEmailAddress.objects.create(user=first_user, email=email, primary=False, verified=False) - second_user = self.setup_user(email='second@user.fake', authenticate=True) - response = self.client.post('/v1/user/emails', {'email': email}) - self.assertEqual(response.status_code, 201) - self.assertNotIn("signup=true", mail.outbox[0].body) - - def test_create_user_with_already_claimed_email(self): - email = 'test2@example.com' - user_data = { - 'first_name': 'Test', - 'last_name': 'User', - 'email': email, - 'password': '123456' - } - existing_user = self.setup_user(email=email, authenticate=False, create_email_address=True) - - response = self.client.post('/v1/user/profile', user_data) - - self.assertEqual(response.status_code, 400) - self.assertEqual(len(mail.outbox), 0) - - def test_can_create_user_with_preexisting_unconfirmed_email(self): - email = 'unclaimed1@example.com' - user_data = { - 'first_name': 'NEW Test', - 'last_name': 'User', - 'email': email, - 'password': 'secr3t4nds3cur3' - } - - # create an existing user that owns email -- but unverified - existing_user = self.setup_user(email=email, password='secret', authenticate=False, verified=False) - existing_user_pk = existing_user.pk - existing_email = existing_user.cached_emails()[0] - self.assertEqual(existing_email.email, email) - self.assertFalse(existing_email.verified) - - # attempt to signup with the same email - response = self.client.post('/v1/user/profile', user_data) - - # should work successfully and a confirmation email sent - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - - # the user with this email should be the new signup - new_user = BadgeUser.objects.get(email=email) - self.assertEqual(new_user.email, email) - self.assertEqual(new_user.first_name, user_data.get('first_name')) - self.assertEqual(new_user.last_name, user_data.get('last_name')) - existing_email = CachedEmailAddress.objects.get(email=email) - self.assertEqual(existing_email.user, new_user) - - # the old user should no longer exist - with self.assertRaises(BadgeUser.DoesNotExist): - old_user = BadgeUser.objects.get(pk=existing_user_pk) - - def test_user_can_add_secondary_email_of_preexisting_unclaimed_email(self): - email = "unclaimed2@example.com" - first_user = self.setup_user(authenticate=False) - CachedEmailAddress.objects.create(user=first_user, email=email, primary=False, verified=False) - - second_user = self.setup_user(email='second@user.fake', authenticate=True) - response = self.client.post('/v1/user/emails', {'email': email}) - self.assertEqual(response.status_code, 201) - - def test_can_create_account_with_same_email_since_deleted(self): - email = 'unclaimed1@example.com' - new_email = 'newjunkeremail@junk.net' - first_user_data = user_data = { - 'first_name': 'NEW Test', - 'last_name': 'User', - 'email': email, - 'password': 'secr3t4nds3cur3' - } - response = self.client.post('/v1/user/profile', user_data) - - first_user = BadgeUser.objects.get(email=email) - first_email = CachedEmailAddress.objects.get(email=email) - first_email.verified = True - first_email.save() - - second_email = CachedEmailAddress(email=new_email, user=first_user, verified=True) - second_email.save() - - self.assertEqual(len(first_user.cached_emails()), 2) - - self.client.force_authenticate(user=first_user) - response = self.client.put( - reverse('v1_api_user_email_detail', args=[second_email.pk]), - {'primary': True} - ) - self.assertEqual(response.status_code, 200) - - # Reload user and emails - first_user = BadgeUser.objects.get(email=new_email) - first_email = CachedEmailAddress.objects.get(email=email) - second_email = CachedEmailAddress.objects.get(email=new_email) - - self.assertEqual(first_user.email, new_email) - self.assertTrue(second_email.primary) - self.assertFalse(first_email.primary) - - self.assertTrue(email in [e.email for e in first_user.cached_emails()]) - first_email.delete() - self.assertFalse(email in [e.email for e in first_user.cached_emails()]) - - user_data['name'] = 'NEWEST Test' - self.client.force_authenticate(user=None) - response = self.client.post('/v1/user/profile', user_data) - - self.assertEqual(response.status_code, 201) - - def test_shouldnt_error_when_user_exists_with_email(self): - email = 'existing3@example.test' - - old_user = self.setup_user(email=email, password='secret2') # password is set because its an existing user - - response = self.client.post('/v1/user/profile', { - 'first_name': 'existing', - 'last_name': 'user', - 'password': 'secret', - 'email': email - }) - self.assertEqual(response.status_code, 400) - self.assertEqual(len(mail.outbox), 0) - - def test_autocreated_user_can_signup(self): - email = 'existing4@example.test' - - old_user = self.setup_user(email=email, password=None, create_email_address=False) # no password set - - response = self.client.post('/v1/user/profile', { - 'first_name': 'existing', - 'last_name': 'user', - 'password': 'secr3t4nds3cur3', - 'email': email - }) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - - def test_should_signup_with_email_with_plus(self): - response = self.client.post('/v1/user/profile', { - 'first_name': 'existing', - 'last_name': 'user', - 'password': 'secr3t4nds3cur3', - 'email': 'nonexistent23+extra@test.nonexistent' - }) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - - def test_should_signup_with_email_with_uc_email(self): - response = self.client.post('/v1/user/profile', { - 'first_name': 'existing', - 'last_name': 'user', - 'password': 'secr3t4nds3cur3', - 'email': 'VERYNONEXISTENT@test.nonexistent' - }) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - - def test_autocreated_user_signup(self): - """ - Sometimes admins have a need to manually create users and grant them auth tokens - where their primary email is marked verified, but no password is set. For these - users, the signup flow should proceed normally. - """ - badgrapp = BadgrApp.objects.first() - badgrapp.ui_login_redirect = 'http://testui.test/auth/login/' - badgrapp.email_confirmation_redirect = 'http://testui.test/auth/login/' - badgrapp.save() - - user = BadgeUser( - email='testuser123@example.test' - ) - user.save() - email = CachedEmailAddress.cached.create(user=user, email=user.email, verified=True) - - user_data = { - 'first_name': 'Usery', - 'last_name': 'McUserface', - 'password': 'secr3t4nds3cur3', - 'email': user.email - } - response = self.client.post('/v1/user/profile', user_data) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - - verify_url = re.search("(?P/v2/[^\s]+)", mail.outbox[0].body).group("url") - response = self.client.get(verify_url[:-5]) - self.assertEqual(response.status_code, 302) - self.assertNotIn(user_data['first_name'], response._headers['location'][1]) - - response = self.client.get(verify_url) - self.assertEqual(response.status_code, 302) - self.assertIn(user_data['first_name'], response._headers['location'][1]) - - user = BadgeUser.cached.get(email=user.email) - self.assertIsNotNone(user.password) - - self.client.logout() - self.client.login(username=user.username, password=user_data['password']) - response = self.client.get('/v1/user/profile') - self.assertEqual(response.data['first_name'], user_data['first_name']) - - -class UserUnitTests(BadgrTestCase): - def test_user_can_have_unicode_characters_in_name(self): - user = BadgeUser( - username='abc', email='abc@example.com', - first_name='\xe2', last_name='Bowie') - - self.assertEqual(user.get_full_name(), '\xe2 Bowie') - - -@override_settings( - CELERY_ALWAYS_EAGER=True, - SESSION_ENGINE='django.contrib.sessions.backends.cache', -) -class UserEmailTests(BadgrTestCase): - def setUp(self): - super(UserEmailTests, self).setUp() - - self.badgr_app = BadgrApp(cors='testserver', - email_confirmation_redirect='http://testserver/login/', - forgot_password_redirect='http://testserver/reset-password/') - self.badgr_app.save() - - self.first_user_email = 'first.user@newemail.test' - self.first_user_email_secondary = 'first.user+2@newemail.test' - self.first_user = self.setup_user(email=self.first_user_email, authenticate=True) - CachedEmailAddress.objects.create(user=self.first_user, email=self.first_user_email_secondary, verified=True) - response = self.client.get('/v1/user/auth-token') - self.assertEqual(response.status_code, 200) - - def test_user_register_new_email(self): - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - starting_count = len(response.data) - - response = self.client.post('/v1/user/emails', { - 'email': 'new+email@newemail.com', - }) - self.assertEqual(response.status_code, 201) - - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - self.assertEqual(starting_count+1, len(response.data)) - - # Mark email as verified - email = CachedEmailAddress.cached.get(email='new+email@newemail.com') - email.verified = True - email.save() - - # Can not add the same email twice - response = self.client.post('/v1/user/emails', { - 'email': 'new+email@newemail.com', - }) - self.assertEqual(response.status_code, 400) - self.assertTrue("Could not register email address." in response.data) - - def test_user_can_verify_new_email(self): - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - starting_count = len(response.data) - - response = self.client.post('/v1/user/emails', { - 'email': 'new+email@newemail.com', - }) - self.assertEqual(response.status_code, 201) - - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - self.assertEqual(starting_count+1, len(response.data)) - - # Mark email as verified - email = CachedEmailAddress.cached.get(email='new+email@newemail.com') - self.assertEqual(len(mail.outbox), 1) - verify_url = re.search("(?P/v1/[^\s]+)", mail.outbox[0].body).group("url") - response = self.client.get(verify_url) - self.assertEqual(response.status_code, 302) - - email = CachedEmailAddress.cached.get(email='new+email@newemail.com') - self.assertTrue(email.verified) - - def test_user_cant_register_new_email_verified_by_other(self): - second_user = self.setup_user(authenticate=False) - existing_mail = CachedEmailAddress.objects.create( - user=self.first_user, email='new+email@newemail.com', verified=True) - - response = self.client.get('/v1/user/emails') - - self.assertEqual(response.status_code, 200) - starting_count = len(response.data) - - # Another user tries to add this email - self.client.force_authenticate(user=second_user) - response = self.client.post('/v1/user/emails', { - 'email': 'new+email@newemail.com', - }) - self.assertEqual(response.status_code, 400) - - self.client.force_authenticate(user=self.first_user) - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - self.assertEqual(starting_count, len(response.data)) - - def test_user_can_remove_email(self): - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - - not_primary = random.choice([e for e in response.data if e['verified'] and not e['primary']]) - primary = random.choice([e for e in response.data if e['primary']]) - - # cant remove primary email - response = self.client.delete('/v1/user/emails/{}'.format(primary.get('id'))) - self.assertEqual(response.status_code, 400) - response = self.client.get('/v1/user/emails/{}'.format(primary.get('id'))) - self.assertEqual(response.status_code, 200) - - # can remove a non-primary email - response = self.client.delete('/v1/user/emails/{}'.format(not_primary.get('id'))) - self.assertEqual(response.status_code, 200) - response = self.client.get('/v1/user/emails/{}'.format(not_primary.get('id'))) - self.assertEqual(response.status_code, 404) - - def test_user_can_make_email_primary(self): - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - - self.assertGreater(len(response.data), 0) - - not_primary = random.choice([e for e in response.data if e['verified'] and not e['primary']]) - - # set a non primary email to primary - response = self.client.put('/v1/user/emails/{}'.format(not_primary.get('id')), { - 'primary': True - }) - self.assertEqual(response.status_code, 200) - - # confirm that the new email is primary and the others aren't - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - for email in response.data: - if email['id'] == not_primary['id']: - self.assertEqual(email['primary'], True) - else: - self.assertEqual(email['primary'], False) - - def test_user_can_resend_verification_email(self): - # register a new un-verified email - response = self.client.post('/v1/user/emails', { - 'email': 'new+email@newemail.com', - }) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - not_verified = random.choice([e for e in response.data if not e['verified']]) - verified = random.choice([e for e in response.data if e['verified']]) - - # dont resend verification email if already verified - response = self.client.put('/v1/user/emails/{}'.format(verified.get('id')), { - 'resend': True - }) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(mail.outbox), 1) - - # gets an email for an unverified email - response = self.client.put('/v1/user/emails/{}'.format(not_verified.get('id')), { - 'resend': True - }) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(mail.outbox), 2) - - def test_no_login_on_confirmation_of_verified_email(self): - # register a new un-verified email - response = self.client.post('/v1/user/emails', { - 'email': 'new+email@newemail.com', - }) - self.assertEqual(response.status_code, 201) - - # receive verification email - self.assertEqual(len(mail.outbox), 1) - verify_url = re.search("(?P/v1/[^\s]+)", mail.outbox[0].body).group("url") - - # verify the email address - email_address = CachedEmailAddress.objects.filter(verified=False).get() - email_address.verified = True - email_address.save() - - # verification attempt fails - response = self.client.get(verify_url) - self.assertEqual(response.status_code, 302) - self.assertIn('authError', response['location']) - self.assertNotIn('authToken', response['location']) - - def test_verification_cannot_be_reused(self): - # register a new un-verified email - response = self.client.post('/v1/user/emails', { - 'email': 'new+email@newemail.com', - }) - self.assertEqual(response.status_code, 201) - - # receive verification email - self.assertEqual(len(mail.outbox), 1) - verify_url = re.search("(?P/v1/[^\s]+)", mail.outbox[0].body).group("url") - - # verify the email address successfully - response = self.client.get(verify_url) - self.assertEqual(response.status_code, 302) - self.assertIn('authToken', response['location']) - self.assertNotIn('authError', response['location']) - - # second verification attempt fails - response = self.client.get(verify_url) - self.assertEqual(response.status_code, 302) - self.assertIn('authError', response['location']) - self.assertNotIn('authToken', response['location']) - - def test_user_can_request_forgot_password(self): - self.client.logout() - cache.clear() - - # dont send recovery to unknown emails - response = self.client.post('/v1/user/forgot-password', { - 'email': 'unknown-test2@example.com.fake', - }) - self.assertEqual(response.status_code, 200, "Does not leak information about account emails") - self.assertEqual(len(mail.outbox), 0) - - # successfully send recovery email - response = self.client.post('/v1/user/forgot-password', { - 'email': self.first_user_email - }) - - backoff_key = backoff_cache_key(self.first_user_email) - backoff_data = {'127.0.0.1': {'count': 6, 'until': timezone.now() + timezone.timedelta(seconds=60)}} - cache.set(backoff_key, backoff_data) - self.assertEqual(cache.get(backoff_key), backoff_data) - - self.assertEqual(response.status_code, 200) - # received email with recovery url - self.assertEqual(len(mail.outbox), 1) - matches = re.search(r'/v1/user/forgot-password\?token=([-0-9a-zA-Z]*)', mail.outbox[0].body) - self.assertIsNotNone(matches) - token = matches.group(1) - new_password = 'new-password-ee' - - # able to use token received in email to reset password - response = self.client.put('/v1/user/forgot-password', { - 'token': token, - 'password': new_password - }) - self.assertEqual(response.status_code, 200) - - backoff_data = cache.get(backoff_key) - self.assertIsNone(backoff_data) - - application = Application.objects.create( - client_id='public', - client_secret='', - user=None, - authorization_grant_type=Application.GRANT_PASSWORD, - name='public' - ) - ApplicationInfo.objects.create( - application=application, - allowed_scopes='rw:issuer rw:backpack rw:profile' - ) - - response = self.client.post('/o/token', { - 'username': self.first_user.email, - 'password': new_password, - }) - self.assertEqual(response.status_code, 200) - - @patch('mainsite.serializers.badgrlogger.event') - def test_log_when_api_auth_token_endpoint_is_used(self, mocked_logger): - response = self.client.post('/api-auth/token', { - 'username': self.first_user.username, - 'password': 'secret', - }) - self.assertEqual(response.status_code, 200) - self.assertIn('deprecated', response.data['warning'], 'There is a warning returned to the requester') - mocked_logger.assert_called_once() - self.assertTrue(mocked_logger.call_args[0][0].is_new_token) - - @patch('mainsite.authentication.badgrlogger.event') - def test_log_when_legacy_auth_token_is_used(self, mocked_logger): - # logout previous user - self.client.logout() - - user_email = 'hundredth.user@newemail.test' - user = self.setup_user(email=user_email, authenticate=False) - token, created = Token.objects.get_or_create(user=user) - response = self.client.get('/v2/users/self', HTTP_AUTHORIZATION='Token {}'.format(token.key)) - - mocked_logger.assert_called_once() - self.assertIsNotNone(mocked_logger.call_args[0][0].request.META.get("REMOTE_ADDR", None)) - self.assertEquals(mocked_logger.call_args[0][0].username, user.username) - self.assertFalse(mocked_logger.call_args[0][0].is_new_token) - - def test_lower_variant_autocreated_on_new_email(self): - first_email = CachedEmailAddress( - email="HelloAgain@world.com", user=BadgeUser.objects.first(), verified=True - ) - first_email.save() - self.assertIsNotNone(first_email.pk) - - variants = EmailAddressVariant.objects.filter(canonical_email=first_email) - - self.assertEqual(len(variants), 1) - self.assertEqual(variants[0].email, 'helloagain@world.com') - - def test_can_create_new_variant_api(self): - user = BadgeUser.objects.first() - first_email = CachedEmailAddress( - email="helloagain@world.com", user=user, verified=True - ) - first_email.save() - self.assertIsNotNone(first_email.pk) - - self.client.force_authenticate(user=user) - response = self.client.post('/v1/user/emails', {'email': 'HelloAgain@world.com'}) - - self.assertEqual(response.status_code, 400) - self.assertTrue('Matching address already exists. New case variant registered.' in response.data) - - variants = first_email.cached_variants() - self.assertEqual(len(variants), 1) - self.assertEqual(variants[0].email, 'HelloAgain@world.com') - - def test_can_create_variants(self): - user = self.setup_user(authenticate=False) - first_email = CachedEmailAddress.objects.create(email="test@example.com", verified=True, user=user) - self.assertIsNotNone(first_email.pk) - - first_variant_email = "TEST@example.com" - second_variant_email = "Test@example.com" - - first_variant = EmailAddressVariant(email=first_variant_email, canonical_email=first_email) - first_variant.save() - self.assertEqual(first_variant.canonical_email, first_email) - - second_variant = first_email.add_variant(second_variant_email) - self.assertEqual(second_variant.canonical_email, first_email) - - self.assertEqual(len(first_email.emailaddressvariant_set.all()), 2) - self.assertEqual(len(first_email.cached_variants()), 2) - - def test_user_can_create_variant_method(self): - user = BadgeUser.objects.first() - first_email = CachedEmailAddress( - email="howdy@world.com", user=user, verified=True - ) - first_email.save() - first_email.add_variant("HOWDY@world.com") - - self.assertTrue(user.can_add_variant("Howdy@world.com")) - self.assertFalse(user.can_add_variant("HOWDY@world.com")) # already exists - self.assertFalse(user.can_add_variant("howdy@world.com")) # is the original - self.assertFalse(user.can_add_variant("howdyfeller@world.com")) # not a match of original - - def test_can_create_variant_for_unconfirmed_email(self): - user = BadgeUser.objects.first() - new_email_address = "new@unconfirmed.info" - new_email = CachedEmailAddress.objects.create(email=new_email_address, user=user) - new_variant = EmailAddressVariant(email=new_email_address.upper(), canonical_email=new_email) - - new_variant.save() - self.assertFalse(new_variant.verified) - - verified_emails = [e.email for e in user.emailaddress_set.filter(verified=True)] \ - + [e.email for e in user.cached_email_variants() if e.verified] - - self.assertTrue(new_variant not in verified_emails) - - def cannot_link_variant_of_case_insensitive_nonmatch(self): - first_email = CachedEmailAddress.objects.get(email="test@example.com") - self.assertIsNotNone(first_email.pk) - - variant_email = "NOMATCH@example.com" - - variant = EmailAddressVariant(email=variant_email, canonical_email=first_email) - try: - variant.save() - except ValidationError as e: - self.assertEqual(e.message, "New EmailAddressVariant does not match stored email address.") - else: - raise self.fail("ValidationError expected on nonmatch.") - - -@override_settings( - CELERY_ALWAYS_EAGER=True, - SESSION_ENGINE='django.contrib.sessions.backends.cache', -) -class UserRecipientIdentifierTests(SetupIssuerHelper, BadgrTestCase): - def setUp(self): - super(UserRecipientIdentifierTests, self).setUp() - - self.badgr_app = BadgrApp(cors='testserver', - email_confirmation_redirect='http://testserver/login/', - forgot_password_redirect='http://testserver/reset-password/') - self.badgr_app.save() - - self.first_user_email = 'first.user@newemail.test' - self.first_user_email_secondary = 'first.user+2@newemail.test' - self.first_user = self.setup_user(email=self.first_user_email, authenticate=True) - CachedEmailAddress.objects.create(user=self.first_user, email=self.first_user_email_secondary, verified=True) - response = self.client.get('/v1/user/auth-token') - self.assertEqual(response.status_code, 200) - - self.issuer = self.setup_issuer(owner=self.first_user) - self.badgeclass = self.setup_badgeclass(self.issuer) - - def test_two_users_can_have_same_identifier(self): - url = 'http://example.com' - self.first_user.userrecipientidentifier_set.create(identifier=url) - second_user_email = 'second.user@email.com' - second_user = self.setup_user(email=second_user_email, authenticate=True) - second_user.userrecipientidentifier_set.create(identifier=url) - - self.assertGreater(UserRecipientIdentifier.objects.filter(identifier=url).count(), 1) - - def test_only_one_user_can_have_verified_identifier(self): - url = 'http://example.com' - self.first_user.userrecipientidentifier_set.create(identifier=url, verified=True) - second_user_email = 'second.user@email.com' - second_user = self.setup_user(email=second_user_email, authenticate=True) - second_identifier = second_user.userrecipientidentifier_set.create(identifier=url) - - with self.assertRaisesRegex(ValidationError, re.compile('identifier', re.I)): - second_identifier.verified = True - second_identifier.save() - - def test_url_format_validation(self): - self.first_user.userrecipientidentifier_set.create(identifier='http://example.com') - self.first_user.userrecipientidentifier_set.create(identifier='ftp://example.com') - self.first_user.userrecipientidentifier_set.create(identifier='https://withpath.com/12345678') - self.first_user.userrecipientidentifier_set.create(identifier='https://withhash.com/12345678/bar.html#fooey') - - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create(identifier='http') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create(identifier='(541) 342-8456') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create(identifier='') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create(identifier='12345678') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create(identifier='email@test.com') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create(identifier='customprotocol://example.com') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create(identifier='http://singlepart') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create(identifier='/relative/url') - - def test_phone_format_validation(self): - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier='3428456') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier='5413428456') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier='15413428456') - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier='+15413428456') - - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier='+1541342845612345') - with self.assertRaisesRegex(ValidationError, 'valid'): - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier='(541) 342-8456') - - def test_verified_phone_included_in_all_recipient_identifiers(self): - identifier = '+3428456' - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier=identifier, verified=True) - self.assertIn(identifier, self.first_user.all_recipient_identifiers) - - def test_verified_url_included_in_all_recipient_identifiers(self): - identifier = 'http://example.com' - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, identifier=identifier, verified=True) - self.assertIn(identifier, self.first_user.all_recipient_identifiers) - - def test_identifiers_serialized_to_correct_fields(self): - url = 'http://example.com' - phone = '+15413428456' - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, identifier=url, verified=True) - self.first_user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier=phone, verified=True) - v1serialized = BadgeUserProfileSerializerV1(self.first_user).data - v2serialized = BadgeUserSerializerV2(self.first_user).data['result'][0] - - self.assertIn(url, v1serialized['url']) - self.assertIn(url, v2serialized['url']) - self.assertIn(phone, v1serialized['telephone']) - self.assertIn(phone, v2serialized['telephone']) - - self.assertNotIn(phone, v1serialized['url']) - self.assertNotIn(phone, v2serialized['url']) - self.assertNotIn(url, v1serialized['telephone']) - self.assertNotIn(url, v2serialized['telephone']) - - def test_recipient_identity_serialized_to_correct_fields(self): - user = self.setup_user(create_email_address=False) - v2serialized = BadgeUserSerializerV2(user).data['result'][0] - self.assertEqual(None, v2serialized['recipient']) - - url = 'http://example.com' - phone = '+15413428456' - user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, identifier=url, verified=True) - user.userrecipientidentifier_set.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, identifier=phone, verified=True) - v2serialized = BadgeUserSerializerV2(user).data['result'][0] - self.assertIn(url, v2serialized['recipient']['identity']) - self.assertIn('url', v2serialized['recipient']['type']) - - primary_email = 'primary@example.com' - CachedEmailAddress.objects.create(user=user, email=primary_email, primary=True, verified=True) - v2serialized = BadgeUserSerializerV2(user).data['result'][0] - self.assertIn(primary_email, v2serialized['recipient']['identity']) - self.assertIn('email', v2serialized['recipient']['type']) - - def test_verified_recipient_receives_assertion(self): - url = 'http://example.com' - self.first_user.userrecipientidentifier_set.create(identifier=url, verified=True, type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - self.badgeclass.issue(recipient_id=url, recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - self.assertEqual(len(self.first_user.cached_badgeinstances()), 1) - - def test_unverified_recipient_receives_no_assertion(self): - url = 'http://example.com' - self.first_user.userrecipientidentifier_set.create(identifier=url) - self.badgeclass.issue(recipient_id=url, recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - self.assertEqual(len(self.first_user.cached_badgeinstances()), 0) - - def test_verified_recipient_v1_badges_endpoint(self): - url = 'http://example.com' - self.first_user.userrecipientidentifier_set.create(identifier=url, verified=True, type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - self.badgeclass.issue(recipient_id=url, recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - - response = self.client.get('/v1/earner/badges') - self.assertEqual(len(response.data), 1) - - def test_verified_recipient_v2_assertions_endpoint(self): - url = 'http://example.com' - self.first_user.userrecipientidentifier_set.create(identifier=url, verified=True) - self.badgeclass.issue(recipient_id=url, recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - response = self.client.get('/v2/backpack/assertions') - self.assertEqual(len(response.data['result']), 1) - - def test_unverified_recipient_v1_badges_endpoint(self): - url = 'http://example.com' - self.first_user.userrecipientidentifier_set.create(identifier=url) - self.badgeclass.issue(recipient_id=url, recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - - response = self.client.get('/v1/earner/badges') - self.assertEqual(len(response.data), 0) - - def test_unverified_recipient_v2_assertions_endpoint(self): - url = 'http://example.com' - self.first_user.userrecipientidentifier_set.create(identifier=url) - self.badgeclass.issue(recipient_id=url, recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - - response = self.client.get('/v2/backpack/assertions') - self.assertEqual(len(response.data['result']), 0) - - -@override_settings( - SESSION_ENGINE='django.contrib.sessions.backends.cache', -) -class UserBadgeTests(BadgrTestCase): - def setUp(self): - super(UserBadgeTests, self).setUp() - self.badgr_app = BadgrApp(cors='testserver', - email_confirmation_redirect='http://testserver/login/', - forgot_password_redirect='http://testserver/reset-password/') - self.badgr_app.save() - - def create_badgeclass(self): - with open(os.path.join(TOP_DIR, 'apps', 'issuer', 'testfiles', 'guinea_pig_testing_badge.png'), 'rb') as fh: - issuer = Issuer.objects.create(name='Issuer of Testing') - badgeclass = BadgeClass.objects.create( - issuer=issuer, - name="Badge of Testing", - image=SimpleUploadedFile(name='test_image.png', content=fh.read(), content_type='image/png') - ) - return badgeclass - - def test_badge_awards_transferred_on_email_verification(self): - first_user_email = 'first+user@email.test' - first_user = self.setup_user(email=first_user_email, authenticate=True) - - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - starting_count = len(response.data) - - badgeclass = self.create_badgeclass() - badgeclass.issue(recipient_id='New+email@newemail.com', allow_uppercase=True, recipient_type='email') - badgeclass.issue(recipient_id='New+Email@newemail.com', allow_uppercase=True, recipient_type='email') - - outbox_count = len(mail.outbox) - - response = self.client.post('/v1/user/emails', { - 'email': 'new+email@newemail.com', - }) - self.assertEqual(response.status_code, 201) - - response = self.client.get('/v1/user/emails') - self.assertEqual(response.status_code, 200) - self.assertEqual(starting_count+1, len(response.data)) - - # Mark email as verified - email = CachedEmailAddress.cached.get(email='new+email@newemail.com') - self.assertEqual(len(mail.outbox), outbox_count+1) - verify_url = re.search("(?P/v1/[^\s]+)", mail.outbox[-1].body).group("url") - response = self.client.get(verify_url) - self.assertEqual(response.status_code, 302) - - email = CachedEmailAddress.cached.get(email='new+email@newemail.com') - self.assertTrue(email.verified) - - self.assertTrue('New+email@newemail.com' in [e.email for e in email.cached_variants()]) - self.assertTrue('New+Email@newemail.com' in [e.email for e in email.cached_variants()]) - - -@override_settings( - SESSION_ENGINE='django.contrib.sessions.backends.cache', -) -class UserProfileTests(BadgrTestCase): - def assertUserLoggedIn(self, user_pk=None): - self.assertIn(SESSION_KEY, self.client.session) - if user_pk is not None: - self.assertEqual(self.client.session[SESSION_KEY], user_pk) - - def test_user_can_change_profile(self): - first = 'firsty' - last = 'lastington' - new_password = 'new-password' - username = 'testinguser' - original_password = 'password' - email = 'testinguser@testing.info' - - user = BadgeUser(username=username, is_active=True, email=email) - user.set_password(original_password) - user.save() - self.client.login(username=username, password=original_password) - self.assertUserLoggedIn() - - response = self.client.put('/v1/user/profile', { - 'first_name': first, - 'last_name': last, - 'password': new_password, - 'current_password': original_password - }) - self.assertEqual(response.status_code, 200) - self.assertEqual(first, response.data.get('first_name')) - self.assertEqual(last, response.data.get('last_name')) - - self.client.logout() - self.client.login(username=username, password=new_password) - self.assertUserLoggedIn() - self.assertEqual(len(mail.outbox), 1) - - response = self.client.put('/v1/user/profile', { - 'first_name': 'Barry', - 'last_name': 'Manilow' - }) - self.assertEqual(response.status_code, 200) - self.assertEqual('Barry', response.data.get('first_name')) - - third_password = 'superstar!' - response = self.client.put('/v1/user/profile', { - 'password': third_password, - 'current_password': new_password - }) - self.assertEqual(response.status_code, 200) - self.client.logout() - self.client.login(username=username, password=third_password) - self.assertUserLoggedIn() - - def test_user_can_agree_to_terms(self): - first = 'firsty' - last = 'lastington' - new_password = 'new-password' - username = 'testinguser' - original_password = 'password' - email = 'testinguser@testing.info' - - user = BadgeUser(username=username, is_active=True, email=email) - user.set_password(original_password) - user.save() - self.client.login(username=username, password=original_password) - self.assertUserLoggedIn() - - TermsVersion.objects.create(version=1, short_description='terms 1') - - response = self.client.put('/v1/user/profile', { - 'first_name': first, - 'last_name': last, - 'password': new_password, - 'current_password': original_password, - 'latest_terms_version': 1 - }) - self.assertEqual(response.status_code, 200) - - def test_user_update_ignores_blank_email(self): - first = 'firsty' - last = 'lastington' - new_password = 'new-password' - username = 'testinguser' - original_password = 'password' - - user = BadgeUser(username=username, is_active=True) - user.set_password(original_password) - user.save() - UserRecipientIdentifier.objects.create( - type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, - identifier='http://testurl.com/123', - verified=True, - user=user - ) - self.client.login(username=username, password=original_password) - self.assertUserLoggedIn() - - TermsVersion.objects.create(version=1, short_description='terms 1') - - response = self.client.put('/v1/user/profile', { - 'first_name': first + ' Q.', - 'last_name': last, - 'email': None - }, format='json') - self.assertEqual(response.status_code, 200) diff --git a/apps/badgeuser/utils.py b/apps/badgeuser/utils.py index beee8d3f8..035cd8de6 100644 --- a/apps/badgeuser/utils.py +++ b/apps/badgeuser/utils.py @@ -1,6 +1,4 @@ import base64 -import random -import string from hashlib import md5 from allauth.account.adapter import get_adapter @@ -19,21 +17,31 @@ def notify_on_password_change(user, request=None): badgr_app = BadgrApp.objects.get_current(request=request) else: badgr_app = user.badgrapp - + + # TODO: Use email related to the new domain, when one is created. Not urgent in this phase. base_context = { - 'user': user, - 'site': get_current_site(request), - 'help_email': getattr(settings, 'HELP_EMAIL', 'help@badgr.io'), - 'STATIC_URL': getattr(settings, 'STATIC_URL'), - 'HTTP_ORIGIN': getattr(settings, 'HTTP_ORIGIN'), - 'badgr_app': badgr_app, + "user": user, + "site": get_current_site(request), + "help_email": getattr(settings, "HELP_EMAIL", "info@opensenselab.org"), + "STATIC_URL": getattr(settings, "STATIC_URL"), + "HTTP_ORIGIN": getattr(settings, "HTTP_ORIGIN"), + "badgr_app": badgr_app, } - email_context = Context(base_context) - get_adapter().send_mail('account/email/password_reset_confirmation', user.primary_email, base_context) + Context(base_context) + get_adapter().send_mail( + "account/email/password_reset_confirmation", user.primary_email, base_context + ) + def generate_badgr_username(email): + if not email: + return "unknown" # md5 hash the email and then encode as base64 to take up only 25 characters - salted_email = (email + ''.join(random.choice(string.ascii_lowercase) for i in range(64))).encode('utf-8') - hashed = str(base64.b64encode(md5(salted_email).hexdigest().encode('utf-8')), 'utf-8') - return "badgr{}".format(hashed[:25]) \ No newline at end of file + # For now I removed the salt becaues I don't see why we need it + # salted_email = (email + ''.join(random.choice(string.ascii_lowercase) for i in range(64))).encode('utf-8') + salted_email = email.encode("utf-8") + hashed = str( + base64.b64encode(md5(salted_email).hexdigest().encode("utf-8")), "utf-8" + ) + return "badgr{}".format(hashed[:25]) diff --git a/apps/badgeuser/v1_api_urls.py b/apps/badgeuser/v1_api_urls.py index 0e0a43118..65b56368a 100644 --- a/apps/badgeuser/v1_api_urls.py +++ b/apps/badgeuser/v1_api_urls.py @@ -1,14 +1,92 @@ -from django.conf.urls import url +from django.urls import re_path -from badgeuser.api import BadgeUserToken, BadgeUserForgotPassword, BadgeUserEmailConfirm, BadgeUserDetail +from badgeuser.api import ( + BadgeUserConfirmStaffRequest, + BadgeUserSaveMicroDegree, + BadgeUserStaffRequestDetail, + BadgeUserStaffRequestList, + BadgeUserToken, + BadgeUserForgotPassword, + BadgeUserEmailConfirm, + BadgeUserDetail, + BadgeUserResendEmailConfirmation, + ConfirmNetworkInvitation, + GetRedirectPath, + LearningPathList, + BadgeUserCollectBadgesInBackpack, +) from badgeuser.api_v1 import BadgeUserEmailList, BadgeUserEmailDetail urlpatterns = [ - url(r'^auth-token$', BadgeUserToken.as_view(), name='v1_api_user_auth_token'), - url(r'^profile$', BadgeUserDetail.as_view(), name='v1_api_user_profile'), - url(r'^forgot-password$', BadgeUserForgotPassword.as_view(), name='v1_api_auth_forgot_password'), - url(r'^emails$', BadgeUserEmailList.as_view(), name='v1_api_user_emails'), - url(r'^emails/(?P[^/]+)$', BadgeUserEmailDetail.as_view(), name='v1_api_user_email_detail'), - url(r'^legacyconfirmemail/(?P[^/]+)$', BadgeUserEmailConfirm.as_view(), name='legacy_user_email_confirm'), - url(r'^confirmemail/(?P[^/]+)$', BadgeUserEmailConfirm.as_view(), name='v1_api_user_email_confirm') + re_path(r"^auth-token$", BadgeUserToken.as_view(), name="v1_api_user_auth_token"), + re_path(r"^profile$", BadgeUserDetail.as_view(), name="v1_api_user_profile"), + re_path( + r"^forgot-password$", + BadgeUserForgotPassword.as_view(), + name="v1_api_auth_forgot_password", + ), + re_path(r"^emails$", BadgeUserEmailList.as_view(), name="v1_api_user_emails"), + re_path( + r"^emails/(?P[^/]+)$", + BadgeUserEmailDetail.as_view(), + name="v1_api_user_email_detail", + ), + re_path( + r"^legacyconfirmemail/(?P[^/]+)$", + BadgeUserEmailConfirm.as_view(), + name="legacy_user_email_confirm", + ), + re_path( + r"^confirmemail/(?P[^/]+)$", + BadgeUserEmailConfirm.as_view(), + name="v1_api_user_email_confirm", + ), + re_path( + r"^resendemail$", + BadgeUserResendEmailConfirmation.as_view(), + name="v1_api_resend_user_verification_email", + ), + re_path( + r"^learningpaths$", LearningPathList.as_view(), name="v1_api_user_learningpaths" + ), + re_path( + r"^save-microdegree/(?P[^/]+)$", + BadgeUserSaveMicroDegree.as_view(), + name="v1_api_user_save_microdegree", + ), + re_path( + r"^collect-badges-in-backpack$", + BadgeUserCollectBadgesInBackpack.as_view(), + name="v1_api_user_collect_badges_in_backpack", + ), + re_path( + r"^get-redirect-path$", + GetRedirectPath.as_view(), + name="v1_api_user_get_redirect_path", + ), + re_path( + r"^issuerStaffRequests$", + BadgeUserStaffRequestList.as_view(), + name="v1_api_user_issuer_staff_requests_list", + ), + re_path( + r"^issuerStaffRequest/issuer/(?P[^/]+)$", + BadgeUserStaffRequestList.as_view(), + name="v1_api_user_issuer_staff_requests", + ), + re_path( + r"^issuerStaffRequest/(?P[^/]+)$", + BadgeUserStaffRequestDetail.as_view(), + name="v1_api_user_issuer_staff_request_detail", + ), + re_path( + r"^confirm-staff-request/(?P[^/]+)$", + BadgeUserConfirmStaffRequest.as_view(), + name="v1_api_user_confirm_staffrequest", + ), + re_path( + r"^confirm-network-invitation/(?P[^/]+)$", + ConfirmNetworkInvitation.as_view(), + name="v1_api_user_confirm_network_invite", + ), ] diff --git a/apps/badgeuser/v2_api_urls.py b/apps/badgeuser/v2_api_urls.py index ff4475255..52f20235e 100644 --- a/apps/badgeuser/v2_api_urls.py +++ b/apps/badgeuser/v2_api_urls.py @@ -1,23 +1,69 @@ # encoding: utf-8 -from django.conf.urls import url +from django.urls import re_path -from badgeuser.api import (BadgeUserAccountConfirm, BadgeUserToken, BadgeUserForgotPassword, BadgeUserEmailConfirm, - BadgeUserDetail, AccessTokenList, AccessTokenDetail, LatestTermsVersionDetail,) +from badgeuser.api import ( + BadgeUserAccountConfirm, + BadgeUserToken, + BadgeUserForgotPassword, + BadgeUserEmailConfirm, + BadgeUserDetail, + AccessTokenList, + AccessTokenDetail, + LatestTermsVersionDetail, + ApplicationList, + ApplicationDetails, +) urlpatterns = [ - - url(r'^auth/token$', BadgeUserToken.as_view(), name='v2_api_auth_token'), - url(r'^auth/forgot-password$', BadgeUserForgotPassword.as_view(), name='v2_api_auth_forgot_password'), - url(r'^auth/confirm-email/(?P[^/]+)$', BadgeUserEmailConfirm.as_view(), name='v2_api_auth_confirm_email'), - url(r'^auth/confirm-account/(?P[^/]+)$', BadgeUserAccountConfirm.as_view(), name='v2_api_account_confirm'), - - url(r'^auth/tokens$', AccessTokenList.as_view(), name='v2_api_access_token_list'), - url(r'^auth/tokens/(?P[^/]+)$', AccessTokenDetail.as_view(), name='v2_api_access_token_detail'), - - url(r'^users/(?Pself)$', BadgeUserDetail.as_view(), name='v2_api_user_self'), - url(r'^users/(?P[^/]+)$', BadgeUserDetail.as_view(), name='v2_api_user_detail'), - - url(r'^termsVersions/latest$', LatestTermsVersionDetail.as_view(), name='v2_latest_terms_version_detail'), -] \ No newline at end of file + re_path(r"^auth/token$", BadgeUserToken.as_view(), name="v2_api_auth_token"), + re_path( + r"^auth/forgot-password$", + BadgeUserForgotPassword.as_view(), + name="v2_api_auth_forgot_password", + ), + re_path( + r"^auth/confirm-email/(?P[^/]+)$", + BadgeUserEmailConfirm.as_view(), + name="v2_api_auth_confirm_email", + ), + re_path( + r"^auth/confirm-account/(?P[^/]+)$", + BadgeUserAccountConfirm.as_view(), + name="v2_api_account_confirm", + ), + re_path( + r"^auth/tokens$", AccessTokenList.as_view(), name="v2_api_access_token_list" + ), + re_path( + r"^auth/tokens/(?P[^/]+)$", + AccessTokenDetail.as_view(), + name="v2_api_access_token_detail", + ), + re_path( + r"^auth/applications$", + ApplicationList.as_view(), + name="v2_api_application_list", + ), + re_path( + r"^auth/applications/(?P[^/]+)$", + ApplicationDetails.as_view(), + name="v2_api_application_details", + ), + re_path( + r"^users/(?Pself)$", + BadgeUserDetail.as_view(), + name="v2_api_user_self", + ), + re_path( + r"^users/(?P[^/]+)$", + BadgeUserDetail.as_view(), + name="v2_api_user_detail", + ), + re_path( + r"^termsVersions/latest$", + LatestTermsVersionDetail.as_view(), + name="v2_latest_terms_version_detail", + ), +] diff --git a/apps/badgeuser/v3_api_urls.py b/apps/badgeuser/v3_api_urls.py new file mode 100644 index 000000000..36f036215 --- /dev/null +++ b/apps/badgeuser/v3_api_urls.py @@ -0,0 +1,11 @@ +from django.urls import include, path +from rest_framework import routers + +from . import api_v3 + +router = routers.DefaultRouter() +router.register(r"preferences", api_v3.Preferences, basename="preferences") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/badgrlog/__init__.py b/apps/badgrlog/__init__.py deleted file mode 100644 index 51b5b05cd..000000000 --- a/apps/badgrlog/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# Created by wiggins@concentricsky.com on 8/27/15. - -from .badgrlogger import BadgrLogger -from .events import * - diff --git a/apps/badgrlog/badgrlogger.py b/apps/badgrlog/badgrlogger.py deleted file mode 100644 index 07cc5e2a9..000000000 --- a/apps/badgrlog/badgrlogger.py +++ /dev/null @@ -1,17 +0,0 @@ -# Created by wiggins@concentricsky.com on 8/27/15. - -import logging -from .events.base import BaseBadgrEvent - - -class BadgrLogger(object): - def __init__(self, name='Badgr.Events'): - self.logger = logging.getLogger(name) - - def event(self, event): - if not isinstance(event, BaseBadgrEvent): - raise NotImplementedError() - obj = event.compacted() - self.logger.info(obj) - - diff --git a/apps/badgrlog/events/__init__.py b/apps/badgrlog/events/__init__.py deleted file mode 100644 index 233cc9520..000000000 --- a/apps/badgrlog/events/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .badgeuser import * -from .backpack import * -from .blacklist import * -from .composition import * -from .email import * -from .issuer import * -from .public import * diff --git a/apps/badgrlog/events/backpack.py b/apps/badgrlog/events/backpack.py deleted file mode 100644 index 9395b0277..000000000 --- a/apps/badgrlog/events/backpack.py +++ /dev/null @@ -1,29 +0,0 @@ -import datetime - -from .base import BaseBadgrEvent - - -class BadgeSharedEvent(BaseBadgrEvent): - def __init__(self, assertion, platform, shared_date, source): - if not isinstance(shared_date, datetime.datetime): - raise ValueError('shared_date parameter must be a datetime object') - - self.issuer_ob_id = assertion.issuer_jsonld_id - self.badgeclass_ob_id = assertion.badgeclass_jsonld_id - self.assertion_ob_id = assertion.jsonld_id - self.recipient_identifier = assertion.recipient_identifier - self.recipient_type = assertion.recipient_type - self.platform = platform - self.shared_date = shared_date - self.source = source - - def to_representation(self): - return { - 'issuer_ob_id': self.issuer_ob_id, - 'badgeclass_ob_id': self.badgeclass_ob_id, - 'assertion_ob_id': self.assertion_ob_id, - 'recipient': {'value': self.recipient_identifier, 'type': self.recipient_type}, - 'platform': self.platform, - 'shared_date': self.serializeWithUTCWithZ(self.shared_date), - 'source': self.source - } diff --git a/apps/badgrlog/events/badgeuser.py b/apps/badgrlog/events/badgeuser.py deleted file mode 100644 index dfa10bd35..000000000 --- a/apps/badgrlog/events/badgeuser.py +++ /dev/null @@ -1,143 +0,0 @@ -from mainsite.utils import client_ip_from_request -from .base import BaseBadgrEvent - - -class UserSignedUp(BaseBadgrEvent): - - def __init__(self, request, user, **kwargs): - self.request = request - self.user = user - - def to_representation(self): - return { - 'username': self.user.username, - 'first_name': self.user.first_name, - 'last_name': self.user.last_name, - 'email': self.user.email, - } - - -class EmailConfirmed(BaseBadgrEvent): - - def __init__(self, request, email_address, **kwargs): - self.request = request - self.email_address = email_address - - def to_representation(self): - return { - 'email': self.email_address.email, - } - - -class FailedLoginAttempt(BaseBadgrEvent): - def __init__(self, request, username, endpoint, **kwargs): - self.request = request - self.username = username - self.endpoint = endpoint - - def to_representation(self): - return { - 'username': self.username, - 'endpoint': self.endpoint, - 'ipAddress': client_ip_from_request(self.request) - } - - -class DeprecatedApiAuthToken(BaseBadgrEvent): - def __init__(self, request, username, **kwargs): - self.request = request - self.username = username - self.is_new_token = kwargs.get('is_new_token', False) - - def to_representation(self): - return { - 'username': self.username, - 'ipAddress': client_ip_from_request(self.request), - 'newToken': self.is_new_token - } - - -class NoBadgrApp(BaseBadgrEvent): - - def __init__(self, request, badgrapp_id, **kwargs): - self.request = request - self.badgrapp_id = badgrapp_id - - def to_representation(self): - return { - 'badgrapp_id': self.badgrapp_id, - } - - -class NoEmailConfirmation(BaseBadgrEvent): - - def to_representation(self): - return {} - - -class NoEmailConfirmationEmailAddress(BaseBadgrEvent): - - def __init__(self, request, email_address, **kwargs): - self.request = request - self.email_address = email_address - - def to_representation(self): - return { - 'email': self.email_address.email, - } - - -class InvalidEmailConfirmationToken(BaseBadgrEvent): - - def __init__(self, request, email_address, token, **kwargs): - self.request = request - self.email_address = email_address - self.token = token - - def to_representation(self): - return { - 'email': self.email_address.email, - 'token': self.token, - } - - -class EmailConfirmationTokenExpired(BaseBadgrEvent): - - def __init__(self, request, email_address, **kwargs): - self.request = request - self.email_address = email_address - - def to_representation(self): - return { - 'email': self.email_address.email, - } - - -class OtherUsersEmailConfirmationToken(BaseBadgrEvent): - - def __init__(self, request, email_address, other_user, token, **kwargs): - self.request = request - self.email_address = email_address - self.other_user = other_user - self.token = token - - def to_representation(self): - return { - 'email': self.email_address.email, - 'other_user_email': self.other_user.email, - 'token': self.token, - } - - -class EmailConfirmationAlreadyVerified(BaseBadgrEvent): - - def __init__(self, request, email_address, token, **kwargs): - self.request = request - self.email_address = email_address - self.token = token - - def to_representation(self): - return { - 'email': self.email_address.email, - 'token': self.token, - } diff --git a/apps/badgrlog/events/base.py b/apps/badgrlog/events/base.py deleted file mode 100644 index 7771a818e..000000000 --- a/apps/badgrlog/events/base.py +++ /dev/null @@ -1,34 +0,0 @@ -# Created by wiggins@concentricsky.com on 8/27/15. -import datetime -import pytz -import uuid - -import django.utils.timezone as timezone - - -class BaseBadgrEvent(object): - def serializeWithUTCWithZ(self, date): - if timezone.is_aware(date): - tz_datetime = date.astimezone(pytz.utc) - else: - tz_datetime = timezone.make_aware(date, pytz.utc) - tz_datetime = tz_datetime.isoformat() - if tz_datetime.endswith('+00:00'): - tz_datetime = tz_datetime[:-6] + 'Z' - return tz_datetime - - def get_type(self): - return self.__class__.__name__ - - def to_representation(self): - raise NotImplementedError("subclasses must provide a to_representation method") - - def compacted(self): - data = self.to_representation() - data.update({ - 'type': 'Action', - 'actionType': self.get_type(), - 'timestamp': self.serializeWithUTCWithZ(datetime.datetime.now()), - 'event_id': str(uuid.uuid4()) - }) - return data diff --git a/apps/badgrlog/events/blacklist.py b/apps/badgrlog/events/blacklist.py deleted file mode 100644 index 00c43b7dd..000000000 --- a/apps/badgrlog/events/blacklist.py +++ /dev/null @@ -1,56 +0,0 @@ -from .base import BaseBadgrEvent -from mainsite.blacklist import generate_hash - - -class BlacklistEarnerNotNotifiedEvent(BaseBadgrEvent): - def __init__(self, badge_instance): - self.badge_instance = badge_instance - - def to_representation(self): - return { - 'recipient_identifier': self.badge_instance.recipient_identifier, - 'badge_instance': self.badge_instance.json, - } - - -class BlacklistAssertionNotCreatedEvent(BaseBadgrEvent): - def __init__(self, badge_instance): - self.recipient_id_hash = \ - generate_hash(badge_instance.recipient_type, badge_instance.recipient_identifier) - self.entity_id = badge_instance.badgeclass.entity_id - - def to_representation(self): - return { - 'recipient_id_hash': self.recipient_id_hash, - 'badgeclass_entity_id': self.entity_id, - } - - -class BlacklistUnsubscribeInvalidLinkEvent(BaseBadgrEvent): - def __init__(self, email): - self.email = email - - def to_representation(self): - return { - 'email': self.email - } - - -class BlacklistUnsubscribeRequestSuccessEvent(BaseBadgrEvent): - def __init__(self, email): - self.email = email - - def to_representation(self): - return { - 'email': self.email - } - - -class BlacklistUnsubscribeRequestFailedEvent(BaseBadgrEvent): - def __init__(self, email): - self.email = email - - def to_representation(self): - return { - 'email': self.email - } diff --git a/apps/badgrlog/events/composition.py b/apps/badgrlog/events/composition.py deleted file mode 100644 index 42acb8af0..000000000 --- a/apps/badgrlog/events/composition.py +++ /dev/null @@ -1,38 +0,0 @@ -# Created by wiggins@concentricsky.com on 9/10/15. -from .base import BaseBadgrEvent - - -class BadgeUploaded(BaseBadgrEvent): - def __init__(self, instance): - self.instance = instance - - def to_representation(self): - user_id = '' - if self.instance.recipient_user is not None and self.instance.recipient_user.entity_id is not None: - user_id = self.instance.recipient_user.entity_id - return { - 'user_entityId': user_id, - 'badgeInstance': self.instance - } - - -class InvalidBadgeUploadReport: - def __init__(self, image_data='', user_entity_id='', error_name='', error_result=''): - self.image_data = image_data - self.user_entity_id = user_entity_id - self.error_name = error_name - self.error_result = error_result - - -class InvalidBadgeUploaded(BaseBadgrEvent): - - def __init__(self, error_report): - self.error_report = error_report - - def to_representation(self): - return { - 'userId': self.error_report.user_entity_id, - 'imageData': self.error_report.image_data, - 'errorName': self.error_report.error_name, - 'errorMessage': self.error_report.error_result - } diff --git a/apps/badgrlog/events/email.py b/apps/badgrlog/events/email.py deleted file mode 100644 index d2f56eec6..000000000 --- a/apps/badgrlog/events/email.py +++ /dev/null @@ -1,13 +0,0 @@ -from .base import BaseBadgrEvent - - -class EmailRendered(BaseBadgrEvent): - def __init__(self, email): - self.email = email - - def to_representation(self): - return { - 'subject': self.email.subject, - 'fromAddress': self.email.from_email, - 'toAddress': self.email.to - } diff --git a/apps/badgrlog/events/issuer.py b/apps/badgrlog/events/issuer.py deleted file mode 100644 index 1e6d78d3c..000000000 --- a/apps/badgrlog/events/issuer.py +++ /dev/null @@ -1,79 +0,0 @@ -# Created by wiggins@concentricsky.com on 8/27/15. -from django.conf import settings - -from mainsite.utils import OriginSetting -from .base import BaseBadgrEvent - - -class IssuerCreatedEvent(BaseBadgrEvent): - def __init__(self, issuer): - self.issuer = issuer - - def to_representation(self): - return { - 'creator': self.issuer.cached_creator, - 'issuer': self.issuer.json, - 'image': self.issuer.image, - } - - -class BadgeClassCreatedEvent(BaseBadgrEvent): - def __init__(self, badge_class): - self.badge_class = badge_class - - def to_representation(self): - try: - image_data = { - 'id': self.badge_class.image.url, - } - except ValueError: - image_data = {} - - if hasattr(self.badge_class.image, 'size'): - image_data['size'] = self.badge_class.image.size - if hasattr(self.badge_class.image, 'content_type'): - image_data['fileType'] = self.badge_class.image.content_type - return { - 'creator': self.badge_class.cached_creator, - 'badgeClass': self.badge_class.json, - 'image': image_data - } - - -class BadgeClassDeletedEvent(BaseBadgrEvent): - def __init__(self, badge_class, user): - self.badge_class = badge_class - self.user = user - - def to_representation(self): - return { - 'user': self.user, - 'badgeClass': self.badge_class.json, - } - - -class BadgeInstanceCreatedEvent(BaseBadgrEvent): - def __init__(self, badge_instance): - self.badge_instance = badge_instance - - def to_representation(self): - return { - 'creator': self.badge_instance.created_by, - 'issuer': self.badge_instance.issuer.jsonld_id, - 'recipient': self.badge_instance.recipient_identifier, - 'badgeClass': self.badge_instance.badgeclass.jsonld_id, - 'badgeInstance': self.badge_instance.json, - } - - -class BadgeAssertionRevokedEvent(BaseBadgrEvent): - def __init__(self, badge_instance, user): - self.badge_instance = badge_instance - self.user = user - - def to_representation(self): - return { - 'user': self.user, - 'badgeInstance': self.badge_instance.json - } - diff --git a/apps/badgrlog/events/public.py b/apps/badgrlog/events/public.py deleted file mode 100644 index 8a635ae3b..000000000 --- a/apps/badgrlog/events/public.py +++ /dev/null @@ -1,53 +0,0 @@ -# Created by wiggins@concentricsky.com on 8/27/15. -from .base import BaseBadgrEvent -from mainsite.utils import client_ip_from_request - - -class BaseBadgeAssertionEvent(BaseBadgrEvent): - def __init__(self, badge_instance, request): - self.badge_instance = badge_instance - self.request = request - - def to_representation(self): - return { - 'ipAddress': client_ip_from_request(self.request), - 'badgeInstance': self.badge_instance.json, - 'referer': self.request.META.get('HTTP_REFERER') - } - - -class BadgeAssertionCheckedEvent(BaseBadgeAssertionEvent): - pass - - -class RevokedBadgeAssertionCheckedEvent(BaseBadgeAssertionEvent): - pass - - -class BadgeInstanceDownloadedEvent(BaseBadgeAssertionEvent): - pass - - -class BadgeClassRetrievedEvent(BaseBadgeAssertionEvent): - pass - - -class BadgeClassCriteriaRetrievedEvent(BaseBadgeAssertionEvent): - pass - - -class BadgeClassImageRetrievedEvent(BaseBadgeAssertionEvent): - pass - - -class IssuerRetrievedEvent(BaseBadgeAssertionEvent): - pass - - -class IssuerBadgesRetrievedEvent(BaseBadgeAssertionEvent): - pass - - -class IssuerImageRetrievedEvent(BaseBadgeAssertionEvent): - pass - diff --git a/apps/badgrlog/urls.py b/apps/badgrlog/urls.py deleted file mode 100644 index 05881246b..000000000 --- a/apps/badgrlog/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.conf.urls import url - -from .views import BadgrLogContextView - -urlpatterns = [ - url(r'^v1$', BadgrLogContextView.as_view(), name='badgr_log_context'), -] diff --git a/apps/badgrlog/views.py b/apps/badgrlog/views.py deleted file mode 100644 index 6b9938a0e..000000000 --- a/apps/badgrlog/views.py +++ /dev/null @@ -1,39 +0,0 @@ -from rest_framework.response import Response -from rest_framework.views import APIView - - -class BadgrLogContextView(APIView): - permission_classes = [] - def get(self, request): - - return Response( - { - "@context": [ - "https://w3id.org/openbadges/v1", - { - "sioc": "http://rdfs.org/sioc/ns#", - "badgeInstance": "obi:assertion", - "badgeClass": "obi:badge", - - "Action": "schema:action", - "Image": "schema:ImageObject", - - "timestamp": "schema:endTime", - "user": "schema:agent", - "ipAddress": "sioc:ip_address", - "username": "sioc:name", - "givenName": "schema:givenName", - "familyName": "schema:familyName", - "notification": "http://www.w3.org/ns/odrl/2/deliveryChannel", - - "results": "obi:TBD", - "error": "obi:TBD", - "creator": "obi:TBD", - "size": "obi:TBD", - "fileType": "obi:TBD", - "actionType": "obi:TBD" - - } - ] - } - ) diff --git a/apps/badgrsocialauth/adapter.py b/apps/badgrsocialauth/adapter.py index a929c8a3d..57fe00fb8 100644 --- a/apps/badgrsocialauth/adapter.py +++ b/apps/badgrsocialauth/adapter.py @@ -1,8 +1,10 @@ import logging -import urllib.request, urllib.parse, urllib.error +import urllib.request +import urllib.parse +import urllib.error from allauth.account.utils import user_email -from allauth.exceptions import ImmediateHttpResponse +from allauth.core.exceptions import ImmediateHttpResponse from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from django.conf import settings from django.http import HttpResponseForbidden, HttpResponseRedirect @@ -10,24 +12,32 @@ from rest_framework.exceptions import AuthenticationFailed from badgeuser.authcode import accesstoken_for_authcode -from badgrsocialauth.utils import set_session_verification_email, get_session_authcode, generate_provider_identifier -from badgeuser.models import CachedEmailAddress, UserRecipientIdentifier +from badgrsocialauth.utils import ( + set_session_verification_email, + get_session_authcode, + generate_provider_identifier, +) +from badgeuser.models import UserRecipientIdentifier from mainsite.models import BadgrApp class BadgrSocialAccountAdapter(DefaultSocialAccountAdapter): - - def authentication_error(self, request, provider_id, error=None, exception=None, extra_context=None): - logging.getLogger(__name__).info( - 'social login authentication error: %s' % { - 'error': error, - 'exception': exception, - 'extra_context': extra_context, - }) + def authentication_error( + self, request, provider_id, error=None, exception=None, extra_context=None + ): + logging.getLogger("Badgr.Events").info( + "social login authentication error: %s" + % { + "error": error, + "exception": exception, + "extra_context": extra_context, + } + ) badgr_app = BadgrApp.objects.get_current(self.request) redirect_url = "{url}?authError={message}".format( url=badgr_app.ui_login_redirect, - message=urllib.parse.quote("Authentication error")) + message=urllib.parse.quote("Authentication error"), + ) raise ImmediateHttpResponse(HttpResponseRedirect(redirect_to=redirect_url)) def _update_session(self, request, sociallogin): @@ -40,10 +50,18 @@ def save_user(self, request, sociallogin, form=None): """ self._update_session(request, sociallogin) - user = super(BadgrSocialAccountAdapter, self).save_user(request, sociallogin, form) - - if sociallogin.account.provider in getattr(settings, 'SOCIALACCOUNT_RECIPIENT_ID_PROVIDERS', ['twitter']): - UserRecipientIdentifier.objects.create(user=user, verified=True, identifier=generate_provider_identifier(sociallogin)) + user = super(BadgrSocialAccountAdapter, self).save_user( + request, sociallogin, form + ) + + if sociallogin.account.provider in getattr( + settings, "SOCIALACCOUNT_RECIPIENT_ID_PROVIDERS", ["twitter"] + ): + UserRecipientIdentifier.objects.create( + user=user, + verified=True, + identifier=generate_provider_identifier(sociallogin), + ) return user @@ -54,13 +72,16 @@ def get_connect_redirect_url(self, request, socialaccount): """ assert request.user.is_authenticated - if socialaccount.provider in getattr(settings, 'SOCIALACCOUNT_RECIPIENT_ID_PROVIDERS', ['twitter']): + if socialaccount.provider in getattr( + settings, "SOCIALACCOUNT_RECIPIENT_ID_PROVIDERS", ["twitter"] + ): UserRecipientIdentifier.objects.get_or_create( - user=socialaccount.user, identifier=generate_provider_identifier(socialaccount=socialaccount), - defaults={'verified': True} + user=socialaccount.user, + identifier=generate_provider_identifier(socialaccount=socialaccount), + defaults={"verified": True}, ) - url = reverse('socialaccount_connections') + url = reverse("socialaccount_connections") return url def pre_social_login(self, request, sociallogin): @@ -81,19 +102,30 @@ def pre_social_login(self, request, sociallogin): badgr_app = BadgrApp.objects.get_current(self.request) redirect_url = "{url}?authError={message}".format( url=badgr_app.ui_connect_success_redirect, - message=urllib.parse.quote("Could not add social login. This account is already associated with a user.")) - raise ImmediateHttpResponse(HttpResponseRedirect(redirect_to=redirect_url)) + message=urllib.parse.quote( + "Could not add social login. This account is already associated with a user." + ), + ) + raise ImmediateHttpResponse( + HttpResponseRedirect(redirect_to=redirect_url) + ) elif sociallogin.is_existing and len(sociallogin.email_addresses): # See if we should mark an unverified email address as verified try: - should_verify = settings.SOCIALACCOUNT_PROVIDERS[sociallogin.account.provider]['VERIFIED_EMAIL'] + should_verify = settings.SOCIALACCOUNT_PROVIDERS[ + sociallogin.account.provider + ]["VERIFIED_EMAIL"] if should_verify and not sociallogin.user.verified: email = sociallogin.email_addresses[0].email user_emails = sociallogin.user.cached_emails() this_email = [e for e in user_emails if e.email == email][0] this_email.verified = True this_email.save() - except (AttributeError, IndexError, KeyError,): + except ( + AttributeError, + IndexError, + KeyError, + ): pass except AuthenticationFailed as e: diff --git a/apps/badgrsocialauth/admin.py b/apps/badgrsocialauth/admin.py index 486ae1160..42a36dac8 100644 --- a/apps/badgrsocialauth/admin.py +++ b/apps/badgrsocialauth/admin.py @@ -7,20 +7,26 @@ class Saml2ConfigurationAdminForm(forms.ModelForm): - class Meta: model = Saml2Configuration - fields = ('metadata_conf_url', 'cached_metadata', 'slug', 'use_signed_authn_request', - 'custom_settings') - + fields = ( + "metadata_conf_url", + "cached_metadata", + "slug", + "use_signed_authn_request", + "custom_settings", + ) def clean(self): - custom_settings = self.cleaned_data.get('custom_settings') + custom_settings = self.cleaned_data.get("custom_settings") try: data = json.loads(custom_settings) if not isinstance(data, dict): raise ValueError() - except (TypeError, ValueError,): + except ( + TypeError, + ValueError, + ): raise forms.ValidationError( "custom_settings must be a valid JSON. email, first_name, and last_name keys are valid." ) @@ -30,12 +36,15 @@ def clean(self): class Saml2ConfigurationModelAdmin(ModelAdmin): form = Saml2ConfigurationAdminForm - readonly_fields = ('acs_url', 'sp_metadata_url') + readonly_fields = ("acs_url", "sp_metadata_url") + badgr_admin.register(Saml2Configuration, Saml2ConfigurationModelAdmin) class Saml2AccountModelAdmin(ModelAdmin): - raw_id_fields = ('user',) + raw_id_fields = ("user",) model = Saml2Account + + badgr_admin.register(Saml2Account, Saml2AccountModelAdmin) diff --git a/apps/badgrsocialauth/api.py b/apps/badgrsocialauth/api.py index 8ab9e93ff..054403e06 100644 --- a/apps/badgrsocialauth/api.py +++ b/apps/badgrsocialauth/api.py @@ -7,7 +7,11 @@ from django.urls import reverse from oauth2_provider.models import AccessToken from rest_framework.response import Response -from rest_framework.status import HTTP_404_NOT_FOUND, HTTP_204_NO_CONTENT, HTTP_403_FORBIDDEN +from rest_framework.status import ( + HTTP_404_NOT_FOUND, + HTTP_204_NO_CONTENT, + HTTP_403_FORBIDDEN, +) from rest_framework.views import APIView from badgeuser.authcode import authcode_for_accesstoken @@ -20,6 +24,7 @@ from entity.serializers import BaseSerializerV2 from issuer.permissions import BadgrOAuthTokenHasScope from mainsite.utils import OriginSetting +from drf_spectacular.utils import extend_schema class BadgrSocialAccountList(BaseEntityListView): @@ -27,10 +32,7 @@ class BadgrSocialAccountList(BaseEntityListView): v1_serializer_class = BadgrSocialAccountSerializerV1 v2_serializer_class = BadgrSocialAccountSerializerV2 permission_classes = (BadgrOAuthTokenHasScope,) - valid_scopes = { - 'get': ['r:profile', 'rw:profile'], - 'post': ['rw:profile'] - } + valid_scopes = {"get": ["r:profile", "rw:profile"], "post": ["rw:profile"]} def get_objects(self, request, **kwargs): oauth2_objects = self.request.user.socialaccount_set.all() @@ -41,30 +43,32 @@ def get(self, request, **kwargs): return super(BadgrSocialAccountList, self).get(request, **kwargs) +@extend_schema(exclude=True) class BadgrSocialAccountConnect(APIView): permission_classes = (BadgrOAuthTokenHasScope,) - valid_scopes = ['rw:profile'] + valid_scopes = ["rw:profile"] def get(self, request, **kwargs): if not isinstance(request.auth, AccessToken): raise ValidationError("Invalid credentials") - provider_name = self.request.GET.get('provider', None) + provider_name = self.request.GET.get("provider", None) if provider_name is None: - raise ValidationError('No provider specified') + raise ValidationError("No provider specified") authcode = authcode_for_accesstoken(request.auth) redirect_url = "{origin}{url}?provider={provider}&authCode={code}".format( origin=OriginSetting.HTTP, - url=reverse('socialaccount_login'), + url=reverse("socialaccount_login"), provider=provider_name, - code=authcode) + code=authcode, + ) response_data = dict(url=redirect_url) - if kwargs['version'] == 'v1': + if kwargs["version"] == "v1": return Response(response_data) - return Response(BaseSerializerV2.response_envelope(response_data, True, 'OK')) + return Response(BaseSerializerV2.response_envelope(response_data, True, "OK")) class BadgrSocialAccountDetail(BaseEntityDetailView): @@ -73,20 +77,20 @@ class BadgrSocialAccountDetail(BaseEntityDetailView): v2_serializer_class = BadgrSocialAccountSerializerV2 permission_classes = (BadgrOAuthTokenHasScope, IsSocialAccountOwner) valid_scopes = { - 'get': ['r:profile', 'rw:profile'], - 'post': ['rw:profile'], - 'delete': ['rw:profile'] + "get": ["r:profile", "rw:profile"], + "post": ["rw:profile"], + "delete": ["rw:profile"], } def get_object(self, request, **kwargs): try: - saml_id = re.match(r'saml2\.([0-9]+)$', kwargs['id']).group(1) + saml_id = re.match(r"saml2\.([0-9]+)$", kwargs["id"]).group(1) return Saml2Account.objects.get(id=saml_id) except Saml2Account.DoesNotExist: pass except AttributeError: # None no-match case doesn't have .group attribute try: - return SocialAccount.objects.get(id=kwargs['id']) + return SocialAccount.objects.get(id=kwargs["id"]) except SocialAccount.DoesNotExist: pass @@ -107,8 +111,10 @@ def delete(self, request, **kwargs): except ValidationError as e: return Response(e.message, status=HTTP_403_FORBIDDEN) - if social_account.provider == 'twitter': - identifier = 'https://twitter.com/{}'.format(social_account.extra_data.get('screen_name', '').lower()) + if social_account.provider == "twitter": + identifier = "https://twitter.com/{}".format( + social_account.extra_data.get("screen_name", "").lower() + ) try: uri = UserRecipientIdentifier.objects.get(identifier=identifier) uri.delete() diff --git a/apps/badgrsocialauth/migrations/0001_initial.py b/apps/badgrsocialauth/migrations/0001_initial.py index a7ffef2a8..900c2124d 100644 --- a/apps/badgrsocialauth/migrations/0001_initial.py +++ b/apps/badgrsocialauth/migrations/0001_initial.py @@ -8,7 +8,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -17,28 +16,62 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Saml2Account', + name="Saml2Account", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.CharField(max_length=255, unique=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uuid", models.CharField(max_length=255, unique=True)), ], ), migrations.CreateModel( - name='Saml2Configuration', + name="Saml2Configuration", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('metadata_conf_url', models.URLField(help_text=b'The URL for the XML configuration for SAML2 flows. Get this from the Identity Provider Application.', verbose_name=b'Metadata Configuration URL')), - ('slug', models.CharField(help_text=b'This slug must be prefixed with saml2.', max_length=32, unique=True)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata_conf_url", + models.URLField( + help_text=b"The URL for the XML configuration for SAML2 flows. Get this from the Identity Provider Application.", + verbose_name=b"Metadata Configuration URL", + ), + ), + ( + "slug", + models.CharField( + help_text=b"This slug must be prefixed with saml2.", + max_length=32, + unique=True, + ), + ), ], ), migrations.AddField( - model_name='saml2account', - name='config', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='badgrsocialauth.Saml2Configuration'), + model_name="saml2account", + name="config", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="badgrsocialauth.Saml2Configuration", + ), ), migrations.AddField( - model_name='saml2account', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="saml2account", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), ), ] diff --git a/apps/badgrsocialauth/migrations/0002_saml2configuration_cached_metadata.py b/apps/badgrsocialauth/migrations/0002_saml2configuration_cached_metadata.py index d5546ace6..90e917b52 100644 --- a/apps/badgrsocialauth/migrations/0002_saml2configuration_cached_metadata.py +++ b/apps/badgrsocialauth/migrations/0002_saml2configuration_cached_metadata.py @@ -6,15 +6,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgrsocialauth', '0001_initial'), + ("badgrsocialauth", "0001_initial"), ] operations = [ migrations.AddField( - model_name='saml2configuration', - name='cached_metadata', - field=models.TextField(blank=True, default=b'', help_text=b'If the XML is provided here we avoid making a network request to the metadata_conf_url.'), + model_name="saml2configuration", + name="cached_metadata", + field=models.TextField( + blank=True, + default=b"", + help_text=b"If the XML is provided here we avoid making a network request to the metadata_conf_url.", + ), ), ] diff --git a/apps/badgrsocialauth/migrations/0003_saml2configuration_use_signed_authn_request.py b/apps/badgrsocialauth/migrations/0003_saml2configuration_use_signed_authn_request.py index da25887bd..226c1bddb 100644 --- a/apps/badgrsocialauth/migrations/0003_saml2configuration_use_signed_authn_request.py +++ b/apps/badgrsocialauth/migrations/0003_saml2configuration_use_signed_authn_request.py @@ -6,15 +6,17 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgrsocialauth', '0002_saml2configuration_cached_metadata'), + ("badgrsocialauth", "0002_saml2configuration_cached_metadata"), ] operations = [ migrations.AddField( - model_name='saml2configuration', - name='use_signed_authn_request', - field=models.BooleanField(default=False, help_text=b'triggers a user\'s browser to POST a signed Authn Request to the client\'s authorization URL'), + model_name="saml2configuration", + name="use_signed_authn_request", + field=models.BooleanField( + default=False, + help_text=b"triggers a user's browser to POST a signed Authn Request to the client's authorization URL", + ), ), ] diff --git a/apps/badgrsocialauth/migrations/0004_auto_20200608_0452.py b/apps/badgrsocialauth/migrations/0004_auto_20200608_0452.py index 344ca1ac5..abb487e8a 100644 --- a/apps/badgrsocialauth/migrations/0004_auto_20200608_0452.py +++ b/apps/badgrsocialauth/migrations/0004_auto_20200608_0452.py @@ -4,30 +4,40 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgrsocialauth', '0003_saml2configuration_use_signed_authn_request'), + ("badgrsocialauth", "0003_saml2configuration_use_signed_authn_request"), ] operations = [ migrations.AlterField( - model_name='saml2configuration', - name='cached_metadata', - field=models.TextField(blank=True, default='', help_text='If the XML is provided here we avoid making a network request to the metadata_conf_url.'), + model_name="saml2configuration", + name="cached_metadata", + field=models.TextField( + blank=True, + default="", + help_text="If the XML is provided here we avoid making a network request to the metadata_conf_url.", + ), ), migrations.AlterField( - model_name='saml2configuration', - name='metadata_conf_url', - field=models.URLField(help_text='The URL for the XML configuration for SAML2 flows. Get this from the Identity Provider Application.', verbose_name='Metadata Configuration URL'), + model_name="saml2configuration", + name="metadata_conf_url", + field=models.URLField( + help_text="The URL for the XML configuration for SAML2 flows. Get this from the Identity Provider Application.", + verbose_name="Metadata Configuration URL", + ), ), migrations.AlterField( - model_name='saml2configuration', - name='slug', - field=models.CharField(help_text='This slug must be prefixed with saml2.', max_length=32, unique=True), + model_name="saml2configuration", + name="slug", + field=models.CharField( + help_text="This slug must be prefixed with saml2.", + max_length=32, + unique=True, + ), ), migrations.AlterField( - model_name='saml2configuration', - name='use_signed_authn_request', + model_name="saml2configuration", + name="use_signed_authn_request", field=models.BooleanField(default=False), ), ] diff --git a/apps/badgrsocialauth/migrations/0005_saml2configuration_custom_settings.py b/apps/badgrsocialauth/migrations/0005_saml2configuration_custom_settings.py index 9c95ab574..f176a1060 100644 --- a/apps/badgrsocialauth/migrations/0005_saml2configuration_custom_settings.py +++ b/apps/badgrsocialauth/migrations/0005_saml2configuration_custom_settings.py @@ -4,15 +4,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('badgrsocialauth', '0004_auto_20200608_0452'), + ("badgrsocialauth", "0004_auto_20200608_0452"), ] operations = [ migrations.AddField( - model_name='saml2configuration', - name='custom_settings', - field=models.TextField(blank=True, default='{}', help_text='Valid JSON for claim names accepted for local values like email, first_name, last_name'), + model_name="saml2configuration", + name="custom_settings", + field=models.TextField( + blank=True, + default="{}", + help_text="Valid JSON for claim names accepted for local values like email, first_name, last_name", + ), ), ] diff --git a/apps/badgrsocialauth/models.py b/apps/badgrsocialauth/models.py index dd4c3345a..342303ad1 100644 --- a/apps/badgrsocialauth/models.py +++ b/apps/badgrsocialauth/models.py @@ -9,11 +9,26 @@ class Saml2Configuration(models.Model): - metadata_conf_url = models.URLField(verbose_name="Metadata Configuration URL", help_text="The URL for the XML configuration for SAML2 flows. Get this from the Identity Provider Application.") - cached_metadata = models.TextField(default='', blank=True, help_text="If the XML is provided here we avoid making a network request to the metadata_conf_url.") - slug = models.CharField(max_length=32, unique=True, help_text="This slug must be prefixed with saml2.") + metadata_conf_url = models.URLField( + verbose_name="Metadata Configuration URL", + help_text="The URL for the XML configuration " + "for SAML2 flows. Get this from the Identity " + "Provider Application.", + ) + cached_metadata = models.TextField( + default="", + blank=True, + help_text="If the XML is provided here we avoid making a network request to the metadata_conf_url.", + ) + slug = models.CharField( + max_length=32, unique=True, help_text="This slug must be prefixed with saml2." + ) use_signed_authn_request = models.BooleanField(default=False) - custom_settings = models.TextField(default='{}', blank=True, help_text="Valid JSON for claim names accepted for local values like email, first_name, last_name") + custom_settings = models.TextField( + default="{}", + blank=True, + help_text="Valid JSON for claim names accepted for local values like email, first_name, last_name", + ) def __str__(self): return self.slug @@ -22,23 +37,26 @@ def acs_url(self): if not self.slug: return "" return "{}{}".format( - getattr(settings, 'HTTP_ORIGIN', ''), - reverse('assertion_consumer_service', kwargs={'idp_name': self.slug}) + getattr(settings, "HTTP_ORIGIN", ""), + reverse("assertion_consumer_service", kwargs={"idp_name": self.slug}), ) def sp_metadata_url(self): if not self.slug: return "" return "{}{}".format( - getattr(settings, 'HTTP_ORIGIN', ''), - reverse('saml2_sp_metadata', kwargs={'idp_name': self.slug}) + getattr(settings, "HTTP_ORIGIN", ""), + reverse("saml2_sp_metadata", kwargs={"idp_name": self.slug}), ) @property def custom_settings_data(self): try: return json.loads(self.custom_settings) - except (TypeError, ValueError,): + except ( + TypeError, + ValueError, + ): return dict() def save(self, **kwargs): @@ -47,23 +65,20 @@ def save(self, **kwargs): class Saml2Account(models.Model): - user = models.ForeignKey(BadgeUser, - on_delete=models.CASCADE) - config = models.ForeignKey(Saml2Configuration, - on_delete=models.CASCADE) + user = models.ForeignKey(BadgeUser, on_delete=models.CASCADE) + config = models.ForeignKey(Saml2Configuration, on_delete=models.CASCADE) uuid = models.CharField(max_length=255, unique=True) def __str__(self): return "{} on {}".format(self.uuid, self.config) - @property def uid(self): return self.uuid @property def account_identifier(self): - return 'saml2.{}'.format(self.pk) + return "saml2.{}".format(self.pk) @property def provider(self): diff --git a/apps/badgrsocialauth/permissions.py b/apps/badgrsocialauth/permissions.py index 795043851..91698f9a9 100644 --- a/apps/badgrsocialauth/permissions.py +++ b/apps/badgrsocialauth/permissions.py @@ -7,4 +7,4 @@ class IsSocialAccountOwner(permissions.BasePermission): """ def has_object_permission(self, request, view, obj): - return obj.user == request.user \ No newline at end of file + return obj.user == request.user diff --git a/apps/badgrsocialauth/providers/facebook/provider.py b/apps/badgrsocialauth/providers/facebook/provider.py index b7c4ede97..2247d56c6 100644 --- a/apps/badgrsocialauth/providers/facebook/provider.py +++ b/apps/badgrsocialauth/providers/facebook/provider.py @@ -1,12 +1,15 @@ from allauth.account.models import EmailAddress from allauth.socialaccount import providers -from allauth.socialaccount.providers.facebook.provider import FacebookProvider, FacebookAccount +from allauth.socialaccount.providers.facebook.provider import ( + FacebookProvider, + FacebookAccount, +) class VerifiedFacebookProvider(FacebookProvider): - id = 'facebook' - name = 'Facebook' - package = 'allauth.socialaccount.providers.facebook' + id = "facebook" + name = "Facebook" + package = "allauth.socialaccount.providers.facebook" account_class = FacebookAccount def extract_email_addresses(self, data): @@ -14,11 +17,16 @@ def extract_email_addresses(self, data): Force verification of email addresses """ ret = [] - email = data.get('email') - if email and data.get('email'): - ret.append(EmailAddress(email=email, - verified=True, # Originally verified=False - primary=True)) + email = data.get("email") + if email and data.get("email"): + ret.append( + EmailAddress( + email=email, + verified=True, # Originally verified=False + primary=True, + ) + ) return ret + providers.registry.register(VerifiedFacebookProvider) diff --git a/apps/badgrsocialauth/providers/facebook/tests.py b/apps/badgrsocialauth/providers/facebook/tests.py deleted file mode 100644 index 6ccc007b7..000000000 --- a/apps/badgrsocialauth/providers/facebook/tests.py +++ /dev/null @@ -1,57 +0,0 @@ -import base64 -import urllib.parse -from allauth.tests import MockedResponse - -from badgeuser.models import BadgeUser -from badgrsocialauth.providers.tests.base import BadgrOAuth2TestsMixin, BadgrSocialAuthTestCase -from badgrsocialauth.providers.tests.test_third_party import DoesNotSendVerificationEmailMixin - -from .provider import VerifiedFacebookProvider - - -class VerifiedFacebookProviderTests(DoesNotSendVerificationEmailMixin, BadgrOAuth2TestsMixin, BadgrSocialAuthTestCase): - provider_id = VerifiedFacebookProvider.id - - def get_mocked_response(self): - response = MockedResponse(200, """ - { - "id": "630595557", - "name": "Raymond Penners", - "first_name": "Raymond", - "last_name": "Penners, Ph.D, Extraordinary, Famous, Most Respected", - "email": "raymond.penners@example.com", - "link": "https://www.facebook.com/raymond.penners", - "username": "raymond.penners", - "birthday": "07/17/1973", - "work": [ - { - "employer": { - "id": "204953799537777", - "name": "IntenCT" - } - } - ], - "timezone": 1, - "locale": "nl_NL", - "verified": true, - "updated_time": "2012-11-30T20:40:33+0000" - }""") - response.ok = True - return response - - def test_can_add_facebook_account_to_profile(self): - user = self.setup_user(token_scope="rw:profile") - response = self.client.get('/v1/user/socialaccounts/connect?provider=facebook') - self.assertEqual(response.status_code, 200) - - def test_cannot_add_to_account_when_email_already_verified(self): - user = self.setup_user(authenticate=False, email='raymond.penners@example.com') - response = self.login(self.get_mocked_response()) - self.assertEqual(BadgeUser.objects.all().count(), 1, "There is still only one user.") - response = self.client.get(response._headers['location'][1]) - self.assertEqual(response.status_code, 302) - url = urllib.parse.urlparse(response._headers['location'][1]) - query = dict(urllib.parse.parse_qsl(url[4])) - self.assertEqual(base64.urlsafe_b64decode(query['email']), b'raymond.penners@example.com') - self.assertEqual(query['socialAuthSlug'], 'facebook') - self.assertEqual(url.path, "/fail") diff --git a/apps/badgrsocialauth/providers/kony/provider.py b/apps/badgrsocialauth/providers/kony/provider.py index 7314e9b81..05bcab7d2 100644 --- a/apps/badgrsocialauth/providers/kony/provider.py +++ b/apps/badgrsocialauth/providers/kony/provider.py @@ -10,23 +10,25 @@ class KonyAccount(ProviderAccount): class KonyProvider(OAuthProvider): - id = 'kony' - name = 'Kony' - package = 'badgrsocialauth.providers.kony' + id = "kony" + name = "Kony" + package = "badgrsocialauth.providers.kony" account_class = KonyAccount def extract_uid(self, data): - return data['user_guid'] + return data["user_guid"] def extract_common_fields(self, data): return { - 'email': data['primary_email'], - 'first_name': data['first_name'], - 'last_name': data['last_name'] + "email": data["primary_email"], + "first_name": data["first_name"], + "last_name": data["last_name"], } def extract_email_addresses(self, data): - return [CachedEmailAddress(email=data['primary_email'], verified=True, primary=True)] + return [ + CachedEmailAddress(email=data["primary_email"], verified=True, primary=True) + ] providers.registry.register(KonyProvider) diff --git a/apps/badgrsocialauth/providers/kony/tests.py b/apps/badgrsocialauth/providers/kony/tests.py deleted file mode 100644 index 3c011735e..000000000 --- a/apps/badgrsocialauth/providers/kony/tests.py +++ /dev/null @@ -1,20 +0,0 @@ -from allauth.tests import MockedResponse - -from .provider import KonyProvider -from ..tests.base import BadgrSocialAuthTestCase, BadgrOAuthTestsMixin, DoesNotSendVerificationEmailMixin - - -class KonyProviderTests(DoesNotSendVerificationEmailMixin, BadgrOAuthTestsMixin, BadgrSocialAuthTestCase): - provider_id = KonyProvider.id - - def get_mocked_response(self): - # inferred by looking at KonyProvider implementation - return [ - MockedResponse(200, """ - { - "primary_email": "raymond.penners@intenct.nl", - "first_name": "Raymond", - "user_guid": "ZLARGMFT1M", - "last_name": "Penners" - }""") - ] diff --git a/apps/badgrsocialauth/providers/kony/urls.py b/apps/badgrsocialauth/providers/kony/urls.py index 4f8d43a6d..2dc623068 100644 --- a/apps/badgrsocialauth/providers/kony/urls.py +++ b/apps/badgrsocialauth/providers/kony/urls.py @@ -2,4 +2,4 @@ from badgrsocialauth.providers.kony.provider import KonyProvider -urlpatterns = default_urlpatterns(KonyProvider) \ No newline at end of file +urlpatterns = default_urlpatterns(KonyProvider) diff --git a/apps/badgrsocialauth/providers/kony/views.py b/apps/badgrsocialauth/providers/kony/views.py index 071e1a274..bc7631503 100644 --- a/apps/badgrsocialauth/providers/kony/views.py +++ b/apps/badgrsocialauth/providers/kony/views.py @@ -1,17 +1,19 @@ import json from allauth.socialaccount import app_settings from allauth.socialaccount.providers.oauth.client import OAuth -from allauth.socialaccount.providers.oauth.views import (OAuthAdapter, - OAuthLoginView, - OAuthCallbackView) +from allauth.socialaccount.providers.oauth.views import ( + OAuthAdapter, + OAuthLoginView, + OAuthCallbackView, +) from .provider import KonyProvider class KonyAPI(OAuth): - if app_settings.PROVIDERS['kony'].get('environment', 'dev') == 'dev': - url = 'https://api.dev-kony.com/api/v1_0/whoami' - elif app_settings.PROVIDERS['kony'].get('environment', 'prod') == 'prod': - url = 'https://api.kony.com/api/v1_0/whoami' + if app_settings.PROVIDERS["kony"].get("environment", "dev") == "dev": + url = "https://api.dev-kony.com/api/v1_0/whoami" + elif app_settings.PROVIDERS["kony"].get("environment", "prod") == "prod": + url = "https://api.kony.com/api/v1_0/whoami" def get_user_info(self): raw_json = self.query(self.url, method="POST") @@ -22,21 +24,20 @@ def get_user_info(self): class KonyOAuthAdapter(OAuthAdapter): provider_id = KonyProvider.id - if app_settings.PROVIDERS['kony'].get('environment', 'dev') == 'dev': - request_token_url = 'https://manage.dev-kony.com/oauth/request_token' - access_token_url = 'https://manage.dev-kony.com/oauth/access_token' - authorize_url = 'https://manage.dev-kony.com/oauth/authorize' - elif app_settings.PROVIDERS['kony'].get('environment', 'dev') == 'prod': - request_token_url = 'https://manage.kony.com/oauth/request_token' - access_token_url = 'https://manage.kony.com/oauth/access_token' - authorize_url = 'https://manage.kony.com/oauth/authorize' + if app_settings.PROVIDERS["kony"].get("environment", "dev") == "dev": + request_token_url = "https://manage.dev-kony.com/oauth/request_token" + access_token_url = "https://manage.dev-kony.com/oauth/access_token" + authorize_url = "https://manage.dev-kony.com/oauth/authorize" + elif app_settings.PROVIDERS["kony"].get("environment", "dev") == "prod": + request_token_url = "https://manage.kony.com/oauth/request_token" + access_token_url = "https://manage.kony.com/oauth/access_token" + authorize_url = "https://manage.kony.com/oauth/authorize" def complete_login(self, request, app, token, response): - client = KonyAPI(request, app.client_id, app.secret, - self.request_token_url) + client = KonyAPI(request, app.client_id, app.secret, self.request_token_url) extra_data = client.get_user_info() - return self.get_provider().sociallogin_from_response(request, - extra_data) + return self.get_provider().sociallogin_from_response(request, extra_data) + oauth_login = OAuthLoginView.adapter_view(KonyOAuthAdapter) oauth_callback = OAuthCallbackView.adapter_view(KonyOAuthAdapter) diff --git a/apps/badgrsocialauth/providers/oauth2_idtoken/adapter.py b/apps/badgrsocialauth/providers/oauth2_idtoken/adapter.py index e8fda04e5..2edd7d15a 100644 --- a/apps/badgrsocialauth/providers/oauth2_idtoken/adapter.py +++ b/apps/badgrsocialauth/providers/oauth2_idtoken/adapter.py @@ -10,122 +10,143 @@ from allauth.socialaccount import app_settings from allauth.socialaccount.models import SocialToken from allauth.socialaccount.providers.oauth2.views import ( - OAuth2Adapter, OAuth2CallbackView, OAuth2LoginView) + OAuth2Adapter, + OAuth2CallbackView, + OAuth2LoginView, +) from allauth.socialaccount.providers.oauth2.client import OAuth2Client, OAuth2Error from django.core.exceptions import ImproperlyConfigured -from django.utils import timezone from django.utils.http import urlencode from socialauth.providers.log_configuration import debug_requests -from .provider import IdTokenProvider -logger = logging.getLogger(__name__) +logger = logging.getLogger("Badgr.Events") class IdTokenOAuth2Adapter(OAuth2Adapter): provider_id = None # override with Provider.id supports_state = False - redirect_uri_protocol = 'https' + redirect_uri_protocol = "https" def __init__(self, request): super(IdTokenOAuth2Adapter, self).__init__(request) try: - self.access_token_url = app_settings.PROVIDERS[self.provider_id]['access_token_url'] - self.authorize_url = app_settings.PROVIDERS[self.provider_id]['authorize_url'] - self.jwks_url = app_settings.PROVIDERS[self.provider_id]['jwks_url'] - self.intended_aud = app_settings.PROVIDERS[self.provider_id].get('aud', None) - self.response_type = app_settings.PROVIDERS[self.provider_id].get('response_type', 'code') - except KeyError as e: + self.access_token_url = app_settings.PROVIDERS[self.provider_id][ + "access_token_url" + ] + self.authorize_url = app_settings.PROVIDERS[self.provider_id][ + "authorize_url" + ] + self.jwks_url = app_settings.PROVIDERS[self.provider_id]["jwks_url"] + self.intended_aud = app_settings.PROVIDERS[self.provider_id].get( + "aud", None + ) + self.response_type = app_settings.PROVIDERS[self.provider_id].get( + "response_type", "code" + ) + except KeyError: raise ImproperlyConfigured(self.provider_id) def get_public_key(self, kid=None): try: # TODO fetch jwks url only if not cached jwks = requests.get(self.jwks_url).json() - available_keys = jwks['keys'] + available_keys = jwks["keys"] if kid is not None: - key = [key for key in available_keys if key.get('kid') == kid][0] + key = [key for key in available_keys if key.get("kid") == kid][0] else: key = available_keys[0] return jwk.JWK.from_json(json.dumps(key)) except (TypeError, KeyError, IndexError) as e: - raise OAuth2Error("Couldn't get JWKS File and process it: {}".format(e.message)) + raise OAuth2Error( + "Couldn't get JWKS File and process it: {}".format(e.message) + ) def parse_token(self, data, app=None): # parse token header and get relevant public key - unverified_header, unverified_claims = jwt.process_jwt(data['id_token']) - public_key = self.get_public_key(unverified_header.get('kid')) + unverified_header, unverified_claims = jwt.process_jwt(data["id_token"]) + public_key = self.get_public_key(unverified_header.get("kid")) # verify signature, iat, exp header, claims = jwt.verify_jwt( - data['id_token'], public_key, ['RS256'], timedelta(minutes=1), checks_optional=True + data["id_token"], + public_key, + ["RS256"], + timedelta(minutes=1), + checks_optional=True, ) # Verify we are the intended audience for the token intended_audiences = [self.intended_aud] if app is not None: intended_audiences.append(app.client_id) - if claims.get('aud') is not None and claims.get('aud') not in intended_audiences: - raise OAuth2Error("JWT aud {} does not match intended audience {}".format( - claims.get('aud'), self.intended_aud) + if ( + claims.get("aud") is not None + and claims.get("aud") not in intended_audiences + ): + raise OAuth2Error( + "JWT aud {} does not match intended audience {}".format( + claims.get("aud"), self.intended_aud + ) ) - social_token = SocialToken(token=data['id_token']) - social_token.expires_at = datetime.fromtimestamp(claims['iat'], tz=timezone.utc) + social_token = SocialToken(token=data["id_token"]) + social_token.expires_at = datetime.fromtimestamp( + claims["iat"], tz=datetime.timezone.utc + ) return social_token def complete_login(self, request, app, token, **kwargs): # token has already been verified, so we can use unverified data here unverified_header, unverified_claims = jwt.process_jwt(token.token) extra_data = unverified_claims - logger.debug('IdToken | IdTokenOAuth2Adapter:complete_login()' - 'token claims | %s' % str(extra_data)) + logger.debug( + "IdToken | IdTokenOAuth2Adapter:complete_login()" + "token claims | %s" % str(extra_data) + ) - return self.get_provider().sociallogin_from_response(request, - extra_data) + return self.get_provider().sociallogin_from_response(request, extra_data) class IdTokenOAuth2Client(OAuth2Client): def __init__(self, *args, **kwargs): super(IdTokenOAuth2Client, self).__init__(*args, **kwargs) - self.response_type = kwargs.get('response_type', 'code') + self.response_type = kwargs.get("response_type", "code") def get_redirect_url(self, authorization_url, extra_params): params = { - 'client_id': self.consumer_key, - 'redirect_uri': self.callback_url, - 'scope': self.scope, - 'response_type': self.response_type + "client_id": self.consumer_key, + "redirect_uri": self.callback_url, + "scope": self.scope, + "response_type": self.response_type, } if self.state: - params['state'] = self.state + params["state"] = self.state params.update(extra_params) - return '%s?%s' % (authorization_url, urlencode(params)) + return "%s?%s" % (authorization_url, urlencode(params)) def get_access_token(self, code): data = { - 'redirect_uri': self.callback_url, - 'grant_type': 'authorization_code', - 'code': code} + "redirect_uri": self.callback_url, + "grant_type": "authorization_code", + "code": code, + } if self.basic_auth: - auth = requests.auth.HTTPBasicAuth( - self.consumer_key, - self.consumer_secret) + auth = requests.auth.HTTPBasicAuth(self.consumer_key, self.consumer_secret) else: auth = None - data.update({ - 'client_id': self.consumer_key, - 'client_secret': self.consumer_secret - }) + data.update( + {"client_id": self.consumer_key, "client_secret": self.consumer_secret} + ) params = None self._strip_empty_keys(data) url = self.access_token_url - if self.access_token_method == 'GET': + if self.access_token_method == "GET": params = data data = None # TODO: Proper exception handling @@ -135,18 +156,21 @@ def get_access_token(self, code): params=params, data=data, headers=self.headers, - auth=auth) + auth=auth, + ) access_token = None if resp.status_code in [200, 201]: # Weibo sends json via 'text/plain;charset=UTF-8' - if resp.headers['content-type'].split(';')[0] == 'application/json' or resp.text[:2] == '{"': + if ( + resp.headers["content-type"].split(";")[0] == "application/json" + or resp.text[:2] == '{"' + ): access_token = resp.json() else: access_token = dict(parse_qsl(resp.text)) - if not access_token or 'id_token' not in access_token: - raise OAuth2Error('Error retrieving access token: %s' - % resp.content) + if not access_token or "id_token" not in access_token: + raise OAuth2Error("Error retrieving access token: %s" % resp.content) return access_token @@ -154,10 +178,12 @@ class IdTokenOAuth2CallbackView(OAuth2CallbackView): def get_client(self, request, app): callback_url = self.adapter.get_callback_url(request, app) provider = self.adapter.get_provider() - response_type = getattr(provider, 'response_type', 'code') + response_type = getattr(provider, "response_type", "code") scope = provider.get_scope(request) client = IdTokenOAuth2Client( - request, app.client_id, app.secret, + request, + app.client_id, + app.secret, request.access_token_method, self.adapter.access_token_url, callback_url, @@ -165,13 +191,15 @@ def get_client(self, request, app): scope_delimiter=self.adapter.scope_delimiter, headers=self.adapter.headers, basic_auth=self.adapter.basic_auth, - response_type=response_type + response_type=response_type, ) return client oauth2_login = OAuth2LoginView.adapter_view(IdTokenOAuth2Adapter) base_oauth2_callback = IdTokenOAuth2CallbackView.adapter_view(IdTokenOAuth2Adapter) + + def oauth2_callback(*args, **kwargs): with debug_requests(): return base_oauth2_callback(*args, **kwargs) diff --git a/apps/badgrsocialauth/providers/oauth2_idtoken/provider.py b/apps/badgrsocialauth/providers/oauth2_idtoken/provider.py index d6207b5a9..f01ba4ba5 100644 --- a/apps/badgrsocialauth/providers/oauth2_idtoken/provider.py +++ b/apps/badgrsocialauth/providers/oauth2_idtoken/provider.py @@ -6,13 +6,12 @@ from allauth.socialaccount.providers.oauth2.client import OAuth2Client, OAuth2Error from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider -logger = logging.getLogger(__name__) +logger = logging.getLogger("Badgr.Events") class IdTokenAccount(ProviderAccount): def to_str(self): - return self.account.extra_data.get( - 'name', super(IdTokenAccount, self).to_str()) + return self.account.extra_data.get("name", super(IdTokenAccount, self).to_str()) class IdTokenProvider(OAuth2Provider): @@ -23,43 +22,46 @@ class IdTokenProvider(OAuth2Provider): def __init__(self, request): super(IdTokenProvider, self).__init__(request) - def get_default_scope(self): - return ['openid', 'profile', 'email'] + return ["openid", "profile", "email"] def extract_uid(self, data): - logger.debug('{} | IdTokenProvider:extract_uid().data | {}'.format(self.id, str(data))) - return str(data['sub']) + logger.debug( + "{} | IdTokenProvider:extract_uid().data | {}".format(self.id, str(data)) + ) + return str(data["sub"]) def extract_common_fields(self, data): - logger.debug('{} | IdTokenProvider:extract_common_fields().data | %s'.format(self.id, str(data))) + logger.debug( + "{} | IdTokenProvider:extract_common_fields().data | {}".format( + self.id, str(data) + ) + ) return { - 'first_name': data['given_name'], - 'last_name': data['family_name'], - 'email': data['emails'][0], + "first_name": data["given_name"], + "last_name": data["family_name"], + "email": data["emails"][0], } class IdTokenOAuth2Client(OAuth2Client): def get_access_token(self, code): data = { - 'redirect_uri': self.callback_url, - 'grant_type': 'authorization_code', - 'code': code} + "redirect_uri": self.callback_url, + "grant_type": "authorization_code", + "code": code, + } if self.basic_auth: - auth = requests.auth.HTTPBasicAuth( - self.consumer_key, - self.consumer_secret) + auth = requests.auth.HTTPBasicAuth(self.consumer_key, self.consumer_secret) else: auth = None - data.update({ - 'client_id': self.consumer_key, - 'client_secret': self.consumer_secret - }) + data.update( + {"client_id": self.consumer_key, "client_secret": self.consumer_secret} + ) params = None self._strip_empty_keys(data) url = self.access_token_url - if self.access_token_method == 'GET': + if self.access_token_method == "GET": params = data data = None # TODO: Proper exception handling @@ -69,16 +71,19 @@ def get_access_token(self, code): params=params, data=data, headers=self.headers, - auth=auth) + auth=auth, + ) access_token = None if resp.status_code in [200, 201]: # Weibo sends json via 'text/plain;charset=UTF-8' - if resp.headers['content-type'].split(';')[0] == 'application/json' or resp.text[:2] == '{"': + if ( + resp.headers["content-type"].split(";")[0] == "application/json" + or resp.text[:2] == '{"' + ): access_token = resp.json() else: access_token = dict(parse_qsl(resp.text)) - if not access_token or 'id_token' not in access_token: - raise OAuth2Error('Error retrieving access token: %s' - % resp.content) + if not access_token or "id_token" not in access_token: + raise OAuth2Error("Error retrieving access token: %s" % resp.content) return access_token diff --git a/apps/badgrsocialauth/providers/oauth2_idtoken/views.py b/apps/badgrsocialauth/providers/oauth2_idtoken/views.py index d5c880877..c0d445f24 100644 --- a/apps/badgrsocialauth/providers/oauth2_idtoken/views.py +++ b/apps/badgrsocialauth/providers/oauth2_idtoken/views.py @@ -1,4 +1,7 @@ -from allauth.socialaccount.helpers import complete_social_login, render_authentication_error +from allauth.socialaccount.helpers import ( + complete_social_login, + render_authentication_error, +) from allauth.socialaccount.models import SocialLogin from allauth.socialaccount.providers.base import AuthError, ProviderException from allauth.socialaccount.providers.oauth2.client import OAuth2Error @@ -16,14 +19,16 @@ def get_client(self, request, app): provider = self.adapter.get_provider() scope = provider.get_scope(request) client = IdTokenOAuth2Client( - request, app.client_id, app.secret, + request, + app.client_id, + app.secret, self.adapter.access_token_method, self.adapter.access_token_url, callback_url, scope, scope_delimiter=self.adapter.scope_delimiter, headers=self.adapter.headers, - basic_auth=self.adapter.basic_auth + basic_auth=self.adapter.basic_auth, ) return client @@ -31,41 +36,39 @@ def dispatch(self, request, *args, **kwargs): """ Copied from base class to be able to pass the app to parse_token to use its data to match up to token claims. """ - if 'error' in request.GET or 'code' not in request.GET: + if "error" in request.GET or "code" not in request.GET: # Distinguish cancel from error - auth_error = request.GET.get('error', None) + auth_error = request.GET.get("error", None) if auth_error == self.adapter.login_cancelled_error: error = AuthError.CANCELLED else: error = AuthError.UNKNOWN return render_authentication_error( - request, - self.adapter.provider_id, - error=error) + request, self.adapter.provider_id, error=error + ) app = self.adapter.get_provider().get_app(self.request) client = self.get_client(request, app) try: - access_token = client.get_access_token(request.GET['code']) + access_token = client.get_access_token(request.GET["code"]) token = self.adapter.parse_token(access_token, app=app) token.app = app - login = self.adapter.complete_login(request, - app, - token, - response=access_token) + login = self.adapter.complete_login( + request, app, token, response=access_token + ) login.token = token if self.adapter.supports_state: - login.state = SocialLogin \ - .verify_and_unstash_state( - request, - get_request_param(request, 'state')) + login.state = SocialLogin.verify_and_unstash_state( + request, get_request_param(request, "state") + ) else: login.state = SocialLogin.unstash_state(request) return complete_social_login(request, login) - except (PermissionDenied, - OAuth2Error, - RequestException, - ProviderException) as e: + except ( + PermissionDenied, + OAuth2Error, + RequestException, + ProviderException, + ) as e: return render_authentication_error( - request, - self.adapter.provider_id, - exception=e) + request, self.adapter.provider_id, exception=e + ) diff --git a/apps/badgrsocialauth/providers/tests/base.py b/apps/badgrsocialauth/providers/tests/base.py deleted file mode 100644 index 91f7b5661..000000000 --- a/apps/badgrsocialauth/providers/tests/base.py +++ /dev/null @@ -1,129 +0,0 @@ -from unittest import skip - -from allauth.socialaccount.tests import OAuth2TestsMixin, OAuthTestsMixin -from django.core import mail -from django.test import override_settings -from django.urls import reverse - -from badgeuser.models import BadgeUser -from mainsite.tests import BadgrTestCase - - -class BadgrSocialAuthTestsMixin(object): - """ - Default tests include expectations broken by BadgrAccountAdapter, and - this overrides those to make more sense. - """ - - def login(self, resp_mock, **kwargs): - """ - Set session BadgrApp before attempting each login. - - BadgrAccountAdapter.login assumes a BadgrApp pk is stored in the session. It looks like - this means the BadgrSocialLogin view (which sets the session BadgrApp) is the only - supported login flow. - - Logging out clears the session, as expected. We need to re-set session BadgrApp before - attempting each login. - """ - session = self.client.session - session.update({ - 'badgr_app_pk': self.badgr_app.pk - }) - session.save() - - return super(BadgrSocialAuthTestsMixin, self).login(resp_mock, **kwargs) - - def assert_login_redirect(self, response): - self.assertEqual(response.status_code, 302) - redirect_url, query_string = response.url.split('?') - self.assertRegex(query_string, r'^authToken=[^\s]+$') - self.assertEqual(redirect_url, self.badgr_app.ui_login_redirect) - - def test_authentication_error(self): - # override: base implementation looks for a particular template to be rendered. - resp = self.client.get(reverse(self.provider.id + '_callback')) - # Tried assertRedirects here, but don't want to couple this to the query params - self.assertIn(self.badgr_app.ui_login_redirect, resp['Location']) - - def test_login(self): - # override: base implementation uses assertRedirects, but we need to - # allow for query params. Also, base implementation tests initial sign-up, - # but we want to test login behavior for an existing account. - - # Create a user account with a verified email. - with override_settings(SOCIALACCOUNT_PROVIDERS={self.provider.id: {'VERIFIED_EMAIL': True}}): - self.login(self.get_mocked_response()) - - # We call logout here because otherwise AllAuth will do it in - # the middle of the login machinery where we don't get a chance - # to reset the BadgrApp. - self.client.logout() - - # Test login behavior for existing user account. - response = self.login(self.get_mocked_response()) - self.assert_login_redirect(response) - - @skip('unused feature') - def test_auto_signup(self): - # override: don't test this. - pass - - def test_signup(self): - response = self.login(self.get_mocked_response()) - users = BadgeUser.objects.all() - user = users.get() # There can be only one. - if user.verified: - self.assert_login_redirect(response) - else: - self.assertRedirects(response, - reverse('account_email_verification_sent'), - fetch_redirect_response=False) - - def test_cached_email(self): - self.login(self.get_mocked_response()) - users = BadgeUser.objects.all() - user = users.get() # There can be only one. - self.assertEqual(len(user.cached_emails()), len(user.emailaddress_set.all())) - - -class BadgrOAuth2TestsMixin(BadgrSocialAuthTestsMixin, OAuth2TestsMixin): - """ - Tests for OAuth2Provider subclasses in this application should use this - mixin instead of OAuth2TestsMixin. - """ - - -class BadgrOAuthTestsMixin(BadgrSocialAuthTestsMixin, OAuthTestsMixin): - """ - Tests for OAuthProvider subclasses in this application should use this - mixin instead of OAuthTestsMixin. - """ - - -class SendsVerificationEmailMixin(object): - def test_verification_email(self): - # Expect this provider to send a verification email on first login - before_count = len(mail.outbox) - response = self.login(self.get_mocked_response()) - self.assertEqual(response.status_code, 302) # sanity - self.assertEqual(len(mail.outbox), before_count + 1) - - -class DoesNotSendVerificationEmailMixin(object): - def test_verification_email_is_not_sent(self): - # Expect this provider to NOT send a verification email on first login - before_count = len(mail.outbox) - response = self.login(self.get_mocked_response()) - self.assertEqual(response.status_code, 302) # sanity - self.assertEqual(len(mail.outbox), before_count) - - -@override_settings(UNSUBSCRIBE_SECRET_KEY='123a') -class BadgrSocialAuthTestCase(BadgrTestCase): - def setUp(self): - super(BadgrSocialAuthTestCase, self).setUp() - self.badgr_app.ui_login_redirect = 'http://test-badgr.io/' - self.badgr_app.ui_signup_failure_redirect = 'http://test-badgr.io/fail' - self.badgr_app.save() - diff --git a/apps/badgrsocialauth/providers/tests/test_third_party.py b/apps/badgrsocialauth/providers/tests/test_third_party.py deleted file mode 100644 index 46c60e1c1..000000000 --- a/apps/badgrsocialauth/providers/tests/test_third_party.py +++ /dev/null @@ -1,113 +0,0 @@ -from allauth.socialaccount.providers.azure.provider import AzureProvider -from urllib.parse import parse_qs, urlparse -from allauth.socialaccount.providers.facebook.provider import FacebookProvider -from allauth.socialaccount.providers.linkedin_oauth2.provider import LinkedInOAuth2Provider -from allauth.tests import MockedResponse, mocked_response - -from django.shortcuts import reverse -from django.test import override_settings - -from badgeuser.models import CachedEmailAddress - -from .base import BadgrOAuth2TestsMixin, BadgrSocialAuthTestCase, DoesNotSendVerificationEmailMixin, SendsVerificationEmailMixin - - -class LinkedInOAuth2ProviderTests(DoesNotSendVerificationEmailMixin, BadgrOAuth2TestsMixin, BadgrSocialAuthTestCase): - provider_id = LinkedInOAuth2Provider.id - - def get_mocked_response(self): - email_response = MockedResponse(200, """{"elements": [{"handle": "urn:li:emailAddress:319371470", - "handle~": {"emailAddress": "larry.exampleton@example.com"}}]}""") - email_response.ok = True - - profile_response = MockedResponse(200, """{ - "profilePicture": { - "displayImage": "urn:li:digitalmediaAsset:12345abcdefgh-12abcd" - }, - "id": "3735408165", - "lastName": { - "preferredLocale": { - "language": "en", - "country": "US" - }, - "localized": { - "en_US": "Exampleton" - } - }, - "firstName": { - "preferredLocale": { - "language": "en", - "country": "US" - }, - "localized": { - "en_US": "Larry" - } - } - }""") - profile_response.ok = True - - return [email_response, profile_response] - - def login(self, resp_mock, process='login', - with_refresh_token=True): - resp = self.client.get(reverse(self.provider.id + '_login'), - dict(process=process)) - p = urlparse(resp['location']) - q = parse_qs(p.query) - complete_url = reverse(self.provider.id + '_callback') - self.assertGreater(q['redirect_uri'][0] - .find(complete_url), 0) - response_json = self \ - .get_login_response_json(with_refresh_token=with_refresh_token) - with mocked_response( - MockedResponse( - 200, - response_json, - {'content-type': 'application/json'}), - *resp_mock): # supports multiple mocks - resp = self.client.get(complete_url, - {'code': 'test', - 'state': q['state'][0]}) - return resp - - def test_legacy_user_can_sign_in(self): - """ - Users who signed up for an account at an older point in time may have had the email not automatically verified, - They should still be able to sign in. - """ - with override_settings(SOCIALACCOUNT_PROVIDERS={self.provider.id: {'VERIFIED_EMAIL': True}}): - # Create account the normal way - self.login(self.get_mocked_response()) - self.client.logout() - response = self.login(self.get_mocked_response()) - self.assert_login_redirect(response) - - # Manipulate the email to put it in the problem state - email = CachedEmailAddress.objects.last() - email.verified = False - email.save() - - self.client.logout() - response = self.login(self.get_mocked_response()) - self.assert_login_redirect(response) # User can sign in again properly - - email_again = CachedEmailAddress.objects.last() - self.assertTrue(email_again.verified) - - -class AzureProviderTests(DoesNotSendVerificationEmailMixin, BadgrOAuth2TestsMixin, BadgrSocialAuthTestCase): - provider_id = AzureProvider.id - - def get_mocked_response(self): - response = MockedResponse(200, """ - {"displayName": "John Smith", "mobilePhone": null, - "preferredLanguage": "en-US", "jobTitle": "Director", - "userPrincipalName": "john@smith.com", - "@odata.context": - "https://graph.microsoft.com/v1.0/$metadata#users/$entity", - "officeLocation": "Paris", "businessPhones": [], - "mail": "john@smith.com", "surname": "Smith", - "givenName": "John", "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"} - """) - response.ok = True - return response diff --git a/apps/badgrsocialauth/providers/twitter/provider.py b/apps/badgrsocialauth/providers/twitter/provider.py index d31eb9548..98e6e075c 100644 --- a/apps/badgrsocialauth/providers/twitter/provider.py +++ b/apps/badgrsocialauth/providers/twitter/provider.py @@ -1,22 +1,22 @@ -from urllib.parse import urlparse - -from allauth.account.models import EmailAddress from allauth.socialaccount import providers -from allauth.socialaccount.providers.twitter.provider import TwitterProvider, TwitterAccount -from django.core.exceptions import ValidationError - -from badgeuser.models import UserRecipientIdentifier +from allauth.socialaccount.providers.twitter.provider import ( + TwitterProvider, + TwitterAccount, +) class TwitterProviderWithIdentifier(TwitterProvider): - id = 'twitter' - name = 'Twitter' - package = 'allauth.socialaccount.providers.twitter' + id = "twitter" + name = "Twitter" + package = "allauth.socialaccount.providers.twitter" account_class = TwitterAccount def extract_common_fields(self, data): - common_fields = super(TwitterProviderWithIdentifier, self).extract_common_fields(data) - common_fields['url'] = 'https://twitter.com/{}'.format(data.get('screen_name')) + common_fields = super( + TwitterProviderWithIdentifier, self + ).extract_common_fields(data) + common_fields["url"] = "https://twitter.com/{}".format(data.get("screen_name")) return common_fields + providers.registry.register(TwitterProviderWithIdentifier) diff --git a/apps/badgrsocialauth/providers/twitter/tests.py b/apps/badgrsocialauth/providers/twitter/tests.py deleted file mode 100644 index 5398176b5..000000000 --- a/apps/badgrsocialauth/providers/twitter/tests.py +++ /dev/null @@ -1,99 +0,0 @@ -# encoding: utf-8 - - -import responses - -from allauth.socialaccount.models import SocialAccount, SocialApp -from django.shortcuts import reverse -from django.contrib.sites.models import Site -from django.test import override_settings - -from badgeuser.models import BadgeUser, UserRecipientIdentifier -from mainsite.tests import BadgrTestCase - -MOCK_TWITTER_PROFILE_RESPONSE = """ -{"follow_request_sent":false,"profile_use_background_image":true,"id":45671919,"verified":false, -"profile_text_color":"333333","profile_image_url_https":"https://pbs.twimg.com/profile_images/793142149/r_normal.png", -"profile_sidebar_fill_color":"DDEEF6","is_translator":false,"geo_enabled":false,"entities":{"description":{"urls":[]}}, -"followers_count":43,"protected":false,"location":"The Netherlands","default_profile_image":false,"id_str":"45671919", -"status":{"contributors":null,"truncated":false,"text":"...","in_reply_to_status_id":null,"id":400658301702381600, -"favorite_count":0,"source":"","retweeted":true, -"coordinates":null,"entities":{"symbols":[],"user_mentions":[{"indices":[3,16],"screen_name":"denibertovic", -"id":23508244,"name":"Doobie Bones","id_str":"23508344"}],"hashtags":[{"indices":[135,139],"text":"dja"}],"urls":[]}, -"in_reply_to_screen_name":null,"id_str":"400658301702381568","retweet_count":6,"in_reply_to_user_id":null, -"favorited":false,"retweeted_status":{"lang":"en","favorited":false,"in_reply_to_user_id":null,"contributors":null, -"truncated":false,"text":"Allauth example data","created_at":"Sun Jul 28 19: 56: 26 + 0000 2013","retweeted":true, -"in_reply_to_status_id":null,"coordinates":null,"id":361575897674956800,"entities":{"symbols":[],"user_mentions":[], -"hashtags":[{"indices":[117,124],"text":"django"}],"urls":[]},"in_reply_to_status_id_str":null, -"in_reply_to_screen_name":null,"source":"web","place":null,"retweet_count":6,"geo":null,"in_reply_to_user_id_str":null, -"favorite_count":8,"id_str":"361575897674956800"},"geo":null,"in_reply_to_user_id_str":null,"lang":"en", -"created_at":"Wed Nov 13 16:15:57 +0000 2013","in_reply_to_status_id_str":null,"place":null},"utc_offset":3600, -"statuses_count":39,"description":"","friends_count":83,"profile_link_color":"0084B4", -"profile_image_url":"http://pbs.twimg.com/profile_images/793142149/r_normal.png","notifications":false, -"profile_background_image_url_https":"https://abs.twimg.com/images/themes/theme1/bg.png", -"profile_background_color":"C0DEED","profile_background_image_url":"http://abs.twimg.com/images/themes/theme1/bg.png", -"name":"Raymond Penners","lang":"nl","profile_background_tile":false,"favourites_count":0,"screen_name":"pennersr", -"url":null,"created_at":"Mon Jun 08 21:10:45 +0000 2009","contributors_enabled":false,"time_zone":"Amsterdam", -"profile_sidebar_border_color":"C0DEED","default_profile":true,"following":false,"listed_count":1} -""" - -MOCK_REQUEST_TOKEN_RESPONSE = """{ -"oauth_token":"NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0", -"oauth_token_secret":"veNRnAWe6inFuo8o2u8SLLZLjolYDmDP7SzL0YfYI" -"oauth_callback_confirmed":true -}""" - -# class TwitterOAuthFlowTests(BadgrTestCase): -# def setUp(self): -# super(TwitterOAuthFlowTests, self).setUp() -# responses.add( -# responses.POST, 'https://api.twitter.com/oauth/request_token', -# body='{"access_token":"2YotnFZFEjr1zCsicMWpAA","token_type":"bearer","expires_in":3600}', -# status=200, content_type='application/json' -# ) -# responses.add( -# responses.POST, 'https://api.twitter.com/oauth/access_token', -# body='{"access_token":"2YotnFZFEjr1zCsicMWpAA","token_type":"bearer","expires_in":3600}', -# status=200, content_type='application/json' -# ) -# -# responses.add( -# responses.GET, 'https://api.twitter.com/1.1/account/verify_credentials.json', -# body=MOCK_TWITTER_PROFILE_RESPONSE, -# status=200, content_type='application/json' -# ) -# -# sa = SocialApp.objects.create( -# provider='icc_oauth', -# name='ICC OAuth', -# client_id='BADGR_TESTS', -# secret='BADGR_IS_AFRAID_OF_THE_DARK', -# ) -# sa.sites.add(Site.objects.first()) -# -# self.badgr_app.signup_redirect = 'http://TEST.UI/signup/success/' -# self.badgr_app.ui_login_redirect = 'http://TEST.UI/login/' -# self.badgr_app.save() -# -# @responses.activate -# def test_twitter_initiated_signup_flow(self): -# response = self.client.get(reverse('icc_oauth_callback') + '?code=abc123') -# self.assertEqual(response.status_code, 302) -# self.assertIn(self.badgr_app.ui_login_redirect, response._headers.get('location')[1], -# "User is directed to the site with a token, fully logged in") -# -# @responses.activate -# def test_twitter_initiated_login_flow(self): -# """ -# When a user shows up a second time, are they properly reconnected to their account? -# """ -# signup_response = self.client.get(reverse('twitter_callback') + '?code=abc123') -# self.assertEqual(signup_response.status_code, 302) -# uri = UserRecipientIdentifier.objects.last() -# self.assertEqual(uri.identifier, 'https://twitter.com/pennersr') -# self.assertTrue(uri.verified) -# -# login_response = self.client.get(reverse('twitter_callback') + '?code=zyx098') -# self.assertEqual(login_response.status_code, 302) -# self.assertIn(self.badgr_app.ui_login_redirect, login_response._headers.get('location')[1]) -# self.assertEqual(BadgeUser.objects.count(), 1) diff --git a/apps/badgrsocialauth/saml2_utils.py b/apps/badgrsocialauth/saml2_utils.py index 1246fce8f..29698d654 100644 --- a/apps/badgrsocialauth/saml2_utils.py +++ b/apps/badgrsocialauth/saml2_utils.py @@ -1,4 +1,8 @@ -from saml2.metadata import entities_descriptor, entity_descriptor, sign_entity_descriptor +from saml2.metadata import ( + entities_descriptor, + entity_descriptor, + sign_entity_descriptor, +) from saml2.sigver import security_context from saml2.config import Config from saml2.validate import valid_instance @@ -7,23 +11,31 @@ def metadata_tostring_fix(desc, nspair, xmlstring=""): MDNS = '"urn:oasis:names:tc:SAML:2.0:metadata"' bMDNS = b'"urn:oasis:names:tc:SAML:2.0:metadata"' - XMLNSXS = " xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" - bXMLNSXS = b" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"" + XMLNSXS = ' xmlns:xs="http://www.w3.org/2001/XMLSchema"' + bXMLNSXS = b' xmlns:xs="http://www.w3.org/2001/XMLSchema"' if not xmlstring: xmlstring = desc.to_string(nspair) try: - if "\"xs:string\"" in xmlstring and XMLNSXS not in xmlstring: + if '"xs:string"' in xmlstring and XMLNSXS not in xmlstring: xmlstring = xmlstring.replace(MDNS, MDNS + XMLNSXS) except TypeError: - if b"\"xs:string\"" in xmlstring and bXMLNSXS not in xmlstring: + if b'"xs:string"' in xmlstring and bXMLNSXS not in xmlstring: xmlstring = xmlstring.replace(bMDNS, bMDNS + bXMLNSXS) return xmlstring -def create_metadata_string(configfile, config=None, valid=None, cert=None, - keyfile=None, mid=None, name=None, sign=None): +def create_metadata_string( + configfile, + config=None, + valid=None, + cert=None, + keyfile=None, + mid=None, + name=None, + sign=None, +): """ TODO: REMOVE THIS FUNCTION AFTER pysaml2 library is updated. to fix the above metadata_tostring_fix function """ @@ -49,8 +61,7 @@ def create_metadata_string(configfile, config=None, valid=None, cert=None, secc = security_context(conf) if mid: - eid, xmldoc = entities_descriptor(eds, valid_for, name, mid, - sign, secc) + eid, xmldoc = entities_descriptor(eds, valid_for, name, mid, sign, secc) else: eid = eds[0] if sign: diff --git a/apps/badgrsocialauth/serializers_v1.py b/apps/badgrsocialauth/serializers_v1.py index ce26e03b3..648936492 100644 --- a/apps/badgrsocialauth/serializers_v1.py +++ b/apps/badgrsocialauth/serializers_v1.py @@ -6,11 +6,13 @@ class BadgrSocialAccountSerializerV1(serializers.Serializer): id = serializers.CharField() provider = serializers.CharField() - dateAdded = DateTimeWithUtcZAtEndField(source='date_joined') + dateAdded = DateTimeWithUtcZAtEndField(source="date_joined") uid = serializers.CharField() def to_representation(self, instance): - representation = super(BadgrSocialAccountSerializerV1, self).to_representation(instance) + representation = super(BadgrSocialAccountSerializerV1, self).to_representation( + instance + ) try: provider = instance.get_provider() @@ -18,18 +20,26 @@ def to_representation(self, instance): except AttributeError: # For SAML handling common_fields = dict() - representation['id'] = instance.account_identifier - email = common_fields.get('email', None) - url = common_fields.get('url', None) - - if not email and hasattr(instance, 'extra_data') and 'userPrincipalName' in instance.extra_data: - email = instance.extra_data['userPrincipalName'] - - representation.update({ - 'firstName': common_fields.get('first_name', common_fields.get('name', None)), - 'lastName': common_fields.get('last_name', None), - 'primaryEmail': email, - 'url': url, - }) + representation["id"] = instance.account_identifier + email = common_fields.get("email", None) + url = common_fields.get("url", None) + + if ( + not email + and hasattr(instance, "extra_data") + and "userPrincipalName" in instance.extra_data + ): + email = instance.extra_data["userPrincipalName"] + + representation.update( + { + "firstName": common_fields.get( + "first_name", common_fields.get("name", None) + ), + "lastName": common_fields.get("last_name", None), + "primaryEmail": email, + "url": url, + } + ) return representation diff --git a/apps/badgrsocialauth/serializers_v2.py b/apps/badgrsocialauth/serializers_v2.py index ae3c63c3c..116f91168 100644 --- a/apps/badgrsocialauth/serializers_v2.py +++ b/apps/badgrsocialauth/serializers_v2.py @@ -7,14 +7,16 @@ class BadgrSocialAccountSerializerV2(BaseSerializerV2): id = serializers.CharField() provider = serializers.CharField() - dateAdded = DateTimeWithUtcZAtEndField(source='date_joined') + dateAdded = DateTimeWithUtcZAtEndField(source="date_joined") uid = serializers.CharField() class Meta: list_serializer_class = ListSerializerV2 def to_representation(self, instance): - representation = super(BadgrSocialAccountSerializerV2, self).to_representation(instance) + representation = super(BadgrSocialAccountSerializerV2, self).to_representation( + instance + ) try: provider = instance.get_provider() @@ -22,22 +24,28 @@ def to_representation(self, instance): except AttributeError: # For SAML handling common_fields = dict() - representation['id'] = instance.account_identifier - email = common_fields.get('email', None) - url = common_fields.get('url', None) - if not email and hasattr(instance, 'extra_data') and 'userPrincipalName' in instance.extra_data: - email = instance.extra_data['userPrincipalName'] + representation["id"] = instance.account_identifier + email = common_fields.get("email", None) + url = common_fields.get("url", None) + if ( + not email + and hasattr(instance, "extra_data") + and "userPrincipalName" in instance.extra_data + ): + email = instance.extra_data["userPrincipalName"] if self.parent is None: - result = representation['result'][0] + result = representation["result"][0] else: result = representation - result.update({ - 'firstName': common_fields.get('first_name', None), - 'lastName': common_fields.get('last_name', None), - 'primaryEmail': email, - 'url': url, - }) + result.update( + { + "firstName": common_fields.get("first_name", None), + "lastName": common_fields.get("last_name", None), + "primaryEmail": email, + "url": url, + } + ) return representation diff --git a/apps/badgrsocialauth/testfiles/asu_response.xml b/apps/badgrsocialauth/testfiles/asu_response.xml deleted file mode 100644 index 97f4313b7..000000000 --- a/apps/badgrsocialauth/testfiles/asu_response.xml +++ /dev/null @@ -1,110 +0,0 @@ - - - urn:mace:incommon:asu.edu - - - - - - - - - - - - - 9ku8vsW/mG9VtOVJIoPFwH+59A4= - - - RviJ+9oPbiKWAdnDaNx6cMgffq6nQNqUSJYtyiDytuxFIdafskyeQLSn5i4CtPXRe0CUJVKncw07QhgBPzqaqIWzmyY0tGiqitoXgHahVHyHaupaxC+v+ltjD0s7rOsvvcbQC/6NzhyRrQzvqxdNu8UNsBbWPzBdzreIgFkP9t1sbsZfGEMyZNshpI1AfCGqCX32gbM5jL8KM3AET3iFUHBOWSatRCDUDQPm6N3x50acNZnmzUgKQKf5KWNIEwZfzNxn/wL92RiKNgJV+9uBdle6RZGZfaGOSKqduiSVfNs2Cjia8DaZcsb+SBwGDrF5rwkFzaSmuEB4+OyIKjWUwQ== - - - MIIDODCCAiCgAwIBAgIVAIEaja/L6bBgSu+JKg1QGKs5jXFJMA0GCSqGSIb3DQEBBQUAMB4xHDAa -BgNVBAMTE3NoaWJib2xldGgyLmFzdS5lZHUwHhcNMTIxMjA2MDA1ODU4WhcNMzIxMjA2MDA1ODU4 -WjAeMRwwGgYDVQQDExNzaGliYm9sZXRoMi5hc3UuZWR1MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAg/vJpDGOPsQ//Gew8luCxJ6PxezIHYovddn246ZlkFKPKN0RJpZuCLxkWUBbz2P6 -9Be7pbU9nuNHcfjGkzcxHM90hYvihcutx4cP79bJK/97HTonsiQw8DT+rsFkagsOfNSpUKPREgEN -FlTXKc0KNBF4l+AbczLlYs/QygB1NjAWezQaJpElhtlpaaQoVizo9oFNbtQc5mEWnxEqwFR0k/HD -1A7oI2hd819yMu+I4IbJVvvMyXTJaadInLxFw9mXR8A1JDI0QkHX5xSaG3gFnNMmVNfwCAQG2ZeR -cdfiZ/WSGdrYM34QhyYDL1/qDdr3gDGm5Jj0wC2W8D0b4MSG3QIDAQABo20wazBKBgNVHREEQzBB -ghNzaGliYm9sZXRoMi5hc3UuZWR1hipodHRwczovL3NoaWJib2xldGgyLmFzdS5lZHUvaWRwL3No -aWJib2xldGgwHQYDVR0OBBYEFDp/jP+Da0W8YoAWrzOzM/Y6Z3KuMA0GCSqGSIb3DQEBBQUAA4IB -AQBkS0zfyDXzS/dRqqCmwinbR8mVcvsyBWsuGJQDiFIj/6WqT7lrYtp2igZXeu1V3R8XCQ4AJWux -XUB2zAvri8plWMhs/4s1HkeF8OZ647yoonTMxdMLheUyf/Ph+PKBs9SxANVjI4IYpbZZShg8HnDI -Dl8GdR/DTXC96VCARffmf1uu8ixzqceTMwhR6V1DgM4DIVQrE2VLaHwxQUzc7qxfLLwl8J+W7dd+ -LaTndoFtkpW7QLQUandHQWLzGt4lt7ahfbvz/InkWSyJ1jS/ihPrRrjyu7A3iTXITNxMow2gC1/u -0Ho6FqzunXlgC4bcdGPcQiDKCatfNOBqeNRAh5p0 - - - - - - - - urn:mace:incommon:asu.edu - - - - - - - - - - - - - V5p4cPRYrwp7R309rkqu2IGsgzU= - - - Oe2TbDJRuYu/nox/XWnCsZtMaAMx0m0O/G0KIda3LL0yyqcpjVNei3NHDwz/KIaCS7SpZ4Iz/PPtPtnVq6s4YL5YJmj2Jn7CSrDX2lw+SqqF7w5j0UilTcdt5Vw/BqktavkkyUxolmAgsBUwGUjAXmLvsTzWXSoR8KkOj7b8wjwFWjWzxc+MHI5b3OoqS5iCijo4PogGam0sgx5auOteGBlZ3xVQm+jHyzWqguOIk6rjCWR2Xzf/mvbTqGAFT+BXta9dWj5xQUmq2tEabcTHoaeNfkTPGIiDWmuQWC1Z8SLbLgfI0naPjl6fb1LTWjypJiyNHTaxAzSJVkk+s/rnHw== - - - MIIDODCCAiCgAwIBAgIVAIEaja/L6bBgSu+JKg1QGKs5jXFJMA0GCSqGSIb3DQEBBQUAMB4xHDAa -BgNVBAMTE3NoaWJib2xldGgyLmFzdS5lZHUwHhcNMTIxMjA2MDA1ODU4WhcNMzIxMjA2MDA1ODU4 -WjAeMRwwGgYDVQQDExNzaGliYm9sZXRoMi5hc3UuZWR1MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAg/vJpDGOPsQ//Gew8luCxJ6PxezIHYovddn246ZlkFKPKN0RJpZuCLxkWUBbz2P6 -9Be7pbU9nuNHcfjGkzcxHM90hYvihcutx4cP79bJK/97HTonsiQw8DT+rsFkagsOfNSpUKPREgEN -FlTXKc0KNBF4l+AbczLlYs/QygB1NjAWezQaJpElhtlpaaQoVizo9oFNbtQc5mEWnxEqwFR0k/HD -1A7oI2hd819yMu+I4IbJVvvMyXTJaadInLxFw9mXR8A1JDI0QkHX5xSaG3gFnNMmVNfwCAQG2ZeR -cdfiZ/WSGdrYM34QhyYDL1/qDdr3gDGm5Jj0wC2W8D0b4MSG3QIDAQABo20wazBKBgNVHREEQzBB -ghNzaGliYm9sZXRoMi5hc3UuZWR1hipodHRwczovL3NoaWJib2xldGgyLmFzdS5lZHUvaWRwL3No -aWJib2xldGgwHQYDVR0OBBYEFDp/jP+Da0W8YoAWrzOzM/Y6Z3KuMA0GCSqGSIb3DQEBBQUAA4IB -AQBkS0zfyDXzS/dRqqCmwinbR8mVcvsyBWsuGJQDiFIj/6WqT7lrYtp2igZXeu1V3R8XCQ4AJWux -XUB2zAvri8plWMhs/4s1HkeF8OZ647yoonTMxdMLheUyf/Ph+PKBs9SxANVjI4IYpbZZShg8HnDI -Dl8GdR/DTXC96VCARffmf1uu8ixzqceTMwhR6V1DgM4DIVQrE2VLaHwxQUzc7qxfLLwl8J+W7dd+ -LaTndoFtkpW7QLQUandHQWLzGt4lt7ahfbvz/InkWSyJ1jS/ihPrRrjyu7A3iTXITNxMow2gC1/u -0Ho6FqzunXlgC4bcdGPcQiDKCatfNOBqeNRAh5p0 - - - - - AApzZWNyZXQxMTk0BA+bgTKeBd2dmD7il3ieT8M507tTqCjzF2gZTJlVk6Mo+wPJtPh6c633jfFpxMqnY6Y2Mj+DQa6GhqXOoyoLCMWyy6IoRqxPbFCiGSCadQmQk7Pho4I91oVmnjuhqRpO/4nMcLRNi9bTNOATAOxsDzEAQufo45P+ - - - - - - - https://api.test.badgr.com/account/saml2/saml2.asu/acs/ - - - - - - urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport - - - - - Quintana - - - jfquinta@asu.edu - - - Joshua - - - - diff --git a/apps/badgrsocialauth/testfiles/attribute_response.xml b/apps/badgrsocialauth/testfiles/attribute_response.xml deleted file mode 100644 index 1effecd95..000000000 --- a/apps/badgrsocialauth/testfiles/attribute_response.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - urn:mace:example.com:saml:roland:idp - - - - - - - urn:mace:example.com:saml:roland:idp - - - _f6224ef32bb60b146e88463aab04aa6a - - - - - - - - http://localhost:8000/account/saml2/saml2.authn/acs/ - - - - - - - - urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport - - - - - - Chris.Phillips@canarie.ca - - - - - NRIvsX5gMK+TnqejcQP9jH8nTIk= - - - - - - diff --git a/apps/badgrsocialauth/testfiles/attributemaps/basic.py b/apps/badgrsocialauth/testfiles/attributemaps/basic.py deleted file mode 100644 index 7bd9def4a..000000000 --- a/apps/badgrsocialauth/testfiles/attributemaps/basic.py +++ /dev/null @@ -1,326 +0,0 @@ - -MAP = { - "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:basic", - "fro": { - 'urn:mace:dir:attribute-def:aRecord': 'aRecord', - 'urn:mace:dir:attribute-def:aliasedEntryName': 'aliasedEntryName', - 'urn:mace:dir:attribute-def:aliasedObjectName': 'aliasedObjectName', - 'urn:mace:dir:attribute-def:associatedDomain': 'associatedDomain', - 'urn:mace:dir:attribute-def:associatedName': 'associatedName', - 'urn:mace:dir:attribute-def:audio': 'audio', - 'urn:mace:dir:attribute-def:authorityRevocationList': 'authorityRevocationList', - 'urn:mace:dir:attribute-def:buildingName': 'buildingName', - 'urn:mace:dir:attribute-def:businessCategory': 'businessCategory', - 'urn:mace:dir:attribute-def:c': 'c', - 'urn:mace:dir:attribute-def:cACertificate': 'cACertificate', - 'urn:mace:dir:attribute-def:cNAMERecord': 'cNAMERecord', - 'urn:mace:dir:attribute-def:carLicense': 'carLicense', - 'urn:mace:dir:attribute-def:certificateRevocationList': 'certificateRevocationList', - 'urn:mace:dir:attribute-def:cn': 'cn', - 'urn:mace:dir:attribute-def:co': 'co', - 'urn:mace:dir:attribute-def:commonName': 'commonName', - 'urn:mace:dir:attribute-def:countryName': 'countryName', - 'urn:mace:dir:attribute-def:crossCertificatePair': 'crossCertificatePair', - 'urn:mace:dir:attribute-def:dITRedirect': 'dITRedirect', - 'urn:mace:dir:attribute-def:dSAQuality': 'dSAQuality', - 'urn:mace:dir:attribute-def:dc': 'dc', - 'urn:mace:dir:attribute-def:deltaRevocationList': 'deltaRevocationList', - 'urn:mace:dir:attribute-def:departmentNumber': 'departmentNumber', - 'urn:mace:dir:attribute-def:description': 'description', - 'urn:mace:dir:attribute-def:destinationIndicator': 'destinationIndicator', - 'urn:mace:dir:attribute-def:displayName': 'displayName', - 'urn:mace:dir:attribute-def:distinguishedName': 'distinguishedName', - 'urn:mace:dir:attribute-def:dmdName': 'dmdName', - 'urn:mace:dir:attribute-def:dnQualifier': 'dnQualifier', - 'urn:mace:dir:attribute-def:documentAuthor': 'documentAuthor', - 'urn:mace:dir:attribute-def:documentIdentifier': 'documentIdentifier', - 'urn:mace:dir:attribute-def:documentLocation': 'documentLocation', - 'urn:mace:dir:attribute-def:documentPublisher': 'documentPublisher', - 'urn:mace:dir:attribute-def:documentTitle': 'documentTitle', - 'urn:mace:dir:attribute-def:documentVersion': 'documentVersion', - 'urn:mace:dir:attribute-def:domainComponent': 'domainComponent', - 'urn:mace:dir:attribute-def:drink': 'drink', - 'urn:mace:dir:attribute-def:eduOrgHomePageURI': 'eduOrgHomePageURI', - 'urn:mace:dir:attribute-def:eduOrgIdentityAuthNPolicyURI': 'eduOrgIdentityAuthNPolicyURI', - 'urn:mace:dir:attribute-def:eduOrgLegalName': 'eduOrgLegalName', - 'urn:mace:dir:attribute-def:eduOrgSuperiorURI': 'eduOrgSuperiorURI', - 'urn:mace:dir:attribute-def:eduOrgWhitePagesURI': 'eduOrgWhitePagesURI', - 'urn:mace:dir:attribute-def:eduPersonAffiliation': 'eduPersonAffiliation', - 'urn:mace:dir:attribute-def:eduPersonEntitlement': 'eduPersonEntitlement', - 'urn:mace:dir:attribute-def:eduPersonNickname': 'eduPersonNickname', - 'urn:mace:dir:attribute-def:eduPersonOrgDN': 'eduPersonOrgDN', - 'urn:mace:dir:attribute-def:eduPersonOrgUnitDN': 'eduPersonOrgUnitDN', - 'urn:mace:dir:attribute-def:eduPersonPrimaryAffiliation': 'eduPersonPrimaryAffiliation', - 'urn:mace:dir:attribute-def:eduPersonPrimaryOrgUnitDN': 'eduPersonPrimaryOrgUnitDN', - 'urn:mace:dir:attribute-def:eduPersonPrincipalName': 'eduPersonPrincipalName', - 'urn:mace:dir:attribute-def:eduPersonScopedAffiliation': 'eduPersonScopedAffiliation', - 'urn:mace:dir:attribute-def:eduPersonTargetedID': 'eduPersonTargetedID', - 'urn:mace:dir:attribute-def:email': 'email', - 'urn:mace:dir:attribute-def:emailAddress': 'emailAddress', - 'urn:mace:dir:attribute-def:employeeNumber': 'employeeNumber', - 'urn:mace:dir:attribute-def:employeeType': 'employeeType', - 'urn:mace:dir:attribute-def:enhancedSearchGuide': 'enhancedSearchGuide', - 'urn:mace:dir:attribute-def:facsimileTelephoneNumber': 'facsimileTelephoneNumber', - 'urn:mace:dir:attribute-def:favouriteDrink': 'favouriteDrink', - 'urn:mace:dir:attribute-def:fax': 'fax', - 'urn:mace:dir:attribute-def:federationFeideSchemaVersion': 'federationFeideSchemaVersion', - 'urn:mace:dir:attribute-def:friendlyCountryName': 'friendlyCountryName', - 'urn:mace:dir:attribute-def:generationQualifier': 'generationQualifier', - 'urn:mace:dir:attribute-def:givenName': 'givenName', - 'urn:mace:dir:attribute-def:gn': 'gn', - 'urn:mace:dir:attribute-def:homePhone': 'homePhone', - 'urn:mace:dir:attribute-def:homePostalAddress': 'homePostalAddress', - 'urn:mace:dir:attribute-def:homeTelephoneNumber': 'homeTelephoneNumber', - 'urn:mace:dir:attribute-def:host': 'host', - 'urn:mace:dir:attribute-def:houseIdentifier': 'houseIdentifier', - 'urn:mace:dir:attribute-def:info': 'info', - 'urn:mace:dir:attribute-def:initials': 'initials', - 'urn:mace:dir:attribute-def:internationaliSDNNumber': 'internationaliSDNNumber', - 'urn:mace:dir:attribute-def:janetMailbox': 'janetMailbox', - 'urn:mace:dir:attribute-def:jpegPhoto': 'jpegPhoto', - 'urn:mace:dir:attribute-def:knowledgeInformation': 'knowledgeInformation', - 'urn:mace:dir:attribute-def:l': 'l', - 'urn:mace:dir:attribute-def:labeledURI': 'labeledURI', - 'urn:mace:dir:attribute-def:localityName': 'localityName', - 'urn:mace:dir:attribute-def:mDRecord': 'mDRecord', - 'urn:mace:dir:attribute-def:mXRecord': 'mXRecord', - 'urn:mace:dir:attribute-def:mail': 'mail', - 'urn:mace:dir:attribute-def:mailPreferenceOption': 'mailPreferenceOption', - 'urn:mace:dir:attribute-def:manager': 'manager', - 'urn:mace:dir:attribute-def:member': 'member', - 'urn:mace:dir:attribute-def:mobile': 'mobile', - 'urn:mace:dir:attribute-def:mobileTelephoneNumber': 'mobileTelephoneNumber', - 'urn:mace:dir:attribute-def:nSRecord': 'nSRecord', - 'urn:mace:dir:attribute-def:name': 'name', - 'urn:mace:dir:attribute-def:norEduOrgAcronym': 'norEduOrgAcronym', - 'urn:mace:dir:attribute-def:norEduOrgNIN': 'norEduOrgNIN', - 'urn:mace:dir:attribute-def:norEduOrgSchemaVersion': 'norEduOrgSchemaVersion', - 'urn:mace:dir:attribute-def:norEduOrgUniqueIdentifier': 'norEduOrgUniqueIdentifier', - 'urn:mace:dir:attribute-def:norEduOrgUniqueNumber': 'norEduOrgUniqueNumber', - 'urn:mace:dir:attribute-def:norEduOrgUnitUniqueIdentifier': 'norEduOrgUnitUniqueIdentifier', - 'urn:mace:dir:attribute-def:norEduOrgUnitUniqueNumber': 'norEduOrgUnitUniqueNumber', - 'urn:mace:dir:attribute-def:norEduPersonBirthDate': 'norEduPersonBirthDate', - 'urn:mace:dir:attribute-def:norEduPersonLIN': 'norEduPersonLIN', - 'urn:mace:dir:attribute-def:norEduPersonNIN': 'norEduPersonNIN', - 'urn:mace:dir:attribute-def:o': 'o', - 'urn:mace:dir:attribute-def:objectClass': 'objectClass', - 'urn:mace:dir:attribute-def:organizationName': 'organizationName', - 'urn:mace:dir:attribute-def:organizationalStatus': 'organizationalStatus', - 'urn:mace:dir:attribute-def:organizationalUnitName': 'organizationalUnitName', - 'urn:mace:dir:attribute-def:otherMailbox': 'otherMailbox', - 'urn:mace:dir:attribute-def:ou': 'ou', - 'urn:mace:dir:attribute-def:owner': 'owner', - 'urn:mace:dir:attribute-def:pager': 'pager', - 'urn:mace:dir:attribute-def:pagerTelephoneNumber': 'pagerTelephoneNumber', - 'urn:mace:dir:attribute-def:personalSignature': 'personalSignature', - 'urn:mace:dir:attribute-def:personalTitle': 'personalTitle', - 'urn:mace:dir:attribute-def:photo': 'photo', - 'urn:mace:dir:attribute-def:physicalDeliveryOfficeName': 'physicalDeliveryOfficeName', - 'urn:mace:dir:attribute-def:pkcs9email': 'pkcs9email', - 'urn:mace:dir:attribute-def:postOfficeBox': 'postOfficeBox', - 'urn:mace:dir:attribute-def:postalAddress': 'postalAddress', - 'urn:mace:dir:attribute-def:postalCode': 'postalCode', - 'urn:mace:dir:attribute-def:preferredDeliveryMethod': 'preferredDeliveryMethod', - 'urn:mace:dir:attribute-def:preferredLanguage': 'preferredLanguage', - 'urn:mace:dir:attribute-def:presentationAddress': 'presentationAddress', - 'urn:mace:dir:attribute-def:protocolInformation': 'protocolInformation', - 'urn:mace:dir:attribute-def:pseudonym': 'pseudonym', - 'urn:mace:dir:attribute-def:registeredAddress': 'registeredAddress', - 'urn:mace:dir:attribute-def:rfc822Mailbox': 'rfc822Mailbox', - 'urn:mace:dir:attribute-def:roleOccupant': 'roleOccupant', - 'urn:mace:dir:attribute-def:roomNumber': 'roomNumber', - 'urn:mace:dir:attribute-def:sOARecord': 'sOARecord', - 'urn:mace:dir:attribute-def:searchGuide': 'searchGuide', - 'urn:mace:dir:attribute-def:secretary': 'secretary', - 'urn:mace:dir:attribute-def:seeAlso': 'seeAlso', - 'urn:mace:dir:attribute-def:serialNumber': 'serialNumber', - 'urn:mace:dir:attribute-def:singleLevelQuality': 'singleLevelQuality', - 'urn:mace:dir:attribute-def:sn': 'sn', - 'urn:mace:dir:attribute-def:st': 'st', - 'urn:mace:dir:attribute-def:stateOrProvinceName': 'stateOrProvinceName', - 'urn:mace:dir:attribute-def:street': 'street', - 'urn:mace:dir:attribute-def:streetAddress': 'streetAddress', - 'urn:mace:dir:attribute-def:subtreeMaximumQuality': 'subtreeMaximumQuality', - 'urn:mace:dir:attribute-def:subtreeMinimumQuality': 'subtreeMinimumQuality', - 'urn:mace:dir:attribute-def:supportedAlgorithms': 'supportedAlgorithms', - 'urn:mace:dir:attribute-def:supportedApplicationContext': 'supportedApplicationContext', - 'urn:mace:dir:attribute-def:surname': 'surname', - 'urn:mace:dir:attribute-def:telephoneNumber': 'telephoneNumber', - 'urn:mace:dir:attribute-def:teletexTerminalIdentifier': 'teletexTerminalIdentifier', - 'urn:mace:dir:attribute-def:telexNumber': 'telexNumber', - 'urn:mace:dir:attribute-def:textEncodedORAddress': 'textEncodedORAddress', - 'urn:mace:dir:attribute-def:title': 'title', - 'urn:mace:dir:attribute-def:uid': 'uid', - 'urn:mace:dir:attribute-def:uniqueIdentifier': 'uniqueIdentifier', - 'urn:mace:dir:attribute-def:uniqueMember': 'uniqueMember', - 'urn:mace:dir:attribute-def:userCertificate': 'userCertificate', - 'urn:mace:dir:attribute-def:userClass': 'userClass', - 'urn:mace:dir:attribute-def:userPKCS12': 'userPKCS12', - 'urn:mace:dir:attribute-def:userPassword': 'userPassword', - 'urn:mace:dir:attribute-def:userSMIMECertificate': 'userSMIMECertificate', - 'urn:mace:dir:attribute-def:userid': 'userid', - 'urn:mace:dir:attribute-def:x121Address': 'x121Address', - 'urn:mace:dir:attribute-def:x500UniqueIdentifier': 'x500UniqueIdentifier', - }, - "to": { - 'aRecord': 'urn:mace:dir:attribute-def:aRecord', - 'aliasedEntryName': 'urn:mace:dir:attribute-def:aliasedEntryName', - 'aliasedObjectName': 'urn:mace:dir:attribute-def:aliasedObjectName', - 'associatedDomain': 'urn:mace:dir:attribute-def:associatedDomain', - 'associatedName': 'urn:mace:dir:attribute-def:associatedName', - 'audio': 'urn:mace:dir:attribute-def:audio', - 'authorityRevocationList': 'urn:mace:dir:attribute-def:authorityRevocationList', - 'buildingName': 'urn:mace:dir:attribute-def:buildingName', - 'businessCategory': 'urn:mace:dir:attribute-def:businessCategory', - 'c': 'urn:mace:dir:attribute-def:c', - 'cACertificate': 'urn:mace:dir:attribute-def:cACertificate', - 'cNAMERecord': 'urn:mace:dir:attribute-def:cNAMERecord', - 'carLicense': 'urn:mace:dir:attribute-def:carLicense', - 'certificateRevocationList': 'urn:mace:dir:attribute-def:certificateRevocationList', - 'cn': 'urn:mace:dir:attribute-def:cn', - 'co': 'urn:mace:dir:attribute-def:co', - 'commonName': 'urn:mace:dir:attribute-def:commonName', - 'countryName': 'urn:mace:dir:attribute-def:countryName', - 'crossCertificatePair': 'urn:mace:dir:attribute-def:crossCertificatePair', - 'dITRedirect': 'urn:mace:dir:attribute-def:dITRedirect', - 'dSAQuality': 'urn:mace:dir:attribute-def:dSAQuality', - 'dc': 'urn:mace:dir:attribute-def:dc', - 'deltaRevocationList': 'urn:mace:dir:attribute-def:deltaRevocationList', - 'departmentNumber': 'urn:mace:dir:attribute-def:departmentNumber', - 'description': 'urn:mace:dir:attribute-def:description', - 'destinationIndicator': 'urn:mace:dir:attribute-def:destinationIndicator', - 'displayName': 'urn:mace:dir:attribute-def:displayName', - 'distinguishedName': 'urn:mace:dir:attribute-def:distinguishedName', - 'dmdName': 'urn:mace:dir:attribute-def:dmdName', - 'dnQualifier': 'urn:mace:dir:attribute-def:dnQualifier', - 'documentAuthor': 'urn:mace:dir:attribute-def:documentAuthor', - 'documentIdentifier': 'urn:mace:dir:attribute-def:documentIdentifier', - 'documentLocation': 'urn:mace:dir:attribute-def:documentLocation', - 'documentPublisher': 'urn:mace:dir:attribute-def:documentPublisher', - 'documentTitle': 'urn:mace:dir:attribute-def:documentTitle', - 'documentVersion': 'urn:mace:dir:attribute-def:documentVersion', - 'domainComponent': 'urn:mace:dir:attribute-def:domainComponent', - 'drink': 'urn:mace:dir:attribute-def:drink', - 'eduOrgHomePageURI': 'urn:mace:dir:attribute-def:eduOrgHomePageURI', - 'eduOrgIdentityAuthNPolicyURI': 'urn:mace:dir:attribute-def:eduOrgIdentityAuthNPolicyURI', - 'eduOrgLegalName': 'urn:mace:dir:attribute-def:eduOrgLegalName', - 'eduOrgSuperiorURI': 'urn:mace:dir:attribute-def:eduOrgSuperiorURI', - 'eduOrgWhitePagesURI': 'urn:mace:dir:attribute-def:eduOrgWhitePagesURI', - 'eduPersonAffiliation': 'urn:mace:dir:attribute-def:eduPersonAffiliation', - 'eduPersonEntitlement': 'urn:mace:dir:attribute-def:eduPersonEntitlement', - 'eduPersonNickname': 'urn:mace:dir:attribute-def:eduPersonNickname', - 'eduPersonOrgDN': 'urn:mace:dir:attribute-def:eduPersonOrgDN', - 'eduPersonOrgUnitDN': 'urn:mace:dir:attribute-def:eduPersonOrgUnitDN', - 'eduPersonPrimaryAffiliation': 'urn:mace:dir:attribute-def:eduPersonPrimaryAffiliation', - 'eduPersonPrimaryOrgUnitDN': 'urn:mace:dir:attribute-def:eduPersonPrimaryOrgUnitDN', - 'eduPersonPrincipalName': 'urn:mace:dir:attribute-def:eduPersonPrincipalName', - 'eduPersonScopedAffiliation': 'urn:mace:dir:attribute-def:eduPersonScopedAffiliation', - 'eduPersonTargetedID': 'urn:mace:dir:attribute-def:eduPersonTargetedID', - 'email': 'urn:mace:dir:attribute-def:email', - 'emailAddress': 'urn:mace:dir:attribute-def:emailAddress', - 'employeeNumber': 'urn:mace:dir:attribute-def:employeeNumber', - 'employeeType': 'urn:mace:dir:attribute-def:employeeType', - 'enhancedSearchGuide': 'urn:mace:dir:attribute-def:enhancedSearchGuide', - 'facsimileTelephoneNumber': 'urn:mace:dir:attribute-def:facsimileTelephoneNumber', - 'favouriteDrink': 'urn:mace:dir:attribute-def:favouriteDrink', - 'fax': 'urn:mace:dir:attribute-def:fax', - 'federationFeideSchemaVersion': 'urn:mace:dir:attribute-def:federationFeideSchemaVersion', - 'friendlyCountryName': 'urn:mace:dir:attribute-def:friendlyCountryName', - 'generationQualifier': 'urn:mace:dir:attribute-def:generationQualifier', - 'givenName': 'urn:mace:dir:attribute-def:givenName', - 'gn': 'urn:mace:dir:attribute-def:gn', - 'homePhone': 'urn:mace:dir:attribute-def:homePhone', - 'homePostalAddress': 'urn:mace:dir:attribute-def:homePostalAddress', - 'homeTelephoneNumber': 'urn:mace:dir:attribute-def:homeTelephoneNumber', - 'host': 'urn:mace:dir:attribute-def:host', - 'houseIdentifier': 'urn:mace:dir:attribute-def:houseIdentifier', - 'info': 'urn:mace:dir:attribute-def:info', - 'initials': 'urn:mace:dir:attribute-def:initials', - 'internationaliSDNNumber': 'urn:mace:dir:attribute-def:internationaliSDNNumber', - 'janetMailbox': 'urn:mace:dir:attribute-def:janetMailbox', - 'jpegPhoto': 'urn:mace:dir:attribute-def:jpegPhoto', - 'knowledgeInformation': 'urn:mace:dir:attribute-def:knowledgeInformation', - 'l': 'urn:mace:dir:attribute-def:l', - 'labeledURI': 'urn:mace:dir:attribute-def:labeledURI', - 'localityName': 'urn:mace:dir:attribute-def:localityName', - 'mDRecord': 'urn:mace:dir:attribute-def:mDRecord', - 'mXRecord': 'urn:mace:dir:attribute-def:mXRecord', - 'mail': 'urn:mace:dir:attribute-def:mail', - 'mailPreferenceOption': 'urn:mace:dir:attribute-def:mailPreferenceOption', - 'manager': 'urn:mace:dir:attribute-def:manager', - 'member': 'urn:mace:dir:attribute-def:member', - 'mobile': 'urn:mace:dir:attribute-def:mobile', - 'mobileTelephoneNumber': 'urn:mace:dir:attribute-def:mobileTelephoneNumber', - 'nSRecord': 'urn:mace:dir:attribute-def:nSRecord', - 'name': 'urn:mace:dir:attribute-def:name', - 'norEduOrgAcronym': 'urn:mace:dir:attribute-def:norEduOrgAcronym', - 'norEduOrgNIN': 'urn:mace:dir:attribute-def:norEduOrgNIN', - 'norEduOrgSchemaVersion': 'urn:mace:dir:attribute-def:norEduOrgSchemaVersion', - 'norEduOrgUniqueIdentifier': 'urn:mace:dir:attribute-def:norEduOrgUniqueIdentifier', - 'norEduOrgUniqueNumber': 'urn:mace:dir:attribute-def:norEduOrgUniqueNumber', - 'norEduOrgUnitUniqueIdentifier': 'urn:mace:dir:attribute-def:norEduOrgUnitUniqueIdentifier', - 'norEduOrgUnitUniqueNumber': 'urn:mace:dir:attribute-def:norEduOrgUnitUniqueNumber', - 'norEduPersonBirthDate': 'urn:mace:dir:attribute-def:norEduPersonBirthDate', - 'norEduPersonLIN': 'urn:mace:dir:attribute-def:norEduPersonLIN', - 'norEduPersonNIN': 'urn:mace:dir:attribute-def:norEduPersonNIN', - 'o': 'urn:mace:dir:attribute-def:o', - 'objectClass': 'urn:mace:dir:attribute-def:objectClass', - 'organizationName': 'urn:mace:dir:attribute-def:organizationName', - 'organizationalStatus': 'urn:mace:dir:attribute-def:organizationalStatus', - 'organizationalUnitName': 'urn:mace:dir:attribute-def:organizationalUnitName', - 'otherMailbox': 'urn:mace:dir:attribute-def:otherMailbox', - 'ou': 'urn:mace:dir:attribute-def:ou', - 'owner': 'urn:mace:dir:attribute-def:owner', - 'pager': 'urn:mace:dir:attribute-def:pager', - 'pagerTelephoneNumber': 'urn:mace:dir:attribute-def:pagerTelephoneNumber', - 'personalSignature': 'urn:mace:dir:attribute-def:personalSignature', - 'personalTitle': 'urn:mace:dir:attribute-def:personalTitle', - 'photo': 'urn:mace:dir:attribute-def:photo', - 'physicalDeliveryOfficeName': 'urn:mace:dir:attribute-def:physicalDeliveryOfficeName', - 'pkcs9email': 'urn:mace:dir:attribute-def:pkcs9email', - 'postOfficeBox': 'urn:mace:dir:attribute-def:postOfficeBox', - 'postalAddress': 'urn:mace:dir:attribute-def:postalAddress', - 'postalCode': 'urn:mace:dir:attribute-def:postalCode', - 'preferredDeliveryMethod': 'urn:mace:dir:attribute-def:preferredDeliveryMethod', - 'preferredLanguage': 'urn:mace:dir:attribute-def:preferredLanguage', - 'presentationAddress': 'urn:mace:dir:attribute-def:presentationAddress', - 'protocolInformation': 'urn:mace:dir:attribute-def:protocolInformation', - 'pseudonym': 'urn:mace:dir:attribute-def:pseudonym', - 'registeredAddress': 'urn:mace:dir:attribute-def:registeredAddress', - 'rfc822Mailbox': 'urn:mace:dir:attribute-def:rfc822Mailbox', - 'roleOccupant': 'urn:mace:dir:attribute-def:roleOccupant', - 'roomNumber': 'urn:mace:dir:attribute-def:roomNumber', - 'sOARecord': 'urn:mace:dir:attribute-def:sOARecord', - 'searchGuide': 'urn:mace:dir:attribute-def:searchGuide', - 'secretary': 'urn:mace:dir:attribute-def:secretary', - 'seeAlso': 'urn:mace:dir:attribute-def:seeAlso', - 'serialNumber': 'urn:mace:dir:attribute-def:serialNumber', - 'singleLevelQuality': 'urn:mace:dir:attribute-def:singleLevelQuality', - 'sn': 'urn:mace:dir:attribute-def:sn', - 'st': 'urn:mace:dir:attribute-def:st', - 'stateOrProvinceName': 'urn:mace:dir:attribute-def:stateOrProvinceName', - 'street': 'urn:mace:dir:attribute-def:street', - 'streetAddress': 'urn:mace:dir:attribute-def:streetAddress', - 'subtreeMaximumQuality': 'urn:mace:dir:attribute-def:subtreeMaximumQuality', - 'subtreeMinimumQuality': 'urn:mace:dir:attribute-def:subtreeMinimumQuality', - 'supportedAlgorithms': 'urn:mace:dir:attribute-def:supportedAlgorithms', - 'supportedApplicationContext': 'urn:mace:dir:attribute-def:supportedApplicationContext', - 'surname': 'urn:mace:dir:attribute-def:surname', - 'telephoneNumber': 'urn:mace:dir:attribute-def:telephoneNumber', - 'teletexTerminalIdentifier': 'urn:mace:dir:attribute-def:teletexTerminalIdentifier', - 'telexNumber': 'urn:mace:dir:attribute-def:telexNumber', - 'textEncodedORAddress': 'urn:mace:dir:attribute-def:textEncodedORAddress', - 'title': 'urn:mace:dir:attribute-def:title', - 'uid': 'urn:mace:dir:attribute-def:uid', - 'uniqueIdentifier': 'urn:mace:dir:attribute-def:uniqueIdentifier', - 'uniqueMember': 'urn:mace:dir:attribute-def:uniqueMember', - 'userCertificate': 'urn:mace:dir:attribute-def:userCertificate', - 'userClass': 'urn:mace:dir:attribute-def:userClass', - 'userPKCS12': 'urn:mace:dir:attribute-def:userPKCS12', - 'userPassword': 'urn:mace:dir:attribute-def:userPassword', - 'userSMIMECertificate': 'urn:mace:dir:attribute-def:userSMIMECertificate', - 'userid': 'urn:mace:dir:attribute-def:userid', - 'x121Address': 'urn:mace:dir:attribute-def:x121Address', - 'x500UniqueIdentifier': 'urn:mace:dir:attribute-def:x500UniqueIdentifier', - } -} diff --git a/apps/badgrsocialauth/testfiles/attributemaps/saml_uri.py b/apps/badgrsocialauth/testfiles/attributemaps/saml_uri.py deleted file mode 100644 index a0bbdd4a9..000000000 --- a/apps/badgrsocialauth/testfiles/attributemaps/saml_uri.py +++ /dev/null @@ -1,241 +0,0 @@ -__author__ = 'rolandh' - -EDUPERSON_OID = "urn:oid:1.3.6.1.4.1.5923.1.1.1." -X500ATTR_OID = "urn:oid:2.5.4." -NOREDUPERSON_OID = "urn:oid:1.3.6.1.4.1.2428.90.1." -NETSCAPE_LDAP = "urn:oid:2.16.840.1.113730.3.1." -UCL_DIR_PILOT = 'urn:oid:0.9.2342.19200300.100.1.' -PKCS_9 = "urn:oid:1.2.840.113549.1.9.1." -UMICH = "urn:oid:1.3.6.1.4.1.250.1.57." -SCHAC = "urn:oid:1.3.6.1.4.1.25178.2." - -MAP = { - "identifier": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", - "fro": { - EDUPERSON_OID+'2': 'eduPersonNickname', - EDUPERSON_OID+'9': 'eduPersonScopedAffiliation', - EDUPERSON_OID+'11': 'eduPersonAssurance', - EDUPERSON_OID+'10': 'eduPersonTargetedID', - EDUPERSON_OID+'4': 'eduPersonOrgUnitDN', - NOREDUPERSON_OID+'6': 'norEduOrgAcronym', - NOREDUPERSON_OID+'7': 'norEduOrgUniqueIdentifier', - NOREDUPERSON_OID+'4': 'norEduPersonLIN', - EDUPERSON_OID+'1': 'eduPersonAffiliation', - NOREDUPERSON_OID+'2': 'norEduOrgUnitUniqueNumber', - NETSCAPE_LDAP+'40': 'userSMIMECertificate', - NOREDUPERSON_OID+'1': 'norEduOrgUniqueNumber', - NETSCAPE_LDAP+'241': 'displayName', - UCL_DIR_PILOT+'37': 'associatedDomain', - EDUPERSON_OID+'6': 'eduPersonPrincipalName', - NOREDUPERSON_OID+'8': 'norEduOrgUnitUniqueIdentifier', - NOREDUPERSON_OID+'9': 'federationFeideSchemaVersion', - X500ATTR_OID+'53': 'deltaRevocationList', - X500ATTR_OID+'52': 'supportedAlgorithms', - X500ATTR_OID+'51': 'houseIdentifier', - X500ATTR_OID+'50': 'uniqueMember', - X500ATTR_OID+'19': 'physicalDeliveryOfficeName', - X500ATTR_OID+'18': 'postOfficeBox', - X500ATTR_OID+'17': 'postalCode', - X500ATTR_OID+'16': 'postalAddress', - X500ATTR_OID+'15': 'businessCategory', - X500ATTR_OID+'14': 'searchGuide', - EDUPERSON_OID+'5': 'eduPersonPrimaryAffiliation', - X500ATTR_OID+'12': 'title', - X500ATTR_OID+'11': 'ou', - X500ATTR_OID+'10': 'o', - X500ATTR_OID+'37': 'cACertificate', - X500ATTR_OID+'36': 'userCertificate', - X500ATTR_OID+'31': 'member', - X500ATTR_OID+'30': 'supportedApplicationContext', - X500ATTR_OID+'33': 'roleOccupant', - X500ATTR_OID+'32': 'owner', - NETSCAPE_LDAP+'1': 'carLicense', - PKCS_9+'1': 'email', - NETSCAPE_LDAP+'3': 'employeeNumber', - NETSCAPE_LDAP+'2': 'departmentNumber', - X500ATTR_OID+'39': 'certificateRevocationList', - X500ATTR_OID+'38': 'authorityRevocationList', - NETSCAPE_LDAP+'216': 'userPKCS12', - EDUPERSON_OID+'8': 'eduPersonPrimaryOrgUnitDN', - X500ATTR_OID+'9': 'street', - X500ATTR_OID+'8': 'st', - NETSCAPE_LDAP+'39': 'preferredLanguage', - EDUPERSON_OID+'7': 'eduPersonEntitlement', - X500ATTR_OID+'2': 'knowledgeInformation', - X500ATTR_OID+'7': 'l', - X500ATTR_OID+'6': 'c', - X500ATTR_OID+'5': 'serialNumber', - X500ATTR_OID+'4': 'sn', - UCL_DIR_PILOT+'60': 'jpegPhoto', - X500ATTR_OID+'65': 'pseudonym', - NOREDUPERSON_OID+'5': 'norEduPersonNIN', - UCL_DIR_PILOT+'3': 'mail', - UCL_DIR_PILOT+'25': 'dc', - X500ATTR_OID+'40': 'crossCertificatePair', - X500ATTR_OID+'42': 'givenName', - X500ATTR_OID+'43': 'initials', - X500ATTR_OID+'44': 'generationQualifier', - X500ATTR_OID+'45': 'x500UniqueIdentifier', - X500ATTR_OID+'46': 'dnQualifier', - X500ATTR_OID+'47': 'enhancedSearchGuide', - X500ATTR_OID+'48': 'protocolInformation', - X500ATTR_OID+'54': 'dmdName', - NETSCAPE_LDAP+'4': 'employeeType', - X500ATTR_OID+'22': 'teletexTerminalIdentifier', - X500ATTR_OID+'23': 'facsimileTelephoneNumber', - X500ATTR_OID+'20': 'telephoneNumber', - X500ATTR_OID+'21': 'telexNumber', - X500ATTR_OID+'26': 'registeredAddress', - X500ATTR_OID+'27': 'destinationIndicator', - X500ATTR_OID+'24': 'x121Address', - X500ATTR_OID+'25': 'internationaliSDNNumber', - X500ATTR_OID+'28': 'preferredDeliveryMethod', - X500ATTR_OID+'29': 'presentationAddress', - EDUPERSON_OID+'3': 'eduPersonOrgDN', - NOREDUPERSON_OID+'3': 'norEduPersonBirthDate', - UMICH+'57': 'labeledURI', - UCL_DIR_PILOT+'1': 'uid', - SCHAC+'1': 'schacMotherTongue', - SCHAC+'2': 'schacGender', - SCHAC+'3': 'schacDateOfBirth', - SCHAC+'4': 'schacPlaceOfBirth', - SCHAC+'5': 'schacCountryOfCitizenship', - SCHAC+'6': 'schacSn1', - SCHAC+'7': 'schacSn2', - SCHAC+'8': 'schacPersonalTitle', - SCHAC+'9': 'schacHomeOrganization', - SCHAC+'10': 'schacHomeOrganizationType', - SCHAC+'11': 'schacCountryOfResidence', - SCHAC+'12': 'schacUserPresenceID', - SCHAC+'13': 'schacPersonalPosition', - SCHAC+'14': 'schacPersonalUniqueCode', - SCHAC+'15': 'schacPersonalUniqueID', - SCHAC+'17': 'schacExpiryDate', - SCHAC+'18': 'schacUserPrivateAttribute', - SCHAC+'19': 'schacUserStatus', - SCHAC+'20': 'schacProjectMembership', - SCHAC+'21': 'schacProjectSpecificRole', - }, - "to": { - 'roleOccupant': X500ATTR_OID+'33', - 'gn': X500ATTR_OID+'42', - 'norEduPersonNIN': NOREDUPERSON_OID+'5', - 'title': X500ATTR_OID+'12', - 'facsimileTelephoneNumber': X500ATTR_OID+'23', - 'mail': UCL_DIR_PILOT+'3', - 'postOfficeBox': X500ATTR_OID+'18', - 'fax': X500ATTR_OID+'23', - 'telephoneNumber': X500ATTR_OID+'20', - 'norEduPersonBirthDate': NOREDUPERSON_OID+'3', - 'rfc822Mailbox': UCL_DIR_PILOT+'3', - 'dc': UCL_DIR_PILOT+'25', - 'countryName': X500ATTR_OID+'6', - 'emailAddress': PKCS_9+'1', - 'employeeNumber': NETSCAPE_LDAP+'3', - 'organizationName': X500ATTR_OID+'10', - 'eduPersonAssurance': EDUPERSON_OID+'11', - 'norEduOrgAcronym': NOREDUPERSON_OID+'6', - 'registeredAddress': X500ATTR_OID+'26', - 'physicalDeliveryOfficeName': X500ATTR_OID+'19', - 'associatedDomain': UCL_DIR_PILOT+'37', - 'l': X500ATTR_OID+'7', - 'stateOrProvinceName': X500ATTR_OID+'8', - 'federationFeideSchemaVersion': NOREDUPERSON_OID+'9', - 'pkcs9email': PKCS_9+'1', - 'givenName': X500ATTR_OID+'42', - 'givenname': X500ATTR_OID+'42', - 'x500UniqueIdentifier': X500ATTR_OID+'45', - 'eduPersonNickname': EDUPERSON_OID+'2', - 'houseIdentifier': X500ATTR_OID+'51', - 'street': X500ATTR_OID+'9', - 'supportedAlgorithms': X500ATTR_OID+'52', - 'preferredLanguage': NETSCAPE_LDAP+'39', - 'postalAddress': X500ATTR_OID+'16', - 'email': PKCS_9+'1', - 'norEduOrgUnitUniqueIdentifier': NOREDUPERSON_OID+'8', - 'eduPersonPrimaryOrgUnitDN': EDUPERSON_OID+'8', - 'c': X500ATTR_OID+'6', - 'teletexTerminalIdentifier': X500ATTR_OID+'22', - 'o': X500ATTR_OID+'10', - 'cACertificate': X500ATTR_OID+'37', - 'telexNumber': X500ATTR_OID+'21', - 'ou': X500ATTR_OID+'11', - 'initials': X500ATTR_OID+'43', - 'eduPersonOrgUnitDN': EDUPERSON_OID+'4', - 'deltaRevocationList': X500ATTR_OID+'53', - 'norEduPersonLIN': NOREDUPERSON_OID+'4', - 'supportedApplicationContext': X500ATTR_OID+'30', - 'eduPersonEntitlement': EDUPERSON_OID+'7', - 'generationQualifier': X500ATTR_OID+'44', - 'eduPersonAffiliation': EDUPERSON_OID+'1', - 'edupersonaffiliation': EDUPERSON_OID+'1', - 'eduPersonPrincipalName': EDUPERSON_OID+'6', - 'edupersonprincipalname': EDUPERSON_OID+'6', - 'localityName': X500ATTR_OID+'7', - 'owner': X500ATTR_OID+'32', - 'norEduOrgUnitUniqueNumber': NOREDUPERSON_OID+'2', - 'searchGuide': X500ATTR_OID+'14', - 'certificateRevocationList': X500ATTR_OID+'39', - 'organizationalUnitName': X500ATTR_OID+'11', - 'userCertificate': X500ATTR_OID+'36', - 'preferredDeliveryMethod': X500ATTR_OID+'28', - 'internationaliSDNNumber': X500ATTR_OID+'25', - 'uniqueMember': X500ATTR_OID+'50', - 'departmentNumber': NETSCAPE_LDAP+'2', - 'enhancedSearchGuide': X500ATTR_OID+'47', - 'userPKCS12': NETSCAPE_LDAP+'216', - 'eduPersonTargetedID': EDUPERSON_OID+'10', - 'norEduOrgUniqueNumber': NOREDUPERSON_OID+'1', - 'x121Address': X500ATTR_OID+'24', - 'destinationIndicator': X500ATTR_OID+'27', - 'eduPersonPrimaryAffiliation': EDUPERSON_OID+'5', - 'surname': X500ATTR_OID+'4', - 'jpegPhoto': UCL_DIR_PILOT+'60', - 'eduPersonScopedAffiliation': EDUPERSON_OID+'9', - 'edupersonscopedaffiliation': EDUPERSON_OID+'9', - 'protocolInformation': X500ATTR_OID+'48', - 'knowledgeInformation': X500ATTR_OID+'2', - 'employeeType': NETSCAPE_LDAP+'4', - 'userSMIMECertificate': NETSCAPE_LDAP+'40', - 'member': X500ATTR_OID+'31', - 'streetAddress': X500ATTR_OID+'9', - 'dmdName': X500ATTR_OID+'54', - 'postalCode': X500ATTR_OID+'17', - 'pseudonym': X500ATTR_OID+'65', - 'dnQualifier': X500ATTR_OID+'46', - 'crossCertificatePair': X500ATTR_OID+'40', - 'eduPersonOrgDN': EDUPERSON_OID+'3', - 'authorityRevocationList': X500ATTR_OID+'38', - 'displayName': NETSCAPE_LDAP+'241', - 'businessCategory': X500ATTR_OID+'15', - 'serialNumber': X500ATTR_OID+'5', - 'norEduOrgUniqueIdentifier': NOREDUPERSON_OID+'7', - 'st': X500ATTR_OID+'8', - 'carLicense': NETSCAPE_LDAP+'1', - 'presentationAddress': X500ATTR_OID+'29', - 'sn': X500ATTR_OID+'4', - 'domainComponent': UCL_DIR_PILOT+'25', - 'labeledURI': UMICH+'57', - 'uid': UCL_DIR_PILOT+'1', - 'schacMotherTongue':SCHAC+'1', - 'schacGender': SCHAC+'2', - 'schacDateOfBirth':SCHAC+'3', - 'schacPlaceOfBirth': SCHAC+'4', - 'schacCountryOfCitizenship':SCHAC+'5', - 'schacSn1': SCHAC+'6', - 'schacSn2': SCHAC+'7', - 'schacPersonalTitle':SCHAC+'8', - 'schacHomeOrganization': SCHAC+'9', - 'schacHomeOrganizationType': SCHAC+'10', - 'schacCountryOfResidence': SCHAC+'11', - 'schacUserPresenceID': SCHAC+'12', - 'schacPersonalPosition': SCHAC+'13', - 'schacPersonalUniqueCode': SCHAC+'14', - 'schacPersonalUniqueID': SCHAC+'15', - 'schacExpiryDate': SCHAC+'17', - 'schacUserPrivateAttribute': SCHAC+'18', - 'schacUserStatus': SCHAC+'19', - 'schacProjectMembership': SCHAC+'20', - 'schacProjectSpecificRole': SCHAC+'21', - } -} diff --git a/apps/badgrsocialauth/testfiles/attributemaps/shibboleth_uri.py b/apps/badgrsocialauth/testfiles/attributemaps/shibboleth_uri.py deleted file mode 100644 index 002a8fec0..000000000 --- a/apps/badgrsocialauth/testfiles/attributemaps/shibboleth_uri.py +++ /dev/null @@ -1,190 +0,0 @@ -EDUPERSON_OID = "urn:oid:1.3.6.1.4.1.5923.1.1.1." -X500ATTR = "urn:oid:2.5.4." -NOREDUPERSON_OID = "urn:oid:1.3.6.1.4.1.2428.90.1." -NETSCAPE_LDAP = "urn:oid:2.16.840.1.113730.3.1." -UCL_DIR_PILOT = "urn:oid:0.9.2342.19200300.100.1." -PKCS_9 = "urn:oid:1.2.840.113549.1.9." -UMICH = "urn:oid:1.3.6.1.4.1.250.1.57." - -MAP = { - "identifier": "urn:mace:shibboleth:1.0:attributeNamespace:uri", - "fro": { - EDUPERSON_OID+'2': 'eduPersonNickname', - EDUPERSON_OID+'9': 'eduPersonScopedAffiliation', - EDUPERSON_OID+'11': 'eduPersonAssurance', - EDUPERSON_OID+'10': 'eduPersonTargetedID', - EDUPERSON_OID+'4': 'eduPersonOrgUnitDN', - NOREDUPERSON_OID+'6': 'norEduOrgAcronym', - NOREDUPERSON_OID+'7': 'norEduOrgUniqueIdentifier', - NOREDUPERSON_OID+'4': 'norEduPersonLIN', - EDUPERSON_OID+'1': 'eduPersonAffiliation', - NOREDUPERSON_OID+'2': 'norEduOrgUnitUniqueNumber', - NETSCAPE_LDAP+'40': 'userSMIMECertificate', - NOREDUPERSON_OID+'1': 'norEduOrgUniqueNumber', - NETSCAPE_LDAP+'241': 'displayName', - UCL_DIR_PILOT+'37': 'associatedDomain', - EDUPERSON_OID+'6': 'eduPersonPrincipalName', - NOREDUPERSON_OID+'8': 'norEduOrgUnitUniqueIdentifier', - NOREDUPERSON_OID+'9': 'federationFeideSchemaVersion', - X500ATTR+'53': 'deltaRevocationList', - X500ATTR+'52': 'supportedAlgorithms', - X500ATTR+'51': 'houseIdentifier', - X500ATTR+'50': 'uniqueMember', - X500ATTR+'19': 'physicalDeliveryOfficeName', - X500ATTR+'18': 'postOfficeBox', - X500ATTR+'17': 'postalCode', - X500ATTR+'16': 'postalAddress', - X500ATTR+'15': 'businessCategory', - X500ATTR+'14': 'searchGuide', - EDUPERSON_OID+'5': 'eduPersonPrimaryAffiliation', - X500ATTR+'12': 'title', - X500ATTR+'11': 'ou', - X500ATTR+'10': 'o', - X500ATTR+'37': 'cACertificate', - X500ATTR+'36': 'userCertificate', - X500ATTR+'31': 'member', - X500ATTR+'30': 'supportedApplicationContext', - X500ATTR+'33': 'roleOccupant', - X500ATTR+'32': 'owner', - NETSCAPE_LDAP+'1': 'carLicense', - PKCS_9+'1': 'email', - NETSCAPE_LDAP+'3': 'employeeNumber', - NETSCAPE_LDAP+'2': 'departmentNumber', - X500ATTR+'39': 'certificateRevocationList', - X500ATTR+'38': 'authorityRevocationList', - NETSCAPE_LDAP+'216': 'userPKCS12', - EDUPERSON_OID+'8': 'eduPersonPrimaryOrgUnitDN', - X500ATTR+'9': 'street', - X500ATTR+'8': 'st', - NETSCAPE_LDAP+'39': 'preferredLanguage', - EDUPERSON_OID+'7': 'eduPersonEntitlement', - X500ATTR+'2': 'knowledgeInformation', - X500ATTR+'7': 'l', - X500ATTR+'6': 'c', - X500ATTR+'5': 'serialNumber', - X500ATTR+'4': 'sn', - UCL_DIR_PILOT+'60': 'jpegPhoto', - X500ATTR+'65': 'pseudonym', - NOREDUPERSON_OID+'5': 'norEduPersonNIN', - UCL_DIR_PILOT+'3': 'mail', - UCL_DIR_PILOT+'25': 'dc', - X500ATTR+'40': 'crossCertificatePair', - X500ATTR+'42': 'givenName', - X500ATTR+'43': 'initials', - X500ATTR+'44': 'generationQualifier', - X500ATTR+'45': 'x500UniqueIdentifier', - X500ATTR+'46': 'dnQualifier', - X500ATTR+'47': 'enhancedSearchGuide', - X500ATTR+'48': 'protocolInformation', - X500ATTR+'54': 'dmdName', - NETSCAPE_LDAP+'4': 'employeeType', - X500ATTR+'22': 'teletexTerminalIdentifier', - X500ATTR+'23': 'facsimileTelephoneNumber', - X500ATTR+'20': 'telephoneNumber', - X500ATTR+'21': 'telexNumber', - X500ATTR+'26': 'registeredAddress', - X500ATTR+'27': 'destinationIndicator', - X500ATTR+'24': 'x121Address', - X500ATTR+'25': 'internationaliSDNNumber', - X500ATTR+'28': 'preferredDeliveryMethod', - X500ATTR+'29': 'presentationAddress', - EDUPERSON_OID+'3': 'eduPersonOrgDN', - NOREDUPERSON_OID+'3': 'norEduPersonBirthDate', - }, - "to":{ - 'roleOccupant': X500ATTR+'33', - 'gn': X500ATTR+'42', - 'norEduPersonNIN': NOREDUPERSON_OID+'5', - 'title': X500ATTR+'12', - 'facsimileTelephoneNumber': X500ATTR+'23', - 'mail': UCL_DIR_PILOT+'3', - 'postOfficeBox': X500ATTR+'18', - 'fax': X500ATTR+'23', - 'telephoneNumber': X500ATTR+'20', - 'norEduPersonBirthDate': NOREDUPERSON_OID+'3', - 'rfc822Mailbox': UCL_DIR_PILOT+'3', - 'dc': UCL_DIR_PILOT+'25', - 'countryName': X500ATTR+'6', - 'emailAddress': PKCS_9+'1', - 'employeeNumber': NETSCAPE_LDAP+'3', - 'organizationName': X500ATTR+'10', - 'eduPersonAssurance': EDUPERSON_OID+'11', - 'norEduOrgAcronym': NOREDUPERSON_OID+'6', - 'registeredAddress': X500ATTR+'26', - 'physicalDeliveryOfficeName': X500ATTR+'19', - 'associatedDomain': UCL_DIR_PILOT+'37', - 'l': X500ATTR+'7', - 'stateOrProvinceName': X500ATTR+'8', - 'federationFeideSchemaVersion': NOREDUPERSON_OID+'9', - 'pkcs9email': PKCS_9+'1', - 'givenName': X500ATTR+'42', - 'x500UniqueIdentifier': X500ATTR+'45', - 'eduPersonNickname': EDUPERSON_OID+'2', - 'houseIdentifier': X500ATTR+'51', - 'street': X500ATTR+'9', - 'supportedAlgorithms': X500ATTR+'52', - 'preferredLanguage': NETSCAPE_LDAP+'39', - 'postalAddress': X500ATTR+'16', - 'email': PKCS_9+'1', - 'norEduOrgUnitUniqueIdentifier': NOREDUPERSON_OID+'8', - 'eduPersonPrimaryOrgUnitDN': EDUPERSON_OID+'8', - 'c': X500ATTR+'6', - 'teletexTerminalIdentifier': X500ATTR+'22', - 'o': X500ATTR+'10', - 'cACertificate': X500ATTR+'37', - 'telexNumber': X500ATTR+'21', - 'ou': X500ATTR+'11', - 'initials': X500ATTR+'43', - 'eduPersonOrgUnitDN': EDUPERSON_OID+'4', - 'deltaRevocationList': X500ATTR+'53', - 'norEduPersonLIN': NOREDUPERSON_OID+'4', - 'supportedApplicationContext': X500ATTR+'30', - 'eduPersonEntitlement': EDUPERSON_OID+'7', - 'generationQualifier': X500ATTR+'44', - 'eduPersonAffiliation': EDUPERSON_OID+'1', - 'eduPersonPrincipalName': EDUPERSON_OID+'6', - 'localityName': X500ATTR+'7', - 'owner': X500ATTR+'32', - 'norEduOrgUnitUniqueNumber': NOREDUPERSON_OID+'2', - 'searchGuide': X500ATTR+'14', - 'certificateRevocationList': X500ATTR+'39', - 'organizationalUnitName': X500ATTR+'11', - 'userCertificate': X500ATTR+'36', - 'preferredDeliveryMethod': X500ATTR+'28', - 'internationaliSDNNumber': X500ATTR+'25', - 'uniqueMember': X500ATTR+'50', - 'departmentNumber': NETSCAPE_LDAP+'2', - 'enhancedSearchGuide': X500ATTR+'47', - 'userPKCS12': NETSCAPE_LDAP+'216', - 'eduPersonTargetedID': EDUPERSON_OID+'10', - 'norEduOrgUniqueNumber': NOREDUPERSON_OID+'1', - 'x121Address': X500ATTR+'24', - 'destinationIndicator': X500ATTR+'27', - 'eduPersonPrimaryAffiliation': EDUPERSON_OID+'5', - 'surname': X500ATTR+'4', - 'jpegPhoto': UCL_DIR_PILOT+'60', - 'eduPersonScopedAffiliation': EDUPERSON_OID+'9', - 'protocolInformation': X500ATTR+'48', - 'knowledgeInformation': X500ATTR+'2', - 'employeeType': NETSCAPE_LDAP+'4', - 'userSMIMECertificate': NETSCAPE_LDAP+'40', - 'member': X500ATTR+'31', - 'streetAddress': X500ATTR+'9', - 'dmdName': X500ATTR+'54', - 'postalCode': X500ATTR+'17', - 'pseudonym': X500ATTR+'65', - 'dnQualifier': X500ATTR+'46', - 'crossCertificatePair': X500ATTR+'40', - 'eduPersonOrgDN': EDUPERSON_OID+'3', - 'authorityRevocationList': X500ATTR+'38', - 'displayName': NETSCAPE_LDAP+'241', - 'businessCategory': X500ATTR+'15', - 'serialNumber': X500ATTR+'5', - 'norEduOrgUniqueIdentifier': NOREDUPERSON_OID+'7', - 'st': X500ATTR+'8', - 'carLicense': NETSCAPE_LDAP+'1', - 'presentationAddress': X500ATTR+'29', - 'sn': X500ATTR+'4', - 'domainComponent': UCL_DIR_PILOT+'25', - } -} diff --git a/apps/badgrsocialauth/testfiles/idp-metadata-for-saml2configuration.xml b/apps/badgrsocialauth/testfiles/idp-metadata-for-saml2configuration.xml deleted file mode 100644 index 816d925a1..000000000 --- a/apps/badgrsocialauth/testfiles/idp-metadata-for-saml2configuration.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - MIICpDCCAYwCCQCRX9j/8ZGj9jANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjAwNDI4MTkxNDQ3WhcNMjEwNDI4MTkxNDQ3WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC04KoAM2rqbZPpEQ7Xm1+mOfxLfpYRcKCjhLxuJKxdeupO/MfM4f1WlgGlJRtf1I9rJ2x5JVXVBBRvz4kj1znlmwN6Ni/vZH+GLdan38EvHQ2+E3QSApEVdt4BrbgWW9v0LvEfBRL07NuPfeBpwk11RYNAbNjcE5+128X5s+v0vPK9EENRkEeEBiK9pU1K4qq3p+Pe9oTqvIJvhzQPgq0BRWbn6xJDy4P3dq71uL3NBEuB+4O7h8dfKekpR59DAHeM37X8vmbl6RTsu4Zo+qwFew6FEedriJy/GiYTuiROjXRANnxy43twS/VGgYTgUZsWO7dPFkIKTDCZSXKQ8khNAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEWv4MJmFODbKaiW/FtneEhdaBd2qco5cUR2aMEyQxIXnJCJA70rL7uONIPRYhx82BdEAA5YTKQpxj9YF1+SSjJTSznEkmcAtutXjpg5cTxWAlzo3G9kcHBdSHm0VCVgSVhdAoevEWcyTuteUYog/D6busHuzAdbZpUukprwRVLtFdqAFsms9h1g/DcP1bXEfozbCrFRkOeOtQ9i5TnaEG/d1qKo76LI+dRtz5Rt9TzqDYv11P0yCDC/QggxwM0Akd1W1xl9DWnzhgIU3jDUG0HtTXQACfSj+jIbi4Q460u02KSNW/s3Zgncss2K6cO+JFvs03lhI/ARodo0S+5he24= - - - - - - - MIICpDCCAYwCCQCRX9j/8ZGj9jANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjAwNDI4MTkxNDQ3WhcNMjEwNDI4MTkxNDQ3WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC04KoAM2rqbZPpEQ7Xm1+mOfxLfpYRcKCjhLxuJKxdeupO/MfM4f1WlgGlJRtf1I9rJ2x5JVXVBBRvz4kj1znlmwN6Ni/vZH+GLdan38EvHQ2+E3QSApEVdt4BrbgWW9v0LvEfBRL07NuPfeBpwk11RYNAbNjcE5+128X5s+v0vPK9EENRkEeEBiK9pU1K4qq3p+Pe9oTqvIJvhzQPgq0BRWbn6xJDy4P3dq71uL3NBEuB+4O7h8dfKekpR59DAHeM37X8vmbl6RTsu4Zo+qwFew6FEedriJy/GiYTuiROjXRANnxy43twS/VGgYTgUZsWO7dPFkIKTDCZSXKQ8khNAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEWv4MJmFODbKaiW/FtneEhdaBd2qco5cUR2aMEyQxIXnJCJA70rL7uONIPRYhx82BdEAA5YTKQpxj9YF1+SSjJTSznEkmcAtutXjpg5cTxWAlzo3G9kcHBdSHm0VCVgSVhdAoevEWcyTuteUYog/D6busHuzAdbZpUukprwRVLtFdqAFsms9h1g/DcP1bXEfozbCrFRkOeOtQ9i5TnaEG/d1qKo76LI+dRtz5Rt9TzqDYv11P0yCDC/QggxwM0Akd1W1xl9DWnzhgIU3jDUG0HtTXQACfSj+jIbi4Q460u02KSNW/s3Zgncss2K6cO+JFvs03lhI/ARodo0S+5he24= - - - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - - - diff --git a/apps/badgrsocialauth/testfiles/idp-test-cert.pem b/apps/badgrsocialauth/testfiles/idp-test-cert.pem deleted file mode 100644 index 97ddc0c62..000000000 --- a/apps/badgrsocialauth/testfiles/idp-test-cert.pem +++ /dev/null @@ -1,17 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICpDCCAYwCCQCRX9j/8ZGj9jANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls -b2NhbGhvc3QwHhcNMjAwNDI4MTkxNDQ3WhcNMjEwNDI4MTkxNDQ3WjAUMRIwEAYD -VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0 -4KoAM2rqbZPpEQ7Xm1+mOfxLfpYRcKCjhLxuJKxdeupO/MfM4f1WlgGlJRtf1I9r -J2x5JVXVBBRvz4kj1znlmwN6Ni/vZH+GLdan38EvHQ2+E3QSApEVdt4BrbgWW9v0 -LvEfBRL07NuPfeBpwk11RYNAbNjcE5+128X5s+v0vPK9EENRkEeEBiK9pU1K4qq3 -p+Pe9oTqvIJvhzQPgq0BRWbn6xJDy4P3dq71uL3NBEuB+4O7h8dfKekpR59DAHeM -37X8vmbl6RTsu4Zo+qwFew6FEedriJy/GiYTuiROjXRANnxy43twS/VGgYTgUZsW -O7dPFkIKTDCZSXKQ8khNAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEWv4MJmFODb -KaiW/FtneEhdaBd2qco5cUR2aMEyQxIXnJCJA70rL7uONIPRYhx82BdEAA5YTKQp -xj9YF1+SSjJTSznEkmcAtutXjpg5cTxWAlzo3G9kcHBdSHm0VCVgSVhdAoevEWcy -TuteUYog/D6busHuzAdbZpUukprwRVLtFdqAFsms9h1g/DcP1bXEfozbCrFRkOeO -tQ9i5TnaEG/d1qKo76LI+dRtz5Rt9TzqDYv11P0yCDC/QggxwM0Akd1W1xl9DWnz -hgIU3jDUG0HtTXQACfSj+jIbi4Q460u02KSNW/s3Zgncss2K6cO+JFvs03lhI/AR -odo0S+5he24= ------END CERTIFICATE----- diff --git a/apps/badgrsocialauth/testfiles/idp-test-key.pem b/apps/badgrsocialauth/testfiles/idp-test-key.pem deleted file mode 100644 index d67831346..000000000 --- a/apps/badgrsocialauth/testfiles/idp-test-key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC04KoAM2rqbZPp -EQ7Xm1+mOfxLfpYRcKCjhLxuJKxdeupO/MfM4f1WlgGlJRtf1I9rJ2x5JVXVBBRv -z4kj1znlmwN6Ni/vZH+GLdan38EvHQ2+E3QSApEVdt4BrbgWW9v0LvEfBRL07NuP -feBpwk11RYNAbNjcE5+128X5s+v0vPK9EENRkEeEBiK9pU1K4qq3p+Pe9oTqvIJv -hzQPgq0BRWbn6xJDy4P3dq71uL3NBEuB+4O7h8dfKekpR59DAHeM37X8vmbl6RTs -u4Zo+qwFew6FEedriJy/GiYTuiROjXRANnxy43twS/VGgYTgUZsWO7dPFkIKTDCZ -SXKQ8khNAgMBAAECggEANlfRe6H3G1u0tq9jUC/kOuLtBBmSKMc33CkDG+x0xNkr -EHQt60ZygNMsx1swsEgCluUPWA55jThek4E86MG/KVa7YrppCHmfPscC2QkG14rZ -GCRsGQUgZdyCsupohn8uxFxeIT27Bk8rYB8nj17LOtKZrn+FYAmdUFwpTO5bk/m2 -3kxTcBVktPlHyLQxIzo+KV/fJalir7QK4VSiykouD9odnmwnqY4EesdWdi6snUPx -u6ztPMWJ1d39P/lLeZXtXgedrb3+sRQqZpF66LD8B/3t0r9h1C0yM4gvefyc8oOA -wHc0CceVrxkwSGwOAE9Nyq1RZrof6LVGeI0BqbwTQQKBgQDtcRthsov0yRE/W+ew -qu1nnMYjjRuIHz90hyitN72prqrcus7NFZlfpXa6ZCTmDTFQBhQ8swJ0U3h0lSyv -HPTF8EFd1ALV7+RzSnyr5sVsoZM9ztMNrJG72dLBmghybF9qP9NI4m6mMACmnyji -fjp6crcepRgGVMSJm54MSd2epQKBgQDDA8hXb4ZGr6U7JG0BIlhrcNJMdMvtXGTd -KP48YlPD3/Q0g+KyyX1/jf3BRVkKHjU8IeyfSqLvGQJPclif3aKNE/xZY7zjAz4U -MZNsGPL02lLsmrf3G/Ac4AwXqP4UZPgk8OqRFZ91f8GaZ9enWFJ9utqxAJbjjEYO -qtSjOBs6iQKBgQCzJNFJ5tIdf85ZhVfLPUsdD3WWwRHyo9DWdFtGRXX9neEf5Hm+ -1fr/5PEtM/167J02CUAfg9foOEn7e8lY3Xn4FYrb5ee1zecI7Twe3mA507YpvfAS -sw7JMlEG7NZOrmFW4ozgwqZFEJaNICxSpnYsiHyMzHbR4Abg495c2yYwWQKBgCuG -VZmGL57pJuSbTaTaKIfaR2V/D+CrJWvi8VNC0tU9z8BEyz0CEXt9kmwncSb79P+1 -xp0KyHC60TQwHi2YBuab1k+RHiBAogNZyUBwFRGnBKkAIx0I5D9dlfVV51df7a45 -AvFctRlBaVFv6cbUxJyBLrwgmIyyyxQM9qZzKEiZAoGBAKQ92HAl9GFWMJ9NYUMQ -0QDTmF9Oy5H5hCpkIHtusklq4jEVipmTiwd30rWIw5VVbEu2l5ZvnAH3zHLji5EZ -IbmLBvhYyGGRVbNYSPIfJrbblSf/ovJy3jfsy9Tozld04mHHartxSv/Fw8fz24bQ -HKLkMrflGoP8HEmnzf8wiUoD ------END PRIVATE KEY----- diff --git a/apps/badgrsocialauth/testfiles/idp.xml b/apps/badgrsocialauth/testfiles/idp.xml deleted file mode 100644 index def7d9a76..000000000 --- a/apps/badgrsocialauth/testfiles/idp.xml +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - MIICsDCCAhmgAwIBAgIJAJrzqSSwmDY9MA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV - BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX - aWRnaXRzIFB0eSBMdGQwHhcNMDkxMDA2MTk0OTQxWhcNMDkxMTA1MTk0OTQxWjBF - MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 - ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB - gQDJg2cms7MqjniT8Fi/XkNHZNPbNVQyMUMXE9tXOdqwYCA1cc8vQdzkihscQMXy - 3iPw2cMggBu6gjMTOSOxECkuvX5ZCclKr8pXAJM5cY6gVOaVO2PdTZcvDBKGbiaN - efiEw5hnoZomqZGp8wHNLAUkwtH9vjqqvxyS/vclc6k2ewIDAQABo4GnMIGkMB0G - A1UdDgQWBBRePsKHKYJsiojE78ZWXccK9K4aJTB1BgNVHSMEbjBsgBRePsKHKYJs - iojE78ZWXccK9K4aJaFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt - U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAJrzqSSw - mDY9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAJSrKOEzHO7TL5cy6 - h3qh+3+JAk8HbGBW+cbX6KBCAw/mzU8flK25vnWwXS3dv2FF3Aod0/S7AWNfKib5 - U/SA9nJaz/mWeF9S0farz9AQFc8/NSzAzaVq7YbM4F6f6N2FRl7GikdXRCed45j6 - mrPzGzk3ECbupFnqyREH3+ZPSdk= - - - - - - - - MIICsDCCAhmgAwIBAgIJAJrzqSSwmDY9MA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV - BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX - aWRnaXRzIFB0eSBMdGQwHhcNMDkxMDA2MTk0OTQxWhcNMDkxMTA1MTk0OTQxWjBF - MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 - ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB - gQDJg2cms7MqjniT8Fi/XkNHZNPbNVQyMUMXE9tXOdqwYCA1cc8vQdzkihscQMXy - 3iPw2cMggBu6gjMTOSOxECkuvX5ZCclKr8pXAJM5cY6gVOaVO2PdTZcvDBKGbiaN - efiEw5hnoZomqZGp8wHNLAUkwtH9vjqqvxyS/vclc6k2ewIDAQABo4GnMIGkMB0G - A1UdDgQWBBRePsKHKYJsiojE78ZWXccK9K4aJTB1BgNVHSMEbjBsgBRePsKHKYJs - iojE78ZWXccK9K4aJaFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt - U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAJrzqSSw - mDY9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAJSrKOEzHO7TL5cy6 - h3qh+3+JAk8HbGBW+cbX6KBCAw/mzU8flK25vnWwXS3dv2FF3Aod0/S7AWNfKib5 - U/SA9nJaz/mWeF9S0farz9AQFc8/NSzAzaVq7YbM4F6f6N2FRl7GikdXRCed45j6 - mrPzGzk3ECbupFnqyREH3+ZPSdk= - - - - - - - - - - - Exempel AB - Exempel AB - Example Co. - http://www.example.com/roland - - - John - Smith - john.smith@example.com - - diff --git a/apps/badgrsocialauth/testfiles/metadata_sp_1.xml b/apps/badgrsocialauth/testfiles/metadata_sp_1.xml deleted file mode 100644 index 602ad6abd..000000000 --- a/apps/badgrsocialauth/testfiles/metadata_sp_1.xml +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - - MIICsDCCAhmgAwIBAgIJAJrzqSSwmDY9MA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV - BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX - aWRnaXRzIFB0eSBMdGQwHhcNMDkxMDA2MTk0OTQxWhcNMDkxMTA1MTk0OTQxWjBF - MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 - ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB - gQDJg2cms7MqjniT8Fi/XkNHZNPbNVQyMUMXE9tXOdqwYCA1cc8vQdzkihscQMXy - 3iPw2cMggBu6gjMTOSOxECkuvX5ZCclKr8pXAJM5cY6gVOaVO2PdTZcvDBKGbiaN - efiEw5hnoZomqZGp8wHNLAUkwtH9vjqqvxyS/vclc6k2ewIDAQABo4GnMIGkMB0G - A1UdDgQWBBRePsKHKYJsiojE78ZWXccK9K4aJTB1BgNVHSMEbjBsgBRePsKHKYJs - iojE78ZWXccK9K4aJaFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt - U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAJrzqSSw - mDY9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAJSrKOEzHO7TL5cy6 - h3qh+3+JAk8HbGBW+cbX6KBCAw/mzU8flK25vnWwXS3dv2FF3Aod0/S7AWNfKib5 - U/SA9nJaz/mWeF9S0farz9AQFc8/NSzAzaVq7YbM4F6f6N2FRl7GikdXRCed45j6 - mrPzGzk3ECbupFnqyREH3+ZPSdk= - - - - - - - - - MIICHzCCAYgCAQEwDQYJKoZIhvcNAQELBQAwWDELMAkGA1UEBhMCenoxCzAJBgNV - BAgMAnp6MQ0wCwYDVQQHDAR6enp6MQ4wDAYDVQQKDAVaenp6ejEOMAwGA1UECwwF - Wnp6enoxDTALBgNVBAMMBHRlc3QwHhcNMTUwNjAyMDc0MzAxWhcNMjUwNTMwMDc0 - MzAxWjBYMQswCQYDVQQGEwJ6ejELMAkGA1UECAwCenoxDTALBgNVBAcMBHp6enox - DjAMBgNVBAoMBVp6enp6MQ4wDAYDVQQLDAVaenp6ejENMAsGA1UEAwwEdGVzdDCB - nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA41tJCTPuG2lirbztuGbBlzbzSipM - EzM+zluWegUaoUjqtlgNHOTQqTJOqw/GdjkxRKJT6IxI3/HVcnfw7P4a4xSkL/ME - IG3VyzedWEyLIHeofoQSTvr84ZdD0+Gk+zNCSqOQC7UuqpOLbMKK1tgZ8Mr7BkgI - p8H3lreLf29Sd5MCAwEAATANBgkqhkiG9w0BAQsFAAOBgQB0EXxy5+hsB7Rid7Gy - CZrAObpaC4nbyPPW/vccFKmEkYtlygEPgky7D9AGsVSaTc/YxPZcanY+vKoRIsiR - 6ZitIUU5b+NnHcdj6289tUQ0iHj5jgVyv8wYHvPntTnqH2S7he0talLER8ITYToh - 2wz3u7waz/GypMeA/suhoEfxew== - - - - - - - - - MIICHzCCAYgCAQEwDQYJKoZIhvcNAQELBQAwWDELMAkGA1UEBhMCenoxCzAJBgNV - BAgMAnp6MQ0wCwYDVQQHDAR6enp6MQ4wDAYDVQQKDAVaenp6ejEOMAwGA1UECwwF - Wnp6enoxDTALBgNVBAMMBHRlc3QwHhcNMTUwNjAyMDc0MjI2WhcNMjUwNTMwMDc0 - MjI2WjBYMQswCQYDVQQGEwJ6ejELMAkGA1UECAwCenoxDTALBgNVBAcMBHp6enox - DjAMBgNVBAoMBVp6enp6MQ4wDAYDVQQLDAVaenp6ejENMAsGA1UEAwwEdGVzdDCB - nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAx3I/NFlP1wbHfRZckJn4z1HX5nnY - QhQ3ekxEJmTTaj/1BvlZBmvgV40SBzH4nP1sT02xoQo7+vHItFAzaJlF2oBXsSxj - aZMGu/gkVbaHP9cYKvskhOjOJ4XArrUnKMTb1jZ+XkkOuot1NLE7/dTILF8ahHU2 - omYNASLnxHN3bnkCAwEAATANBgkqhkiG9w0BAQsFAAOBgQCQam1Oz7iQcD9+OurB - M5a+Hth53m5hbAFuguSvERPCuJ/CfP1+g7CIZN/GnsIsg9QW77NvdOyxjXxzoJJm - okl1qz/qy3FY3mJ0gIUxDyPD9DL3c9/03MDv5YmWsoP+HNqK8QtNJ/JDEOhBr/Eo - /MokRo4gtMNeLF/soveWNoNiUg== - - - - - - - - urn:mace:example.com:saml:roland:sp - - My own SP - - - - - - - - - AB Exempel - - AB Exempel - - http://www.example.org - - - - Roland - Hedberg - tech@eample.com - tech@example.org - +46 70 100 0000 - - - diff --git a/apps/badgrsocialauth/testfiles/metadata_sp_2.xml b/apps/badgrsocialauth/testfiles/metadata_sp_2.xml deleted file mode 100644 index 165d8ede2..000000000 --- a/apps/badgrsocialauth/testfiles/metadata_sp_2.xml +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - - MIICsDCCAhmgAwIBAgIJAJrzqSSwmDY9MA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV - BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX - aWRnaXRzIFB0eSBMdGQwHhcNMDkxMDA2MTk0OTQxWhcNMDkxMTA1MTk0OTQxWjBF - MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 - ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB - gQDJg2cms7MqjniT8Fi/XkNHZNPbNVQyMUMXE9tXOdqwYCA1cc8vQdzkihscQMXy - 3iPw2cMggBu6gjMTOSOxECkuvX5ZCclKr8pXAJM5cY6gVOaVO2PdTZcvDBKGbiaN - efiEw5hnoZomqZGp8wHNLAUkwtH9vjqqvxyS/vclc6k2ewIDAQABo4GnMIGkMB0G - A1UdDgQWBBRePsKHKYJsiojE78ZWXccK9K4aJTB1BgNVHSMEbjBsgBRePsKHKYJs - iojE78ZWXccK9K4aJaFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt - U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAJrzqSSw - mDY9MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAJSrKOEzHO7TL5cy6 - h3qh+3+JAk8HbGBW+cbX6KBCAw/mzU8flK25vnWwXS3dv2FF3Aod0/S7AWNfKib5 - U/SA9nJaz/mWeF9S0farz9AQFc8/NSzAzaVq7YbM4F6f6N2FRl7GikdXRCed45j6 - mrPzGzk3ECbupFnqyREH3+ZPSdk= - - - - - - - - - MIICHzCCAYgCAQEwDQYJKoZIhvcNAQELBQAwWDELMAkGA1UEBhMCenoxCzAJBgNV - BAgMAnp6MQ0wCwYDVQQHDAR6enp6MQ4wDAYDVQQKDAVaenp6ejEOMAwGA1UECwwF - Wnp6enoxDTALBgNVBAMMBHRlc3QwHhcNMTUwNjAyMDc0MzAxWhcNMjUwNTMwMDc0 - MzAxWjBYMQswCQYDVQQGEwJ6ejELMAkGA1UECAwCenoxDTALBgNVBAcMBHp6enox - DjAMBgNVBAoMBVp6enp6MQ4wDAYDVQQLDAVaenp6ejENMAsGA1UEAwwEdGVzdDCB - nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA41tJCTPuG2lirbztuGbBlzbzSipM - EzM+zluWegUaoUjqtlgNHOTQqTJOqw/GdjkxRKJT6IxI3/HVcnfw7P4a4xSkL/ME - IG3VyzedWEyLIHeofoQSTvr84ZdD0+Gk+zNCSqOQC7UuqpOLbMKK1tgZ8Mr7BkgI - p8H3lreLf29Sd5MCAwEAATANBgkqhkiG9w0BAQsFAAOBgQB0EXxy5+hsB7Rid7Gy - CZrAObpaC4nbyPPW/vccFKmEkYtlygEPgky7D9AGsVSaTc/YxPZcanY+vKoRIsiR - 6ZitIUU5b+NnHcdj6289tUQ0iHj5jgVyv8wYHvPntTnqH2S7he0talLER8ITYToh - 2wz3u7waz/GypMeA/suhoEfxew== - - - - - - - - - MIICHzCCAYgCAQEwDQYJKoZIhvcNAQELBQAwWDELMAkGA1UEBhMCenoxCzAJBgNV - BAgMAnp6MQ0wCwYDVQQHDAR6enp6MQ4wDAYDVQQKDAVaenp6ejEOMAwGA1UECwwF - Wnp6enoxDTALBgNVBAMMBHRlc3QwHhcNMTUwNjAyMDc0MjI2WhcNMjUwNTMwMDc0 - MjI2WjBYMQswCQYDVQQGEwJ6ejELMAkGA1UECAwCenoxDTALBgNVBAcMBHp6enox - DjAMBgNVBAoMBVp6enp6MQ4wDAYDVQQLDAVaenp6ejENMAsGA1UEAwwEdGVzdDCB - nzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAx3I/NFlP1wbHfRZckJn4z1HX5nnY - QhQ3ekxEJmTTaj/1BvlZBmvgV40SBzH4nP1sT02xoQo7+vHItFAzaJlF2oBXsSxj - aZMGu/gkVbaHP9cYKvskhOjOJ4XArrUnKMTb1jZ+XkkOuot1NLE7/dTILF8ahHU2 - omYNASLnxHN3bnkCAwEAATANBgkqhkiG9w0BAQsFAAOBgQCQam1Oz7iQcD9+OurB - M5a+Hth53m5hbAFuguSvERPCuJ/CfP1+g7CIZN/GnsIsg9QW77NvdOyxjXxzoJJm - okl1qz/qy3FY3mJ0gIUxDyPD9DL3c9/03MDv5YmWsoP+HNqK8QtNJ/JDEOhBr/Eo - /MokRo4gtMNeLF/soveWNoNiUg== - - - - - - - - urn:mace:example.com:saml:roland:sp - - My own SP - - - - - - - - - AB Exempel - - AB Exempel - - http://www.example.org - - - - Roland - Hedberg - tech@eample.com - tech@example.org - +46 70 100 0000 - - - diff --git a/apps/badgrsocialauth/testfiles/server_conf.py b/apps/badgrsocialauth/testfiles/server_conf.py deleted file mode 100644 index cec1524b5..000000000 --- a/apps/badgrsocialauth/testfiles/server_conf.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -from mainsite import TOP_DIR -from django.conf import settings - -test_files_path = os.path.join(TOP_DIR, 'apps', 'badgrsocialauth', 'testfiles') -attribute_map_dir = os.path.join(test_files_path, 'attributemaps') -# metadata_sp_1 = os.path.join(test_files_path, 'metadata_sp_1.xml') -# metadata_sp_2 = os.path.join(test_files_path, 'metadata_sp_2.xml') -ipd_cert_path = os.path.join(test_files_path, 'idp-test-cert.pem') -ipd_key_path = os.path.join(test_files_path, 'idp-test-key.pem') -idp_xml_path = os.path.join(test_files_path, 'idp.xml') -vo_metadata_path = os.path.join(test_files_path, 'vo_metadata.xml') - -CONFIG = { - "entityid": "urn:mace:example.com:saml:roland:sp", - "name": "urn:mace:example.com:saml:roland:sp", - "description": "My own SP", - "service": { - "sp": { - "endpoints": { - "assertion_consumer_service": [ - "http://lingon.catalogix.se:8087/"], - }, - "required_attributes": ["surName", "givenName", "mail"], - "optional_attributes": ["title"], - "idp": ["urn:mace:example.com:saml:roland:idp"], - "requested_attributes": [ - { - "name": "http://eidas.europa.eu/attributes/naturalperson/DateOfBirth", - "required": False, - }, - { - "friendly_name": "PersonIdentifier", - "required": True, - }, - { - "friendly_name": "PlaceOfBirth", - }, - ], - # 'authn_requests_signed': True, - # 'logout_requests_signed': True, - # 'want_assertions_signed': True, - } - }, - "debug": 1, - "key_file": ipd_key_path, - "cert_file": ipd_cert_path, - # "encryption_keypairs": [{"key_file": full_path("test_1.key"), "cert_file": full_path("test_1.crt")}, - # {"key_file": full_path("test_2.key"), "cert_file": full_path("test_2.crt")}], - # "ca_certs": full_path("cacerts.txt"), - "xmlsec_binary": getattr(settings, 'XMLSEC_BINARY_PATH', None), - "metadata": { - "local": [idp_xml_path, vo_metadata_path], - }, - "virtual_organization": { - "urn:mace:example.com:it:tek": { - "nameid_format": "urn:oid:1.3.6.1.4.1.1466.115.121.1.15-NameID", - "common_identifier": "umuselin", - } - }, - - "subject_data": "subject_data.db", - "accepted_time_diff": 60, - "attribute_map_dir": attribute_map_dir, - "valid_for": 6, - "organization": { - "name": ("AB Exempel", "se"), - "display_name": ("AB Exempel", "se"), - "url": "http://www.example.org", - }, - "contact_person": [{ - "given_name": "Roland", - "sur_name": "Hedberg", - "telephone_number": "+46 70 100 0000", - "email_address": ["tech@eample.com", - "tech@example.org"], - "contact_type": "technical" - }, - ], - # "logger": { - # "rotating": { - # "filename": full_path("sp.log"), - # "maxBytes": 100000, - # "backupCount": 5, - # }, - # "loglevel": "info", - # } -} diff --git a/apps/badgrsocialauth/testfiles/vo_metadata.xml b/apps/badgrsocialauth/testfiles/vo_metadata.xml deleted file mode 100644 index 3f1e75177..000000000 --- a/apps/badgrsocialauth/testfiles/vo_metadata.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - urn:mace:example.com:saml:aa - - - urn:mace:example.com:saml:idp - - - - diff --git a/apps/badgrsocialauth/tests/__init__.py b/apps/badgrsocialauth/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/badgrsocialauth/tests/test_saml2.py b/apps/badgrsocialauth/tests/test_saml2.py deleted file mode 100644 index 10304f940..000000000 --- a/apps/badgrsocialauth/tests/test_saml2.py +++ /dev/null @@ -1,710 +0,0 @@ -import base64 -import json -import os - -from contextlib import closing -from urllib.parse import urlparse, parse_qs - -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from django.utils.timezone import datetime -from django.shortcuts import reverse -from django.test import override_settings -from django.test.client import RequestFactory - -from badgrsocialauth.models import Saml2Configuration, Saml2Account -from badgrsocialauth.views import auto_provision, saml2_client_for, create_saml_config_for -from badgrsocialauth.utils import set_session_authcode, set_session_badgr_app, userdata_from_saml_assertion - -from badgeuser.models import CachedEmailAddress, BadgeUser - -from mainsite.models import BadgrApp -from mainsite.tests import BadgrTestCase -from mainsite import TOP_DIR -from mainsite.utils import set_url_query_params - -from saml2 import config, saml, BINDING_SOAP, BINDING_HTTP_REDIRECT, BINDING_HTTP_POST -from saml2.authn_context import authn_context_class_ref - -# TODO: Revert to library code once library is fixed for python3 -# from saml2.metadata import create_metadata_string -from badgrsocialauth.saml2_utils import create_metadata_string - -from saml2.saml import AuthnContext, AuthnStatement, NAME_FORMAT_URI, NAMEID_FORMAT_PERSISTENT, \ - NAME_FORMAT_BASIC, AUTHN_PASSWORD_PROTECTED -from saml2.server import Server -from saml2.s_utils import MissingValue - - -class SAML2Tests(BadgrTestCase): - def setUp(self): - super(SAML2Tests, self).setUp() - self.test_files_path = os.path.join(TOP_DIR, 'apps', 'badgrsocialauth', 'testfiles') - self.idp_metadata_for_sp_config_path = os.path.join(self.test_files_path, 'idp-metadata-for-saml2configuration.xml') - - with open(self.idp_metadata_for_sp_config_path, 'r') as f: - metadata_xml = f.read() - self.config = Saml2Configuration.objects.create( - metadata_conf_url="http://example.com", - slug="saml2.test", - cached_metadata=metadata_xml - ) - self.badgr_app = BadgrApp.objects.create( - ui_login_redirect="https://example.com", - ui_signup_failure_redirect='https://example.com/fail' - ) - self.badgr_app.is_default = True - self.badgr_app.save() - self.ipd_cert_path = os.path.join(self.test_files_path, 'idp-test-cert.pem') - self.ipd_key_path = os.path.join(self.test_files_path, 'idp-test-key.pem') - self.sp_acs_location = 'http://localhost:8000/account/saml2/{}/acs/'.format(self.config.slug) - - def _skip_if_xmlsec_binary_missing(self): - xmlsec_binary_path = getattr(settings, 'XMLSEC_BINARY_PATH', None) - if xmlsec_binary_path is None: - self.skipTest("SKIPPING: In order to test XML Signing, XMLSEC_BINARY_PATH to xmlsec1 must be configured.") - - def _initiate_login(self, idp_name, badgr_app, user=None): - # Sets a BadgrApp in the session for later redirect, allows setting of a session authcode - url = set_url_query_params(reverse('socialaccount_login'), provider=idp_name) - - if user is not None: - self.client.force_authenticate(user=user) - preflight_response = self.client.get( - reverse('v2_api_user_socialaccount_connect') + '?provider={}'.format(idp_name) - ) - location = urlparse(preflight_response.data['result']['url']) - url = '?'.join([location.path, location.query]) # strip server info from location - - return self.client.get(url, HTTP_REFERER=badgr_app.ui_login_redirect) - - - def test_signed_authn_request_option_creates_signed_metadata(self): - self._skip_if_xmlsec_binary_missing() - - self.config.use_signed_authn_request = True - self.config.save() - with override_settings( - SAML_KEY_FILE=self.ipd_key_path, - SAML_CERT_FILE=self.ipd_cert_path): - saml_client, config = saml2_client_for(self.config) - self.assertTrue(saml_client.authn_requests_signed) - self.assertNotEqual(saml_client.sec.sec_backend, None) - - def test_signed_authn_request_option_returns_self_posting_form_populated_with_signed_metadata(self): - self._skip_if_xmlsec_binary_missing() - self.config.use_signed_authn_request = True - self.config.save() - with override_settings( - SAML_KEY_FILE=self.ipd_key_path, - SAML_CERT_FILE=self.ipd_cert_path): - authn_request = self.config - url = '/account/sociallogin?provider=' + authn_request.slug - redirect_url = '/account/saml2/' + authn_request.slug + '/' - response = self.client.get(url, follow=True) - intermediate_url, intermediate_url_status = response.redirect_chain[0] - - # login redirect to saml2 login - self.assertEqual(intermediate_url, redirect_url) - self.assertEqual(intermediate_url_status, 302) - # self populated form generated with metadata file from self.ipd_metadata_path - self.assertEqual(response.status_code, 200) - # changing attribute location of element md:SingleSignOnService necessitates updating this value - self.assertIsNot( - response.content.find(b'
    '), -1) - self.assertIsNot( - response.content.find(b'[\w\.\-]+)/$', saml2_render_or_redirect, name='saml2login'), - url(r'^saml2/(?P[\w\.\-]+)/acs/', assertion_consumer_service, name='assertion_consumer_service'), - url(r'^saml2/(?P[\w\.\-]+)/metadata$', saml2_sp_metadata, name='saml2_sp_metadata'), - url(r'^saml2/loginfailure$', SamlFailureRedirect.as_view(permanent=False), name='saml2_failure'), - url(r'^saml2/loginsuccess$', SamlSuccessRedirect.as_view(permanent=False), name='saml2_success'), - url(r'^saml2/emailexists$', SamlEmailExistsRedirect.as_view(permanent=False), name='saml2_emailexists'), - url(r'^saml2/provision$', SamlProvisionRedirect.as_view(permanent=False), name='saml2_provision'), + re_path( + r"^saml2/(?P[\w\.\-]+)/$", saml2_render_or_redirect, name="saml2login" + ), + re_path( + r"^saml2/(?P[\w\.\-]+)/acs/", + assertion_consumer_service, + name="assertion_consumer_service", + ), + re_path( + r"^saml2/(?P[\w\.\-]+)/metadata$", + saml2_sp_metadata, + name="saml2_sp_metadata", + ), + re_path( + r"^saml2/loginfailure$", + SamlFailureRedirect.as_view(permanent=False), + name="saml2_failure", + ), + re_path( + r"^saml2/loginsuccess$", + SamlSuccessRedirect.as_view(permanent=False), + name="saml2_success", + ), + re_path( + r"^saml2/emailexists$", + SamlEmailExistsRedirect.as_view(permanent=False), + name="saml2_emailexists", + ), + re_path( + r"^saml2/provision$", + SamlProvisionRedirect.as_view(permanent=False), + name="saml2_provision", + ), ] -provider_list = providers.registry.get_list() -configured_providers = getattr(settings, 'SOCIALACCOUNT_PROVIDERS', dict()).keys() +provider_list = providers.registry.provider_map.values() +configured_providers = getattr(settings, "SOCIALACCOUNT_PROVIDERS", dict()).keys() for provider in [p for p in provider_list if p.id in configured_providers]: try: - prov_mod =importlib.import_module(provider.get_package() + '.urls') + prov_mod = importlib.import_module(provider.get_package() + ".urls") except ImportError: - logging.getLogger(__name__).warning( - 'url import failed for %s socialaccount provider' % provider.id, - exc_info=True) + logging.getLogger("Badgr.Events").warning( + "url import failed for %s socialaccount provider" % provider.id, + exc_info=True, + ) continue - prov_urlpatterns = getattr(prov_mod, 'urlpatterns', None) + prov_urlpatterns = getattr(prov_mod, "urlpatterns", None) if prov_urlpatterns: urlpatterns += prov_urlpatterns diff --git a/apps/badgrsocialauth/utils.py b/apps/badgrsocialauth/utils.py index 7d0589171..8e67fc850 100644 --- a/apps/badgrsocialauth/utils.py +++ b/apps/badgrsocialauth/utils.py @@ -11,11 +11,11 @@ def get_session_verification_email(request): - return request.session.get('verification_email', None) + return request.session.get("verification_email", None) def set_session_verification_email(request, verification_email): - request.session['verification_email'] = verification_email + request.session["verification_email"] = verification_email def get_session_badgr_app(request): @@ -25,23 +25,23 @@ def get_session_badgr_app(request): All usages should expressly handle None return case. """ try: - if request and hasattr(request, 'session'): - return BadgrApp.objects.get(pk=request.session.get('badgr_app_pk', -1)) + if request and hasattr(request, "session"): + return BadgrApp.objects.get(pk=request.session.get("badgr_app_pk", -1)) except BadgrApp.DoesNotExist: return None def set_session_badgr_app(request, badgr_app): - request.session['badgr_app_pk'] = badgr_app.pk + request.session["badgr_app_pk"] = badgr_app.pk def get_session_authcode(request): if request is not None: - return request.session.get('badgr_authcode', None) + return request.session.get("badgr_authcode", None) def set_session_authcode(request, authcode): - request.session['badgr_authcode'] = authcode + request.session["badgr_authcode"] = authcode def get_verified_user(auth_token): @@ -53,19 +53,21 @@ def get_verified_user(auth_token): def redirect_to_frontend_error_toast(request, message): badgr_app = BadgrApp.objects.get_current(request) redirect_url = "{url}?authError={message}".format( - url=badgr_app.ui_login_redirect, - message=urllib.parse.quote(message)) + url=badgr_app.ui_login_redirect, message=urllib.parse.quote(message) + ) return HttpResponseRedirect(redirect_to=redirect_url) def generate_provider_identifier(sociallogin=None, socialaccount=None): if socialaccount is None: socialaccount = sociallogin.account - if socialaccount.provider == 'twitter': - return 'https://twitter.com/{}'.format(socialaccount.extra_data['screen_name'].lower()) + if socialaccount.provider == "twitter": + return "https://twitter.com/{}".format( + socialaccount.extra_data["screen_name"].lower() + ) -def userdata_from_saml_assertion(claims, data_field='email', config=None, many=False): +def userdata_from_saml_assertion(claims, data_field="email", config=None, many=False): """ From a set of SAML claims processed into a dict, extract a claim for the desired property based on system settings and the specific Saml2Config, if that claim exists. Raise ValidationError if missing @@ -76,23 +78,37 @@ def userdata_from_saml_assertion(claims, data_field='email', config=None, many=F """ config_settings = config.custom_settings_data configured_keys = { - 'email': set(settings.SAML_EMAIL_KEYS) | set(config_settings.get('email', [])), - 'first_name': set(settings.SAML_FIRST_NAME_KEYS) | set(config_settings.get('first_name', [])), - 'last_name': set(settings.SAML_LAST_NAME_KEYS) | set(config_settings.get('last_name', [])) + "email": set(settings.SAML_EMAIL_KEYS) | set(config_settings.get("email", [])), + "first_name": set(settings.SAML_FIRST_NAME_KEYS) + | set(config_settings.get("first_name", [])), + "last_name": set(settings.SAML_LAST_NAME_KEYS) + | set(config_settings.get("last_name", [])), } if len(configured_keys[data_field] & set(claims.keys())) == 0: - raise ValidationError('Missing {} in SAML assertions, received {}'.format(data_field, list(claims.keys()))) - - found = [list_of(claims.get(key)) for key in configured_keys[data_field] if key in claims] + raise ValidationError( + "Missing {} in SAML assertions, received {}".format( + data_field, list(claims.keys()) + ) + ) + + found = [ + list_of(claims.get(key)) for key in configured_keys[data_field] if key in claims + ] found = [claim for sublist in found for claim in sublist] return found if many else found[0] -DEFAULT_VALID_CUSTOM_SETTINGS_KEYS = ('email', 'first_name', 'last_name',) +DEFAULT_VALID_CUSTOM_SETTINGS_KEYS = ( + "email", + "first_name", + "last_name", +) -def custom_settings_filtered_values(input_data, valid_keys=DEFAULT_VALID_CUSTOM_SETTINGS_KEYS): +def custom_settings_filtered_values( + input_data, valid_keys=DEFAULT_VALID_CUSTOM_SETTINGS_KEYS +): def filter_value(value): if len([v for v in list_of(value) if not isinstance(v, str)]): return list() @@ -102,10 +118,14 @@ def filter_value(value): try: data = json.loads(input_data) filtered_data = { - 'email': filter_value(data.get('email')), - 'first_name': filter_value(data.get('first_name')), - 'last_name': filter_value(data.get('last_name')) + "email": filter_value(data.get("email")), + "first_name": filter_value(data.get("first_name")), + "last_name": filter_value(data.get("last_name")), } return json.dumps(filtered_data, indent=2) - except (TypeError, ValueError, AttributeError,): - return '{}' + except ( + TypeError, + ValueError, + AttributeError, + ): + return "{}" diff --git a/apps/badgrsocialauth/v1_api_urls.py b/apps/badgrsocialauth/v1_api_urls.py index 05e665983..aee2376a6 100644 --- a/apps/badgrsocialauth/v1_api_urls.py +++ b/apps/badgrsocialauth/v1_api_urls.py @@ -1,9 +1,25 @@ -from django.conf.urls import url +from django.urls import re_path -from badgrsocialauth.api import BadgrSocialAccountList, BadgrSocialAccountDetail, BadgrSocialAccountConnect +from badgrsocialauth.api import ( + BadgrSocialAccountList, + BadgrSocialAccountDetail, + BadgrSocialAccountConnect, +) urlpatterns = [ - url(r'^socialaccounts$', BadgrSocialAccountList.as_view(), name='v1_api_user_socialaccount_list'), - url(r'^socialaccounts/connect$', BadgrSocialAccountConnect.as_view(), name='v1_api_user_socialaccount_connect'), - url(r'^socialaccounts/(?P[^/]+)$', BadgrSocialAccountDetail.as_view(), name='v1_api_user_socialaccount_detail') + re_path( + r"^socialaccounts$", + BadgrSocialAccountList.as_view(), + name="v1_api_user_socialaccount_list", + ), + re_path( + r"^socialaccounts/connect$", + BadgrSocialAccountConnect.as_view(), + name="v1_api_user_socialaccount_connect", + ), + re_path( + r"^socialaccounts/(?P[^/]+)$", + BadgrSocialAccountDetail.as_view(), + name="v1_api_user_socialaccount_detail", + ), ] diff --git a/apps/badgrsocialauth/v2_api_urls.py b/apps/badgrsocialauth/v2_api_urls.py index aa781c9b1..511da5052 100644 --- a/apps/badgrsocialauth/v2_api_urls.py +++ b/apps/badgrsocialauth/v2_api_urls.py @@ -1,9 +1,25 @@ -from django.conf.urls import url +from django.urls import re_path -from badgrsocialauth.api import BadgrSocialAccountList, BadgrSocialAccountDetail, BadgrSocialAccountConnect +from badgrsocialauth.api import ( + BadgrSocialAccountList, + BadgrSocialAccountDetail, + BadgrSocialAccountConnect, +) urlpatterns = [ - url(r'^socialaccounts$', BadgrSocialAccountList.as_view(), name='v2_api_user_socialaccount_list'), - url(r'^socialaccounts/connect$', BadgrSocialAccountConnect.as_view(), name='v2_api_user_socialaccount_connect'), - url(r'^socialaccounts/(?P[^/]+)$', BadgrSocialAccountDetail.as_view(), name='v2_api_user_socialaccount_detail') + re_path( + r"^socialaccounts$", + BadgrSocialAccountList.as_view(), + name="v2_api_user_socialaccount_list", + ), + re_path( + r"^socialaccounts/connect$", + BadgrSocialAccountConnect.as_view(), + name="v2_api_user_socialaccount_connect", + ), + re_path( + r"^socialaccounts/(?P[^/]+)$", + BadgrSocialAccountDetail.as_view(), + name="v2_api_user_socialaccount_detail", + ), ] diff --git a/apps/badgrsocialauth/views.py b/apps/badgrsocialauth/views.py index 3de8640b4..c3243acdd 100644 --- a/apps/badgrsocialauth/views.py +++ b/apps/badgrsocialauth/views.py @@ -1,24 +1,24 @@ import json -import urllib.request, urllib.parse, urllib.error +import urllib.request +import urllib.parse +import urllib.error import urllib.parse # TODO: Revert to library code once library is fixed for python3 # from saml2.metadata import create_metadata_string from .saml2_utils import create_metadata_string -from allauth.account.adapter import get_adapter from allauth.socialaccount.providers.base import AuthProcess from django.contrib.auth import logout from django.core.exceptions import ValidationError, ImproperlyConfigured from django.urls import reverse, NoReverseMatch from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.views.generic import RedirectView -from django.shortcuts import redirect, render_to_response +from django.shortcuts import redirect from django.views.decorators.csrf import csrf_exempt from rest_framework.exceptions import AuthenticationFailed -from rest_framework import status -from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT +from saml2 import BINDING_HTTP_REDIRECT import logging @@ -26,13 +26,21 @@ import base64 -from badgeuser.authcode import authcode_for_accesstoken, accesstoken_for_authcode, encrypt_authcode, decrypt_authcode +from badgeuser.authcode import ( + authcode_for_accesstoken, + accesstoken_for_authcode, + encrypt_authcode, + decrypt_authcode, +) from badgeuser.models import CachedEmailAddress, BadgeUser from badgrsocialauth.models import Saml2Account, Saml2Configuration -from badgrsocialauth.utils import (set_session_badgr_app, get_session_authcode, - get_session_verification_email, set_session_authcode, - userdata_from_saml_assertion, - ) +from badgrsocialauth.utils import ( + set_session_badgr_app, + get_session_authcode, + get_session_verification_email, + set_session_authcode, + userdata_from_saml_assertion, +) from django.conf import settings from mainsite.models import BadgrApp from mainsite.utils import set_url_query_params @@ -45,7 +53,7 @@ from saml2.config import Config as Saml2Config from mainsite.models import AccessTokenProxy -logger = logging.getLogger(__name__) +logger = logging.getLogger("Badgr.Events") class BadgrSocialLogin(RedirectView): @@ -59,28 +67,31 @@ def get(self, request, *args, **kwargs): return HttpResponseForbidden(e.detail) def get_redirect_url(self): - provider_name = self.request.GET.get('provider', None) + provider_name = self.request.GET.get("provider", None) if provider_name is None: - raise ValidationError('No provider specified') + raise ValidationError("No provider specified") badgr_app = BadgrApp.objects.get_current(request=self.request) if badgr_app is not None: set_session_badgr_app(self.request, badgr_app) else: - raise ValidationError('Unable to save BadgrApp in session') + raise ValidationError("Unable to save BadgrApp in session") - self.request.session['source'] = self.request.GET.get('source', None) + self.request.session["source"] = self.request.GET.get("source", None) try: - if 'saml2' in provider_name: - redirect_url = reverse('saml2login', args=[provider_name]) - self.request.session['idp_name'] = provider_name + if "saml2" in provider_name: + redirect_url = reverse("saml2login", args=[provider_name]) + self.request.session["idp_name"] = provider_name else: - redirect_url = reverse('{}_login'.format(provider_name)) - except (NoReverseMatch, TypeError,): - raise ValidationError('No {} provider found'.format(provider_name)) - - authcode = self.request.GET.get('authCode', None) + redirect_url = reverse("{}_login".format(provider_name)) + except ( + NoReverseMatch, + TypeError, + ): + raise ValidationError("No {} provider found".format(provider_name)) + + authcode = self.request.GET.get("authCode", None) if authcode is not None: set_session_authcode(self.request, authcode) return set_url_query_params(redirect_url, process=AuthProcess.CONNECT) @@ -99,13 +110,17 @@ class BadgrSocialEmailExists(RedirectView): def get_redirect_url(self): badgr_app = BadgrApp.objects.get_current(self.request) if badgr_app is not None: - verification_email = self.request.session.get('verification_email', '') - provider = self.request.session.get('socialaccount_sociallogin', {}).get('account', {}).get('provider', '') + verification_email = self.request.session.get("verification_email", "") + provider = ( + self.request.session.get("socialaccount_sociallogin", {}) + .get("account", {}) + .get("provider", "") + ) return set_url_query_params( badgr_app.ui_signup_failure_redirect, - authError='An account already exists with provided email address', - email=base64.urlsafe_b64encode(verification_email.encode('utf-8')), - socialAuthSlug=provider + authError="An account already exists with provided email address", + email=base64.urlsafe_b64encode(verification_email.encode("utf-8")), + socialAuthSlug=provider, ) @@ -115,14 +130,15 @@ def get_redirect_url(self): verification_email = get_session_verification_email(self.request) if verification_email is not None: - verification_email = urllib.parse.quote(verification_email).encode('utf-8') + verification_email = urllib.parse.quote(verification_email).encode("utf-8") else: - verification_email = b'' + verification_email = b"" if badgr_app is not None: base_64_email = base64.urlsafe_b64encode(verification_email) return urllib.parse.urljoin( - badgr_app.ui_signup_success_redirect.rstrip('/') + '/', base_64_email.decode('utf-8') + badgr_app.ui_signup_success_redirect.rstrip("/") + "/", + base_64_email.decode("utf-8"), ) @@ -138,10 +154,12 @@ def get_redirect_url(self): SAML2 Authentication Flow """ + + def saml2_client_for(idp_name=None): - ''' + """ Given the name of an Identity Provider look up the Saml2Configuration and build a SAML Client. Return these. - ''' + """ config = Saml2Configuration.objects.get(slug=idp_name) saml_config = create_saml_config_for(config) spConfig = Saml2Config() @@ -163,51 +181,54 @@ def create_saml_config_for(config): r = requests.get(config.metadata_conf_url) metadata = r.text - origin = getattr(settings, 'HTTP_ORIGIN') - https_acs_url = origin + reverse('assertion_consumer_service', args=[config.slug]) + origin = getattr(settings, "HTTP_ORIGIN") + https_acs_url = origin + reverse("assertion_consumer_service", args=[config.slug]) setting = { - 'metadata': { - 'inline': [metadata], + "metadata": { + "inline": [metadata], }, - 'entityid': https_acs_url, - 'service': { - 'sp': { - 'endpoints': { - 'assertion_consumer_service': [ - (https_acs_url, BINDING_HTTP_POST) - ], + "entityid": https_acs_url, + "service": { + "sp": { + "endpoints": { + "assertion_consumer_service": [(https_acs_url, BINDING_HTTP_POST)], }, # Don't verify that the incoming requests originate from us via # the built-in cache for authn request ids in pysaml2 - 'allow_unsolicited': True, - 'authn_requests_signed': should_sign_authn_request, - 'logout_requests_signed': True, - 'want_assertions_signed': True, - 'want_response_signed': False, + "allow_unsolicited": True, + "authn_requests_signed": should_sign_authn_request, + "logout_requests_signed": True, + "want_assertions_signed": True, + "want_response_signed": False, }, }, } if should_sign_authn_request: - key_file = getattr(settings, 'SAML_KEY_FILE', None) + key_file = getattr(settings, "SAML_KEY_FILE", None) if key_file is None: raise ImproperlyConfigured( - "Signed Authn request requires the path to a PEM formatted file containing the certificates private key") + "Signed Authn request requires the path to a PEM formatted file " + "containing the certificates private key" + ) - cert_file = getattr(settings, 'SAML_CERT_FILE', None) + cert_file = getattr(settings, "SAML_CERT_FILE", None) if cert_file is None: raise ImproperlyConfigured( - "Signed Authn request requires the path to a PEM formatted file containing the certificates public key") + "Signed Authn request requires the path to a PEM formatted file " + "containing the certificates public key" + ) - xmlsec_binary_path = getattr(settings, 'XMLSEC_BINARY_PATH', None) + xmlsec_binary_path = getattr(settings, "XMLSEC_BINARY_PATH", None) if xmlsec_binary_path is None: raise ImproperlyConfigured( - "Signed Authn request requires the path to the xmlsec binary") + "Signed Authn request requires the path to the xmlsec binary" + ) - setting['key_file'] = key_file - setting['cert_file'] = cert_file + setting["key_file"] = key_file + setting["cert_file"] = cert_file # requires xmlsec binaries per https://pysaml2.readthedocs.io/en/latest/examples/sp.html - setting['xmlsec_binary'] = xmlsec_binary_path + setting["xmlsec_binary"] = xmlsec_binary_path return setting @@ -217,7 +238,9 @@ def saml2_sp_metadata(request, idp_name): spConfig = Saml2Config() spConfig.load(saml_config) - metadata = create_metadata_string('', config=spConfig, sign=config.use_signed_authn_request) + metadata = create_metadata_string( + "", config=spConfig, sign=config.use_signed_authn_request + ) return HttpResponse(metadata, content_type="text/xml") @@ -228,33 +251,32 @@ def saml2_render_or_redirect(request, idp_name): if config.use_signed_authn_request: reqid, info = saml_client.prepare_for_authenticate( - binding=BINDING_HTTP_POST, - sign=True + binding=BINDING_HTTP_POST, sign=True ) - response = HttpResponse(info['data']) + response = HttpResponse(info["data"]) else: - reqid, info = saml_client.prepare_for_authenticate(binding=BINDING_HTTP_REDIRECT) + reqid, info = saml_client.prepare_for_authenticate( + binding=BINDING_HTTP_REDIRECT + ) redirect_url = None # Select the IdP URL to send the AuthN request to - for key, value in info['headers']: - if key == 'Location': + for key, value in info["headers"]: + if key == "Location": redirect_url = value response = redirect(redirect_url) # Read http://stackoverflow.com/a/5494469 and # http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf # We set those headers here as a "belt and suspenders" approach. - response['Cache-Control'] = 'no-cache, no-store' - response['Pragma'] = 'no-cache' + response["Cache-Control"] = "no-cache, no-store" + response["Pragma"] = "no-cache" badgr_app = BadgrApp.objects.get_current(request) - request.session['badgr_app_pk'] = badgr_app.pk + request.session["badgr_app_pk"] = badgr_app.pk return response def saml2_fail(**kwargs): - return set_url_query_params( - reverse('saml2_failure'), **kwargs - ) + return set_url_query_params(reverse("saml2_failure"), **kwargs) def redirect_to_login_with_token(request, accesstoken): @@ -271,7 +293,7 @@ def redirect_to_login_with_token(request, accesstoken): class SamlSuccessRedirect(RedirectView): def get_redirect_url(self, *args, **kwargs): - authcode = self.request.GET.get('authcode', get_session_authcode(self.request)) + authcode = self.request.GET.get("authcode", get_session_authcode(self.request)) if not authcode: return saml2_fail(authError="Could not complete Saml login") @@ -285,27 +307,41 @@ def get_redirect_url(self, *args, **kwargs): accesstoken = accesstoken_for_authcode(authcode) try: - data = json.loads(decrypt_authcode(self.request.GET['request_id'])) - client, config = saml2_client_for(data['idp_name']) - emails = data['emails'] - first_name = data['first_name'] - last_name = data['last_name'] - - except (TypeError, ValueError, AttributeError, KeyError, Saml2Configuration.DoesNotExist,) as e: + data = json.loads(decrypt_authcode(self.request.GET["request_id"])) + client, config = saml2_client_for(data["idp_name"]) + emails = data["emails"] + first_name = data["first_name"] + last_name = data["last_name"] + + except ( + TypeError, + ValueError, + AttributeError, + KeyError, + Saml2Configuration.DoesNotExist, + ): return saml2_fail(authError="Could not process Saml2 Response.") if CachedEmailAddress.cached.filter(email__in=emails).count() > 0: - return saml2_fail(authError="Saml2 Response Processing interrupted. Email exists.") + return saml2_fail( + authError="Saml2 Response Processing interrupted. Email exists." + ) if accesstoken is not None and not accesstoken.is_expired(): account_uuid = emails[0] - Saml2Account.objects.create(config=config, user=accesstoken.user, uuid=account_uuid) + Saml2Account.objects.create( + config=config, user=accesstoken.user, uuid=account_uuid + ) for e in emails: - CachedEmailAddress.objects.create(email=e, user=accesstoken.user, verified=True, primary=False) + CachedEmailAddress.objects.create( + email=e, user=accesstoken.user, verified=True, primary=False + ) return redirect_to_login_with_token(self.request, accesstoken) # Email does not exist, nor does existing account. auto-provision new account and log in - return redirect_user_to_login(saml2_new_account(emails, config, first_name, last_name, self.request)) + return redirect_user_to_login( + saml2_new_account(emails, config, first_name, last_name, self.request) + ) class SamlFailureRedirect(RedirectView): @@ -322,98 +358,129 @@ def get_redirect_url(self, *args, **kwargs): # Override: user has an appropriate authcode for the return flight to the UI authcode = get_session_authcode(self.request) - email = self.request.GET.get('email') - idp_name = self.request.session.get('idp_name') + email = self.request.GET.get("email") + idp_name = self.request.session.get("idp_name") if not email or not authcode or not idp_name: return saml2_fail( - authError="Could not associate SAML response with initial request", email=email, socialAuthSlug=idp_name + authError="Could not associate SAML response with initial request", + email=email, + socialAuthSlug=idp_name, ) saml_client, config = saml2_client_for(idp_name) - decoded_email = base64.urlsafe_b64decode(email).decode('utf-8') + decoded_email = base64.urlsafe_b64decode(email).decode("utf-8") existing_email = CachedEmailAddress.cached.get(email=decoded_email) token = accesstoken_for_authcode(authcode) - if token is not None and not token.is_expired() and token.user == existing_email.user: - saml2_account = Saml2Account.objects.create(config=config, user=existing_email.user, uuid=decoded_email) + if ( + token is not None + and not token.is_expired() + and token.user == existing_email.user + ): + saml2_account = Saml2Account.objects.create( + config=config, user=existing_email.user, uuid=decoded_email + ) return redirect_user_to_login(saml2_account.user) elif token is not None and token.is_expired(): return saml2_fail( - authError='Request is expired. Please try again.', + authError="Request is expired. Please try again.", email=email, - socialAuthSlug=idp_name + socialAuthSlug=idp_name, ) # Fail: user does not have an appropriate authcode return saml2_fail( - authError='An account already exists with provided email address', + authError="An account already exists with provided email address", email=email, - socialAuthSlug=idp_name + socialAuthSlug=idp_name, ) @csrf_exempt def assertion_consumer_service(request, idp_name): saml_client, config = saml2_client_for(idp_name) - saml_response = request.POST.get('SAMLResponse') + saml_response = request.POST.get("SAMLResponse") if saml_response is None: logger.error( - 'assertion_consumer_service: No SAMLResponse was sent to the system by the identity provider.' + "assertion_consumer_service: No SAMLResponse was sent to the system by the identity provider." + ) + return redirect( + reverse( + "saml2_failure", + kwargs=dict( + authError="No SAMLResponse was sent to the system by the identity provider" + ), + ) + ) + + saml_info = ( + "assertion_consumer_service: saml_client entityid:{}, reponse: {}".format( + saml_client.config.entityid, saml_response ) - return redirect(reverse( - 'saml2_failure', - kwargs=dict(authError="No SAMLResponse was sent to the system by the identity provider") - )) - - saml_info = "assertion_consumer_service: saml_client entityid:{}, reponse: {}".format( - saml_client.config.entityid, - saml_response ) logger.info(saml_info) authn_response = saml_client.parse_authn_request_response( - saml_response, - entity.BINDING_HTTP_POST) + saml_response, entity.BINDING_HTTP_POST + ) if authn_response is None: logger.error( - 'assertion_consumer_service: SAMLResponse processing failed, resulting in no parsed data.' + "assertion_consumer_service: SAMLResponse processing failed, resulting in no parsed data." + ) + return redirect( + reverse( + "saml2_failure", + kwargs=dict(authError="Could not process SAMLResponse."), + ) ) - return redirect(reverse( - 'saml2_failure', - kwargs=dict(authError="Could not process SAMLResponse.") - )) authn_response.get_identity() - emails = userdata_from_saml_assertion(authn_response.ava, 'email', config, many=True) - first_name = userdata_from_saml_assertion(authn_response.ava, 'first_name', config) - last_name = userdata_from_saml_assertion(authn_response.ava, 'last_name', config) + emails = userdata_from_saml_assertion( + authn_response.ava, "email", config, many=True + ) + first_name = userdata_from_saml_assertion(authn_response.ava, "first_name", config) + last_name = userdata_from_saml_assertion(authn_response.ava, "last_name", config) return auto_provision(request, emails, first_name, last_name, config) def auto_provision(request, emails, first_name, last_name, config): # Get/Create account and redirect with token or with error message try: - saml2_account = Saml2Account.objects.filter(uuid__in=emails, config=config).first() + saml2_account = Saml2Account.objects.filter( + uuid__in=emails, config=config + ).first() except Saml2Account.MultipleObjectsReturned: - return redirect(reverse( - 'saml2_failure', - kwargs=dict(authError="Multiple SAML accounts found.") - )) + return redirect( + reverse( + "saml2_failure", kwargs=dict(authError="Multiple SAML accounts found.") + ) + ) if saml2_account: - if CachedEmailAddress.objects.exclude(user=saml2_account.user).filter(email__in=emails).count() > 0: - return redirect(reverse( - 'saml2_failure', - kwargs=dict(authError="Multiple accounts using provided emails.") - )) - existing_emails = CachedEmailAddress.objects.filter(user=saml2_account.user).values_list('email', flat=True) + if ( + CachedEmailAddress.objects.exclude(user=saml2_account.user) + .filter(email__in=emails) + .count() + > 0 + ): + return redirect( + reverse( + "saml2_failure", + kwargs=dict(authError="Multiple accounts using provided emails."), + ) + ) + existing_emails = CachedEmailAddress.objects.filter( + user=saml2_account.user + ).values_list("email", flat=True) processed_emails = [ee.lower() for ee in existing_emails] unused = [e for e in emails if e.lower() not in processed_emails] for e in unused: - CachedEmailAddress.objects.create(email=e, user=saml2_account.user, verified=True) + CachedEmailAddress.objects.create( + email=e, user=saml2_account.user, verified=True + ) return redirect(redirect_user_to_login(saml2_account.user)) # Check if any/all of the claimed emails already exist as verified @@ -423,8 +490,8 @@ def auto_provision(request, emails, first_name, last_name, config): email = claimed_emails.first().email return redirect( set_url_query_params( - reverse('saml2_emailexists'), - email=base64.urlsafe_b64encode(email.encode('utf-8')).decode('utf-8') + reverse("saml2_emailexists"), + email=base64.urlsafe_b64encode(email.encode("utf-8")).decode("utf-8"), ) ) @@ -434,28 +501,32 @@ def auto_provision(request, emails, first_name, last_name, config): new_account = saml2_new_account(emails, config, first_name, last_name, request) return redirect(redirect_user_to_login(new_account)) else: - provision_data = json.dumps({ - 'first_name': first_name, - 'last_name': last_name, - 'emails': emails, - 'idp_name': config.slug # TODO: actual property - }) + provision_data = json.dumps( + { + "first_name": first_name, + "last_name": last_name, + "emails": emails, + "idp_name": config.slug, # TODO: actual property + } + ) return redirect( set_url_query_params( - reverse('saml2_provision'), + reverse("saml2_provision"), request_id=encrypt_authcode(provision_data), ) ) -def saml2_new_account(requested_emails, config, first_name='', last_name='', request=None): +def saml2_new_account( + requested_emails, config, first_name="", last_name="", request=None +): first_email = requested_emails[0] new_user = BadgeUser.objects.create( email=first_email, first_name=first_name, last_name=last_name, request=request, - send_confirmation=False + send_confirmation=False, ) for email in requested_emails[1:]: CachedEmailAddress.objects.create(email=email, user=new_user, verified=True) @@ -472,11 +543,8 @@ def redirect_user_to_login(user, token=None): accesstoken = token else: accesstoken = AccessTokenProxy.objects.generate_new_token_for_user( - user, scope='rw:backpack rw:profile rw:issuer' + user, scope="rw:backpack rw:profile rw:issuer" ) authcode = authcode_for_accesstoken(accesstoken) - return set_url_query_params( - reverse('saml2_success'), - authcode=authcode - ) + return set_url_query_params(reverse("saml2_success"), authcode=authcode) diff --git a/apps/composition/migrations/0001_initial.py b/apps/composition/migrations/0001_initial.py index 0b1d0155b..598b8ee69 100644 --- a/apps/composition/migrations/0001_initial.py +++ b/apps/composition/migrations/0001_initial.py @@ -9,148 +9,280 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Collection', + name="Collection", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(max_length=128)), - ('slug', autoslug.fields.AutoSlugField(max_length=128)), - ('description', models.CharField(max_length=255, blank=True)), - ('share_hash', models.CharField(max_length=255, blank=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("name", models.CharField(max_length=128)), + ("slug", autoslug.fields.AutoSlugField(max_length=128)), + ("description", models.CharField(max_length=255, blank=True)), + ("share_hash", models.CharField(max_length=255, blank=True)), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( - name='CollectionPermission', + name="CollectionPermission", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('can_write', models.BooleanField(default=False)), - ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='composition.Collection')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("can_write", models.BooleanField(default=False)), + ( + "collection", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="composition.Collection", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( - name='LocalBadgeClass', + name="LocalBadgeClass", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('json', jsonfield.fields.JSONField()), - ('criteria_text', models.TextField(null=True, blank=True)), - ('image', models.ImageField(upload_to=b'uploads/badges', blank=True)), - ('name', models.CharField(max_length=255)), - ('slug', autoslug.fields.AutoSlugField(unique=True, max_length=255)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("json", jsonfield.fields.JSONField()), + ("criteria_text", models.TextField(null=True, blank=True)), + ("image", models.ImageField(upload_to=b"uploads/badges", blank=True)), + ("name", models.CharField(max_length=255)), + ("slug", autoslug.fields.AutoSlugField(unique=True, max_length=255)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), ], options={ - 'abstract': False, - 'verbose_name_plural': 'Badge classes', + "abstract": False, + "verbose_name_plural": "Badge classes", }, bases=(models.Model,), ), migrations.CreateModel( - name='LocalBadgeInstance', + name="LocalBadgeInstance", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('json', jsonfield.fields.JSONField()), - ('email', models.EmailField(max_length=255)), - ('image', models.ImageField(upload_to=b'uploads/badges', blank=True)), - ('slug', autoslug.fields.AutoSlugField(unique=True, max_length=255, editable=False)), - ('revoked', models.BooleanField(default=False)), - ('revocation_reason', models.CharField(default=None, max_length=255, null=True, blank=True)), - ('badgeclass', models.ForeignKey(related_name='badgeinstances', on_delete=django.db.models.deletion.PROTECT, to='composition.LocalBadgeClass', null=True)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("json", jsonfield.fields.JSONField()), + ("email", models.EmailField(max_length=255)), + ("image", models.ImageField(upload_to=b"uploads/badges", blank=True)), + ( + "slug", + autoslug.fields.AutoSlugField( + unique=True, max_length=255, editable=False + ), + ), + ("revoked", models.BooleanField(default=False)), + ( + "revocation_reason", + models.CharField( + default=None, max_length=255, null=True, blank=True + ), + ), + ( + "badgeclass", + models.ForeignKey( + related_name="badgeinstances", + on_delete=django.db.models.deletion.PROTECT, + to="composition.LocalBadgeClass", + null=True, + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='LocalBadgeInstanceCollection', + name="LocalBadgeInstanceCollection", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('description', models.TextField(blank=True)), - ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='composition.Collection')), - ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='composition.LocalBadgeInstance')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("description", models.TextField(blank=True)), + ( + "collection", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="composition.Collection", + ), + ), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="composition.LocalBadgeInstance", + ), + ), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( - name='LocalIssuer', + name="LocalIssuer", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('json', jsonfield.fields.JSONField()), - ('image', models.ImageField(upload_to=b'uploads/issuers', blank=True)), - ('name', models.CharField(max_length=1024)), - ('slug', autoslug.fields.AutoSlugField(unique=True, max_length=255)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("json", jsonfield.fields.JSONField()), + ("image", models.ImageField(upload_to=b"uploads/issuers", blank=True)), + ("name", models.CharField(max_length=1024)), + ("slug", autoslug.fields.AutoSlugField(unique=True, max_length=255)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.AlterUniqueTogether( - name='localbadgeinstancecollection', - unique_together=set([('instance', 'collection')]), + name="localbadgeinstancecollection", + unique_together=set([("instance", "collection")]), ), migrations.AddField( - model_name='localbadgeinstance', - name='issuer', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='composition.LocalIssuer', null=True), + model_name="localbadgeinstance", + name="issuer", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="composition.LocalIssuer", + null=True, + ), preserve_default=True, ), migrations.AddField( - model_name='localbadgeinstance', - name='recipient_user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="localbadgeinstance", + name="recipient_user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), preserve_default=True, ), migrations.AddField( - model_name='localbadgeclass', - name='issuer', - field=models.ForeignKey(related_name='badgeclasses', on_delete=django.db.models.deletion.PROTECT, to='composition.LocalIssuer'), + model_name="localbadgeclass", + name="issuer", + field=models.ForeignKey( + related_name="badgeclasses", + on_delete=django.db.models.deletion.PROTECT, + to="composition.LocalIssuer", + ), preserve_default=True, ), migrations.AlterUniqueTogether( - name='collectionpermission', - unique_together=set([('user', 'collection')]), + name="collectionpermission", + unique_together=set([("user", "collection")]), ), migrations.AddField( - model_name='collection', - name='instances', - field=models.ManyToManyField(to='composition.LocalBadgeInstance', through='composition.LocalBadgeInstanceCollection'), + model_name="collection", + name="instances", + field=models.ManyToManyField( + to="composition.LocalBadgeInstance", + through="composition.LocalBadgeInstanceCollection", + ), preserve_default=True, ), migrations.AddField( - model_name='collection', - name='owner', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + model_name="collection", + name="owner", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), preserve_default=True, ), migrations.AddField( - model_name='collection', - name='shared_with', - field=models.ManyToManyField(related_name='shared_with_me', through='composition.CollectionPermission', to=settings.AUTH_USER_MODEL), + model_name="collection", + name="shared_with", + field=models.ManyToManyField( + related_name="shared_with_me", + through="composition.CollectionPermission", + to=settings.AUTH_USER_MODEL, + ), preserve_default=True, ), migrations.AlterUniqueTogether( - name='collection', - unique_together=set([('owner', 'slug')]), + name="collection", + unique_together=set([("owner", "slug")]), ), ] diff --git a/apps/composition/migrations/0002_auto_20150914_1421.py b/apps/composition/migrations/0002_auto_20150914_1421.py index e9a40ae0f..4de259951 100644 --- a/apps/composition/migrations/0002_auto_20150914_1421.py +++ b/apps/composition/migrations/0002_auto_20150914_1421.py @@ -5,32 +5,34 @@ class Migration(migrations.Migration): - dependencies = [ - ('composition', '0001_initial'), + ("composition", "0001_initial"), ] operations = [ migrations.AlterModelOptions( - name='localbadgeinstancecollection', - options={'verbose_name': 'BadgeInstance in a Collection', 'verbose_name_plural': 'BadgeInstances in Collections'}, + name="localbadgeinstancecollection", + options={ + "verbose_name": "BadgeInstance in a Collection", + "verbose_name_plural": "BadgeInstances in Collections", + }, ), migrations.AddField( - model_name='localbadgeclass', - name='identifier', - field=models.CharField(default=b'get_full_url', max_length=1024), + model_name="localbadgeclass", + name="identifier", + field=models.CharField(default=b"get_full_url", max_length=1024), preserve_default=True, ), migrations.AddField( - model_name='localbadgeinstance', - name='identifier', - field=models.CharField(default=b'get_full_url', max_length=1024), + model_name="localbadgeinstance", + name="identifier", + field=models.CharField(default=b"get_full_url", max_length=1024), preserve_default=True, ), migrations.AddField( - model_name='localissuer', - name='identifier', - field=models.CharField(default=b'get_full_url', max_length=1024), + model_name="localissuer", + name="identifier", + field=models.CharField(default=b"get_full_url", max_length=1024), preserve_default=True, ), ] diff --git a/apps/composition/migrations/0003_auto_20150914_1447.py b/apps/composition/migrations/0003_auto_20150914_1447.py index c840e8355..d5af891cc 100644 --- a/apps/composition/migrations/0003_auto_20150914_1447.py +++ b/apps/composition/migrations/0003_auto_20150914_1447.py @@ -5,20 +5,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('composition', '0002_auto_20150914_1421'), + ("composition", "0002_auto_20150914_1421"), ] operations = [ migrations.RemoveField( - model_name='localbadgeinstance', - name='email', + model_name="localbadgeinstance", + name="email", ), migrations.AddField( - model_name='localbadgeinstance', - name='recipient_identifier', - field=models.EmailField(default='', max_length=1024), + model_name="localbadgeinstance", + name="recipient_identifier", + field=models.EmailField(default="", max_length=1024), preserve_default=False, ), ] diff --git a/apps/composition/migrations/0004_auto_20150915_1057.py b/apps/composition/migrations/0004_auto_20150915_1057.py index 3c9ebf852..f8a819989 100644 --- a/apps/composition/migrations/0004_auto_20150915_1057.py +++ b/apps/composition/migrations/0004_auto_20150915_1057.py @@ -5,16 +5,17 @@ class Migration(migrations.Migration): - dependencies = [ - ('composition', '0003_auto_20150914_1447'), + ("composition", "0003_auto_20150914_1447"), ] operations = [ migrations.AlterField( - model_name='localissuer', - name='image', - field=models.ImageField(null=True, upload_to=b'uploads/issuers', blank=True), + model_name="localissuer", + name="image", + field=models.ImageField( + null=True, upload_to=b"uploads/issuers", blank=True + ), preserve_default=True, ), ] diff --git a/apps/composition/migrations/0005_auto_20151117_1555.py b/apps/composition/migrations/0005_auto_20151117_1555.py index f26916a44..364e08e3b 100644 --- a/apps/composition/migrations/0005_auto_20151117_1555.py +++ b/apps/composition/migrations/0005_auto_20151117_1555.py @@ -5,32 +5,34 @@ class Migration(migrations.Migration): - dependencies = [ - ('composition', '0004_auto_20150915_1057'), + ("composition", "0004_auto_20150915_1057"), ] operations = [ migrations.AlterModelOptions( - name='localbadgeclass', - options={'verbose_name': 'local badge class', 'verbose_name_plural': 'local badge classes'}, + name="localbadgeclass", + options={ + "verbose_name": "local badge class", + "verbose_name_plural": "local badge classes", + }, ), migrations.AlterField( - model_name='localbadgeclass', - name='image', - field=models.FileField(upload_to=b'uploads/badges', blank=True), + model_name="localbadgeclass", + name="image", + field=models.FileField(upload_to=b"uploads/badges", blank=True), preserve_default=True, ), migrations.AlterField( - model_name='localbadgeinstance', - name='image', - field=models.FileField(upload_to=b'uploads/badges', blank=True), + model_name="localbadgeinstance", + name="image", + field=models.FileField(upload_to=b"uploads/badges", blank=True), preserve_default=True, ), migrations.AlterField( - model_name='localissuer', - name='image', - field=models.FileField(null=True, upload_to=b'uploads/issuers', blank=True), + model_name="localissuer", + name="image", + field=models.FileField(null=True, upload_to=b"uploads/issuers", blank=True), preserve_default=True, ), ] diff --git a/apps/composition/migrations/0006_auto_20160928_0648.py b/apps/composition/migrations/0006_auto_20160928_0648.py index b794dda2b..c1cc8b6ea 100644 --- a/apps/composition/migrations/0006_auto_20160928_0648.py +++ b/apps/composition/migrations/0006_auto_20160928_0648.py @@ -5,21 +5,20 @@ class Migration(migrations.Migration): - dependencies = [ - ('composition', '0005_auto_20151117_1555'), + ("composition", "0005_auto_20151117_1555"), ] operations = [ migrations.AddField( - model_name='localbadgeclass', - name='image_preview_status', + model_name="localbadgeclass", + name="image_preview_status", field=models.IntegerField(default=None, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='localissuer', - name='image_preview_status', + model_name="localissuer", + name="image_preview_status", field=models.IntegerField(default=None, null=True, blank=True), preserve_default=True, ), diff --git a/apps/composition/migrations/0007_auto_20160930_0733.py b/apps/composition/migrations/0007_auto_20160930_0733.py index 03a03b23c..110af0ed7 100644 --- a/apps/composition/migrations/0007_auto_20160930_0733.py +++ b/apps/composition/migrations/0007_auto_20160930_0733.py @@ -7,37 +7,48 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0009_badgeinstance_acceptance'), - ('composition', '0006_auto_20160928_0648'), + ("issuer", "0009_badgeinstance_acceptance"), + ("composition", "0006_auto_20160928_0648"), ] operations = [ migrations.AddField( - model_name='localbadgeinstancecollection', - name='issuer_instance', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeInstance', null=True), + model_name="localbadgeinstancecollection", + name="issuer_instance", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeInstance", + null=True, + ), preserve_default=True, ), migrations.AlterField( - model_name='localbadgeinstancecollection', - name='collection', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='badges', to='composition.Collection'), + model_name="localbadgeinstancecollection", + name="collection", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="badges", + to="composition.Collection", + ), preserve_default=True, ), migrations.AlterField( - model_name='localbadgeinstancecollection', - name='instance', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='composition.LocalBadgeInstance', null=True), + model_name="localbadgeinstancecollection", + name="instance", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="composition.LocalBadgeInstance", + null=True, + ), preserve_default=True, - ) + ), ] - if settings.DATABASES['default'].get('ENGINE', '') != 'sql_server.pyodbc': + if settings.DATABASES["default"].get("ENGINE", "") != "sql_server.pyodbc": # only include this migration if not sql server, it doesn't like it. operations += [ migrations.AlterUniqueTogether( - name='localbadgeinstancecollection', - unique_together=set([('instance', 'issuer_instance', 'collection')]), + name="localbadgeinstancecollection", + unique_together=set([("instance", "issuer_instance", "collection")]), ) ] diff --git a/apps/composition/migrations/0008_collectionshare_localbadgeinstanceshare.py b/apps/composition/migrations/0008_collectionshare_localbadgeinstanceshare.py index 4e5a30c92..621050c37 100644 --- a/apps/composition/migrations/0008_collectionshare_localbadgeinstanceshare.py +++ b/apps/composition/migrations/0008_collectionshare_localbadgeinstanceshare.py @@ -6,39 +6,92 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0009_badgeinstance_acceptance'), - ('composition', '0007_auto_20160930_0733'), + ("issuer", "0009_badgeinstance_acceptance"), + ("composition", "0007_auto_20160930_0733"), ] operations = [ migrations.CreateModel( - name='CollectionShare', + name="CollectionShare", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('provider', models.CharField(max_length=254, choices=[(b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn')])), - ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='composition.Collection')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "provider", + models.CharField( + max_length=254, + choices=[ + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + ], + ), + ), + ( + "collection", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="composition.Collection", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='LocalBadgeInstanceShare', + name="LocalBadgeInstanceShare", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('provider', models.CharField(max_length=254, choices=[(b'facebook', b'Facebook'), (b'linkedin', b'LinkedIn')])), - ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='composition.LocalBadgeInstance', null=True)), - ('issuer_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeInstance', null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "provider", + models.CharField( + max_length=254, + choices=[ + (b"facebook", b"Facebook"), + (b"linkedin", b"LinkedIn"), + ], + ), + ), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="composition.LocalBadgeInstance", + null=True, + ), + ), + ( + "issuer_instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeInstance", + null=True, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), diff --git a/apps/composition/migrations/0009_auto_20161007_1358.py b/apps/composition/migrations/0009_auto_20161007_1358.py index c7274a305..674020c2b 100644 --- a/apps/composition/migrations/0009_auto_20161007_1358.py +++ b/apps/composition/migrations/0009_auto_20161007_1358.py @@ -5,12 +5,16 @@ def migrate_duplicate_collection_badges(apps, schema_editor): - LocalBadgeInstanceCollection = apps.get_model('composition.LocalBadgeInstanceCollection') - BadgeInstance = apps.get_model('issuer.BadgeInstance') + LocalBadgeInstanceCollection = apps.get_model( + "composition.LocalBadgeInstanceCollection" + ) + BadgeInstance = apps.get_model("issuer.BadgeInstance") for entry in LocalBadgeInstanceCollection.objects.all(): if entry.instance_id: try: - issuer_instance = BadgeInstance.objects.get(slug=entry.instance.json.get('uid')) + issuer_instance = BadgeInstance.objects.get( + slug=entry.instance.json.get("uid") + ) except BadgeInstance.DoesNotExist: pass else: @@ -18,19 +22,20 @@ def migrate_duplicate_collection_badges(apps, schema_editor): entry.issuer_instance = issuer_instance entry.instance = None - print(("Migrating duplicate badge Collection.pk={}: LocalBadgeInstance.pk={} -> BadgeInstance.slug={}".format( - entry.collection_id, old_instance_id, issuer_instance.slug - ))) + print( + ( + "Migrating duplicate badge Collection.pk={}: LocalBadgeInstance.pk={} -> BadgeInstance.slug={}".format( + entry.collection_id, old_instance_id, issuer_instance.slug + ) + ) + ) entry.save() class Migration(migrations.Migration): - dependencies = [ - ('composition', '0008_collectionshare_localbadgeinstanceshare'), - ('issuer', '0009_badgeinstance_acceptance'), + ("composition", "0008_collectionshare_localbadgeinstanceshare"), + ("issuer", "0009_badgeinstance_acceptance"), ] - operations = [ - migrations.RunPython(migrate_duplicate_collection_badges) - ] + operations = [migrations.RunPython(migrate_duplicate_collection_badges)] diff --git a/apps/composition/migrations/0010_auto_20170214_0712.py b/apps/composition/migrations/0010_auto_20170214_0712.py index eef7ba168..a9de7703e 100644 --- a/apps/composition/migrations/0010_auto_20170214_0712.py +++ b/apps/composition/migrations/0010_auto_20170214_0712.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations from mainsite.utils import fetch_remote_file_to_storage import json @@ -12,79 +12,83 @@ def noop(apps, schema_editor): def import_localissuers_to_issuer(apps, schema_editor): - LocalIssuer = apps.get_model('composition', 'LocalIssuer') - Issuer = apps.get_model('issuer', 'Issuer') + LocalIssuer = apps.get_model("composition", "LocalIssuer") + Issuer = apps.get_model("issuer", "Issuer") for local_issuer in LocalIssuer.objects.all(): - - remote_image_url = local_issuer.json.get('image', None) + remote_image_url = local_issuer.json.get("image", None) if local_issuer.image: image = local_issuer.image elif remote_image_url and local_issuer.image_preview_status is None: try: - status_code, image = fetch_remote_file_to_storage(remote_image_url, upload_to=local_issuer.image.field.upload_to) + status_code, image = fetch_remote_file_to_storage( + remote_image_url, upload_to=local_issuer.image.field.upload_to + ) except IOError: image = None else: image = None new_issuer, created = Issuer.objects.get_or_create( - source='legacy_local_issuer', + source="legacy_local_issuer", source_url=local_issuer.identifier, defaults={ - 'created_by': local_issuer.created_by, - 'name': local_issuer.name, - 'image': image, - 'original_json': json.dumps(local_issuer.json), - 'url': local_issuer.json.get('url', None), - 'email': local_issuer.json.get('email', None), - 'description': local_issuer.json.get('description', None), - } + "created_by": local_issuer.created_by, + "name": local_issuer.name, + "image": image, + "original_json": json.dumps(local_issuer.json), + "url": local_issuer.json.get("url", None), + "email": local_issuer.json.get("email", None), + "description": local_issuer.json.get("description", None), + }, ) new_issuer.created_at = local_issuer.created_at # Avoid auto-now behavior new_issuer.save() def import_localbadgeclass_to_issuer(apps, schema_editor): - LocalBadgeClass = apps.get_model('composition', 'LocalBadgeClass') - Issuer = apps.get_model('issuer', 'Issuer') - BadgeClass = apps.get_model('issuer', 'BadgeClass') + LocalBadgeClass = apps.get_model("composition", "LocalBadgeClass") + Issuer = apps.get_model("issuer", "Issuer") + BadgeClass = apps.get_model("issuer", "BadgeClass") for local_badgeclass in LocalBadgeClass.objects.all(): - issuer = Issuer.objects.get(source='legacy_local_issuer', source_url=local_badgeclass.issuer.identifier) + issuer = Issuer.objects.get( + source="legacy_local_issuer", source_url=local_badgeclass.issuer.identifier + ) - remote_image_url = local_badgeclass.json.get('image', None) + remote_image_url = local_badgeclass.json.get("image", None) if local_badgeclass.image: image = local_badgeclass.image elif remote_image_url and local_badgeclass.image_preview_status is None: try: - status_code, image = fetch_remote_file_to_storage(remote_image_url, upload_to=local_badgeclass.image.field.upload_to) + status_code, image = fetch_remote_file_to_storage( + remote_image_url, upload_to=local_badgeclass.image.field.upload_to + ) except IOError: image = None else: image = None new_badgeclass, created = BadgeClass.objects.get_or_create( - source='legacy_local_badgeclass', + source="legacy_local_badgeclass", source_url=local_badgeclass.identifier, defaults={ - 'created_by': local_badgeclass.created_by, - 'name': local_badgeclass.name, - 'image': image, - 'criteria_text': local_badgeclass.criteria_text, - 'issuer': issuer, - 'original_json': json.dumps(local_badgeclass.json), - 'criteria_url': local_badgeclass.json.get('criteria', None), - 'description': local_badgeclass.json.get('description', None), - } + "created_by": local_badgeclass.created_by, + "name": local_badgeclass.name, + "image": image, + "criteria_text": local_badgeclass.criteria_text, + "issuer": issuer, + "original_json": json.dumps(local_badgeclass.json), + "criteria_url": local_badgeclass.json.get("criteria", None), + "description": local_badgeclass.json.get("description", None), + }, ) new_badgeclass.created_at = new_badgeclass.created_at # Avoid auto-now behavior new_badgeclass.save() class Migration(migrations.Migration): - dependencies = [ - ('composition', '0009_auto_20161007_1358'), - ('issuer', '0019_auto_20170413_1136') + ("composition", "0009_auto_20161007_1358"), + ("issuer", "0019_auto_20170413_1136"), ] operations = [ diff --git a/apps/composition/migrations/0011_auto_20170227_0847.py b/apps/composition/migrations/0011_auto_20170227_0847.py index 535a0f7e3..980957ecc 100644 --- a/apps/composition/migrations/0011_auto_20170227_0847.py +++ b/apps/composition/migrations/0011_auto_20170227_0847.py @@ -1,24 +1,23 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('composition', '0010_auto_20170214_0712'), + ("composition", "0010_auto_20170214_0712"), ] operations = [ migrations.RenameField( - model_name='localbadgeinstance', - old_name='badgeclass', - new_name='local_badgeclass', + model_name="localbadgeinstance", + old_name="badgeclass", + new_name="local_badgeclass", ), migrations.RenameField( - model_name='localbadgeinstance', - old_name='issuer', - new_name='local_issuer', + model_name="localbadgeinstance", + old_name="issuer", + new_name="local_issuer", ), ] diff --git a/apps/composition/migrations/0012_localbadgeinstance_badgeclass.py b/apps/composition/migrations/0012_localbadgeinstance_badgeclass.py index 9b987b1f8..b276fd7b1 100644 --- a/apps/composition/migrations/0012_localbadgeinstance_badgeclass.py +++ b/apps/composition/migrations/0012_localbadgeinstance_badgeclass.py @@ -6,17 +6,21 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0017_auto_20170227_1334'), - ('composition', '0011_auto_20170227_0847'), + ("issuer", "0017_auto_20170227_1334"), + ("composition", "0011_auto_20170227_0847"), ] operations = [ migrations.AddField( - model_name='localbadgeinstance', - name='issuer_badgeclass', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, blank=True, to='issuer.BadgeClass', null=True), + model_name="localbadgeinstance", + name="issuer_badgeclass", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + blank=True, + to="issuer.BadgeClass", + null=True, + ), preserve_default=True, ), ] diff --git a/apps/composition/migrations/0013_auto_20170227_0847.py b/apps/composition/migrations/0013_auto_20170227_0847.py index d89cf792c..f49df8674 100644 --- a/apps/composition/migrations/0013_auto_20170227_0847.py +++ b/apps/composition/migrations/0013_auto_20170227_0847.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations def noop(apps, schema_editor): @@ -9,14 +9,17 @@ def noop(apps, schema_editor): def migrate_localbadgeinstance_to_use_issuer(apps, schema_editor): - LocalBadgeInstance = apps.get_model('composition', 'LocalBadgeInstance') - BadgeClass = apps.get_model('issuer', 'BadgeClass') + LocalBadgeInstance = apps.get_model("composition", "LocalBadgeInstance") + BadgeClass = apps.get_model("issuer", "BadgeClass") for local_badge_instance in LocalBadgeInstance.objects.all(): try: - source_url = getattr(local_badge_instance.local_badgeclass, 'identifier', None) + source_url = getattr( + local_badge_instance.local_badgeclass, "identifier", None + ) - badgeclass = BadgeClass.objects.get(source='legacy_local_badgeclass', - source_url=source_url) + badgeclass = BadgeClass.objects.get( + source="legacy_local_badgeclass", source_url=source_url + ) local_badge_instance.issuer_badgeclass = badgeclass local_badge_instance.save() except BadgeClass.DoesNotExist: @@ -26,12 +29,13 @@ def migrate_localbadgeinstance_to_use_issuer(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('composition', '0012_localbadgeinstance_badgeclass'), - ('issuer', '0017_auto_20170227_1334'), + ("composition", "0012_localbadgeinstance_badgeclass"), + ("issuer", "0017_auto_20170227_1334"), ] operations = [ - migrations.RunPython(migrate_localbadgeinstance_to_use_issuer, reverse_code=noop), + migrations.RunPython( + migrate_localbadgeinstance_to_use_issuer, reverse_code=noop + ), ] diff --git a/apps/composition/migrations/0014_auto_20170413_1054.py b/apps/composition/migrations/0014_auto_20170413_1054.py index 8fae75a2f..0ac3c0f63 100644 --- a/apps/composition/migrations/0014_auto_20170413_1054.py +++ b/apps/composition/migrations/0014_auto_20170413_1054.py @@ -1,39 +1,49 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations import autoslug.fields class Migration(migrations.Migration): - dependencies = [ - ('composition', '0013_auto_20170227_0847'), + ("composition", "0013_auto_20170227_0847"), ] operations = [ migrations.AlterField( - model_name='collection', - name='slug', - field=autoslug.fields.AutoSlugField(editable=True, max_length=128, populate_from=b'name'), + model_name="collection", + name="slug", + field=autoslug.fields.AutoSlugField( + editable=True, max_length=128, populate_from=b"name" + ), preserve_default=True, ), migrations.AlterField( - model_name='localbadgeclass', - name='slug', - field=autoslug.fields.AutoSlugField(editable=True, unique=True, max_length=255, populate_from=b'name'), + model_name="localbadgeclass", + name="slug", + field=autoslug.fields.AutoSlugField( + editable=True, unique=True, max_length=255, populate_from=b"name" + ), preserve_default=True, ), migrations.AlterField( - model_name='localbadgeinstance', - name='slug', - field=autoslug.fields.AutoSlugField(populate_from=b'populate_slug', unique=True, max_length=255, editable=False), + model_name="localbadgeinstance", + name="slug", + field=autoslug.fields.AutoSlugField( + populate_from=b"populate_slug", + unique=True, + max_length=255, + editable=False, + ), preserve_default=True, ), migrations.AlterField( - model_name='localissuer', - name='slug', - field=autoslug.fields.AutoSlugField(editable=True, unique=True, max_length=255, populate_from=b'name'), + model_name="localissuer", + name="slug", + field=autoslug.fields.AutoSlugField( + editable=True, unique=True, max_length=255, populate_from=b"name" + ), preserve_default=True, ), ] diff --git a/apps/composition/migrations/0015_auto_20170420_0649.py b/apps/composition/migrations/0015_auto_20170420_0649.py index 6e210f20e..c190f71c4 100644 --- a/apps/composition/migrations/0015_auto_20170420_0649.py +++ b/apps/composition/migrations/0015_auto_20170420_0649.py @@ -1,40 +1,39 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('composition', '0014_auto_20170413_1054'), + ("composition", "0014_auto_20170413_1054"), ] operations = [ migrations.RemoveField( - model_name='localbadgeclass', - name='created_by', + model_name="localbadgeclass", + name="created_by", ), migrations.RemoveField( - model_name='localbadgeclass', - name='issuer', + model_name="localbadgeclass", + name="issuer", ), migrations.RemoveField( - model_name='localissuer', - name='created_by', + model_name="localissuer", + name="created_by", ), migrations.RemoveField( - model_name='localbadgeinstance', - name='local_badgeclass', + model_name="localbadgeinstance", + name="local_badgeclass", ), migrations.DeleteModel( - name='LocalBadgeClass', + name="LocalBadgeClass", ), migrations.RemoveField( - model_name='localbadgeinstance', - name='local_issuer', + model_name="localbadgeinstance", + name="local_issuer", ), migrations.DeleteModel( - name='LocalIssuer', + name="LocalIssuer", ), ] diff --git a/apps/composition/migrations/0016_auto_20170619_2301.py b/apps/composition/migrations/0016_auto_20170619_2301.py index 9629b4fe7..f48d94a6a 100644 --- a/apps/composition/migrations/0016_auto_20170619_2301.py +++ b/apps/composition/migrations/0016_auto_20170619_2301.py @@ -6,102 +6,101 @@ class Migration(migrations.Migration): - dependencies = [ - ('composition', '0015_auto_20170420_0649'), + ("composition", "0015_auto_20170420_0649"), ] operations = [ migrations.AlterUniqueTogether( - name='collection', + name="collection", unique_together=set([]), ), migrations.RemoveField( - model_name='collection', - name='instances', + model_name="collection", + name="instances", ), migrations.RemoveField( - model_name='collection', - name='owner', + model_name="collection", + name="owner", ), migrations.RemoveField( - model_name='collection', - name='shared_with', + model_name="collection", + name="shared_with", ), migrations.AlterUniqueTogether( - name='collectionpermission', + name="collectionpermission", unique_together=set([]), ), migrations.RemoveField( - model_name='collectionpermission', - name='collection', + model_name="collectionpermission", + name="collection", ), migrations.RemoveField( - model_name='collectionpermission', - name='user', + model_name="collectionpermission", + name="user", ), migrations.RemoveField( - model_name='collectionshare', - name='collection', + model_name="collectionshare", + name="collection", ), migrations.RemoveField( - model_name='localbadgeinstance', - name='created_by', + model_name="localbadgeinstance", + name="created_by", ), migrations.RemoveField( - model_name='localbadgeinstance', - name='issuer_badgeclass', + model_name="localbadgeinstance", + name="issuer_badgeclass", ), migrations.RemoveField( - model_name='localbadgeinstance', - name='recipient_user', - ) + model_name="localbadgeinstance", + name="recipient_user", + ), ] - if settings.DATABASES['default'].get('ENGINE', '') != 'sql_server.pyodbc': + if settings.DATABASES["default"].get("ENGINE", "") != "sql_server.pyodbc": # only include this migration if not sql server, it doesn't like it. operations += [ migrations.AlterUniqueTogether( - name='localbadgeinstancecollection', + name="localbadgeinstancecollection", unique_together=set([]), ) ] operations += [ migrations.RemoveField( - model_name='localbadgeinstancecollection', - name='collection', + model_name="localbadgeinstancecollection", + name="collection", ), migrations.RemoveField( - model_name='localbadgeinstancecollection', - name='instance', + model_name="localbadgeinstancecollection", + name="instance", ), migrations.RemoveField( - model_name='localbadgeinstancecollection', - name='issuer_instance', + model_name="localbadgeinstancecollection", + name="issuer_instance", ), migrations.RemoveField( - model_name='localbadgeinstanceshare', - name='instance', + model_name="localbadgeinstanceshare", + name="instance", ), migrations.RemoveField( - model_name='localbadgeinstanceshare', - name='issuer_instance', + model_name="localbadgeinstanceshare", + name="issuer_instance", ), migrations.DeleteModel( - name='Collection', + name="Collection", ), migrations.DeleteModel( - name='CollectionPermission', + name="CollectionPermission", ), migrations.DeleteModel( - name='CollectionShare', + name="CollectionShare", ), migrations.DeleteModel( - name='LocalBadgeInstance', + name="LocalBadgeInstance", ), migrations.DeleteModel( - name='LocalBadgeInstanceCollection', + name="LocalBadgeInstanceCollection", ), migrations.DeleteModel( - name='LocalBadgeInstanceShare', + name="LocalBadgeInstanceShare", ), ] diff --git a/apps/entity/__init__.py b/apps/entity/__init__.py index d4896b838..bca5f6749 100644 --- a/apps/entity/__init__.py +++ b/apps/entity/__init__.py @@ -1,4 +1 @@ # encoding: utf-8 - - - diff --git a/apps/entity/api.py b/apps/entity/api.py index e68ef19c6..e1868c5cf 100644 --- a/apps/entity/api.py +++ b/apps/entity/api.py @@ -5,45 +5,32 @@ from django.http import Http404 from rest_framework.exceptions import NotAuthenticated from rest_framework.response import Response -from rest_framework.status import HTTP_404_NOT_FOUND, HTTP_201_CREATED, HTTP_204_NO_CONTENT +from rest_framework.status import HTTP_201_CREATED, HTTP_204_NO_CONTENT from rest_framework.views import APIView -import badgrlog from mainsite.pagination import BadgrCursorPagination +import logging + +logger = logging.getLogger("Badgr.Events") -class BaseEntityView(APIView): - create_event = None - logger = None +class BaseEntityView(APIView): def get_context_data(self, **kwargs): return { - 'request': self.request, - 'kwargs': kwargs, + "request": self.request, + "kwargs": kwargs, } def get_serializer_class(self): - if self.request.version == 'v1' and hasattr(self, 'v1_serializer_class'): + if self.request.version == "v1" and hasattr(self, "v1_serializer_class"): return self.v1_serializer_class - elif self.request.version == 'v2' and hasattr(self, 'v2_serializer_class'): + elif self.request.version == "v2" and hasattr(self, "v2_serializer_class"): return self.v2_serializer_class - return getattr(self, 'serializer_class', None) - - def get_logger(self): - if self.logger: - return self.logger - self.logger = badgrlog.BadgrLogger() - return self.logger - - def get_create_event(self): - return getattr(self, 'create_event', None) + return getattr(self, "serializer_class", None) def log_create(self, instance): - event_cls = self.get_create_event() - if event_cls is not None: - logger = self.get_logger() - if logger is not None: - logger.event(event_cls(instance)) + logger.info("Created instance: '%s'", instance) class BaseEntityListView(BaseEntityView): @@ -56,7 +43,10 @@ def get(self, request, **kwargs): """ GET a list of an entities the authenticated user is authorized for """ - if self.allow_any_unauthenticated_access is False and not request.user.is_authenticated: + if ( + self.allow_any_unauthenticated_access is False + and not request.user.is_authenticated + ): raise NotAuthenticated() objects = self.get_objects(request, **kwargs) @@ -65,11 +55,11 @@ def get(self, request, **kwargs): serializer = serializer_class(objects, many=True, context=context) headers = dict() - paginator = getattr(self, 'paginator', None) - if paginator and callable(getattr(paginator, 'get_link_header', None)): + paginator = getattr(self, "paginator", None) + if paginator and callable(getattr(paginator, "get_link_header", None)): link_header = paginator.get_link_header() if link_header: - headers['Link'] = link_header + headers["Link"] = link_header return Response(serializer.data, headers=headers) @@ -90,7 +80,7 @@ def post(self, request, **kwargs): class VersionedObjectMixin(object): - entity_id_field_name = 'entity_id' + entity_id_field_name = "entity_id" allow_any_unauthenticated_access = False def has_object_permissions(self, request, obj): @@ -100,17 +90,22 @@ def has_object_permissions(self, request, obj): return True def get_object(self, request, **kwargs): - if self.allow_any_unauthenticated_access is False and not request.user.is_authenticated: + if ( + self.allow_any_unauthenticated_access is False + and not request.user.is_authenticated + ): raise NotAuthenticated() - version = getattr(request, 'version', 'v1') - if version == 'v1': - identifier = kwargs.get('slug') - elif version == 'v2': - identifier = kwargs.get('entity_id') + version = getattr(request, "version", "v1") + if version == "v1": + identifier = kwargs.get("slug") + elif version == "v2": + identifier = kwargs.get("entity_id") try: - self.object = self.model.cached.get(**{self.get_entity_id_field_name(): identifier}) + self.object = self.model.cached.get( + **{self.get_entity_id_field_name(): identifier} + ) except self.model.DoesNotExist: pass else: @@ -118,7 +113,7 @@ def get_object(self, request, **kwargs): raise Http404 return self.object - if version == 'v1': + if version == "v1": # try a lookup by legacy slug if its v1 try: self.object = self.model.cached.get(slug=identifier) @@ -136,8 +131,56 @@ def get_entity_id_field_name(self): return self.entity_id_field_name -class BaseEntityDetailView(BaseEntityView, VersionedObjectMixin): +class VersionedObjectMixinPublic(object): + entity_id_field_name = "entity_id" + allow_any_unauthenticated_access = True + + def get_object(self, request, **kwargs): + version = getattr(request, "version", "v1") + if version == "v1": + identifier = kwargs.get("slug") + elif version == "v2": + identifier = kwargs.get("entity_id") + + try: + self.object = self.model.cached.get( + **{self.get_entity_id_field_name(): identifier} + ) + except self.model.DoesNotExist: + pass + else: + return self.object + + if version == "v1": + # try a lookup by legacy slug if its v1 + try: + self.object = self.model.cached.get(slug=identifier) + except (self.model.DoesNotExist, FieldError): + raise Http404 + else: + return self.object + + # nothing found + raise Http404 + def get_entity_id_field_name(self): + return self.entity_id_field_name + + +class BaseEntityDetailViewPublic(BaseEntityView, VersionedObjectMixinPublic): + def get(self, request, **kwargs): + """ + GET a single entity by its identifier + """ + obj = self.get_object(request, **kwargs) + + context = self.get_context_data(**kwargs) + serializer_class = self.get_serializer_class() + serializer = serializer_class(obj, context=context) + return Response(serializer.data) + + +class BaseEntityDetailView(BaseEntityView, VersionedObjectMixin): def get(self, request, **kwargs): """ GET a single entity by its identifier @@ -160,7 +203,9 @@ def put(self, request, data=None, allow_partial=False, **kwargs): context = self.get_context_data(**kwargs) serializer_class = self.get_serializer_class() - serializer = serializer_class(obj, data=data, partial=allow_partial, context=context) + serializer = serializer_class( + obj, data=data, partial=allow_partial, context=context + ) serializer.is_valid(raise_exception=True) serializer.save(updated_by=request.user) return Response(serializer.data) @@ -178,7 +223,7 @@ class UncachedPaginatedViewMixin(object): min_per_page = 1 max_per_page = 500 default_per_page = None # dont paginate by default - per_page_query_parameter_name = 'num' + per_page_query_parameter_name = "num" ordering = "-created_at" def get_ordering(self): @@ -188,7 +233,11 @@ def get_page_size(self, request=None): if request is None: return self.default_per_page try: - per_page = int(request.query_params.get(self.per_page_query_parameter_name, self.default_per_page)) + per_page = int( + request.query_params.get( + self.per_page_query_parameter_name, self.default_per_page + ) + ) per_page = max(self.min_per_page, per_page) return min(self.max_per_page, per_page) except (TypeError, ValueError): @@ -203,7 +252,9 @@ def get_objects(self, request, **kwargs): # only paginate on request if per_page: - self.paginator = BadgrCursorPagination(ordering=self.get_ordering(), page_size=per_page) + self.paginator = BadgrCursorPagination( + ordering=self.get_ordering(), page_size=per_page + ) page = self.paginator.paginate_queryset(queryset, request=request) else: page = list(queryset) diff --git a/apps/entity/api_v3.py b/apps/entity/api_v3.py new file mode 100644 index 000000000..408c1f33b --- /dev/null +++ b/apps/entity/api_v3.py @@ -0,0 +1,63 @@ +from rest_framework import viewsets, serializers +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.filters import OrderingFilter +from django_filters import rest_framework as filters +from drf_spectacular.openapi import AutoSchema + + +class EntityLimitOffsetPagination(LimitOffsetPagination): + default_limit = 20 + + +class EntityFilter(filters.FilterSet): + name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + +# Dummy serializer for the base EntityViewSet +# Subclasses will override with their actual serializer +class EntitySerializer(serializers.Serializer): + """Placeholder serializer for EntityViewSet base class""" + + entity_id = serializers.CharField(read_only=True) + name = serializers.CharField(required=False) + created_at = serializers.DateTimeField(read_only=True) + + +class EntityViewSet(viewsets.ModelViewSet): + """ + Base ViewSet for entity models. + Subclasses MUST define: + - queryset + - serializer_class + """ + + pagination_class = EntityLimitOffsetPagination + http_method_names = ["get", "head", "options"] + lookup_field = "entity_id" + filter_backends = [filters.DjangoFilterBackend, OrderingFilter] + filterset_class = EntityFilter + ordering_fields = ["name", "created_at"] + + # Default serializer - subclasses should override this + serializer_class = EntitySerializer + + # Ensure schema is properly initialized for subclasses + schema = AutoSchema() + + # Placeholder queryset - subclasses MUST override this + queryset = None + + +class TagFilter(filters.BaseInFilter, filters.CharFilter): + """ + A filter combining BaseInFilter and CharFilter allowing + filtering for a list of comma-separated tags, returning + only elements that have all the given tags set. + """ + + def filter(self, qs, value): + if not value: + return qs + for tag in value: + qs = qs.filter(**{f"{self.field_name}__icontains": tag}) + return qs diff --git a/apps/entity/apps.py b/apps/entity/apps.py new file mode 100644 index 000000000..c28bce2c4 --- /dev/null +++ b/apps/entity/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class EntityConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "entity" + + def ready(self): + import entity.openapi # noqa: F401 diff --git a/apps/entity/authentication.py b/apps/entity/authentication.py index 682f7a17c..4068b4513 100644 --- a/apps/entity/authentication.py +++ b/apps/entity/authentication.py @@ -11,6 +11,7 @@ class ExplicitCSRFSessionAuthentication(SessionAuthentication): Wrapper class that raises an explicit CSRFPermissionDenied on CSRF failure to facilitate custom behavior in entity.views.exception_handler. """ + def enforce_csrf(self, request): try: return super(ExplicitCSRFSessionAuthentication, self).enforce_csrf(request) diff --git a/apps/entity/db/__init__.py b/apps/entity/db/__init__.py index d4896b838..bca5f6749 100644 --- a/apps/entity/db/__init__.py +++ b/apps/entity/db/__init__.py @@ -1,4 +1 @@ # encoding: utf-8 - - - diff --git a/apps/entity/db/migrations.py b/apps/entity/db/migrations.py index f3625d498..90d163412 100644 --- a/apps/entity/db/migrations.py +++ b/apps/entity/db/migrations.py @@ -10,9 +10,11 @@ class PopulateEntityIdsMigration(RunPython): def __init__(self, app_label, model_name, entity_class_name=None, **kwargs): self.app_label = app_label self.model_name = model_name - self.entity_class_name = entity_class_name if entity_class_name is not None else model_name - if 'reverse_code' not in kwargs: - kwargs['reverse_code'] = self.noop + self.entity_class_name = ( + entity_class_name if entity_class_name is not None else model_name + ) + if "reverse_code" not in kwargs: + kwargs["reverse_code"] = self.noop super(PopulateEntityIdsMigration, self).__init__(self.generate_ids, **kwargs) def noop(self, apps, schema_editor): diff --git a/apps/entity/models.py b/apps/entity/models.py index d209b17a9..bc1fbb56c 100644 --- a/apps/entity/models.py +++ b/apps/entity/models.py @@ -14,7 +14,7 @@ class Meta: abstract = True def get_entity_class_name(self): - if hasattr(self, 'entity_class_name') and self.entity_class_name: + if hasattr(self, "entity_class_name") and self.entity_class_name: return self.entity_class_name return self.__class__.__name__ @@ -23,14 +23,15 @@ def save(self, *args, **kwargs): self.entity_id = generate_entity_uri() self.entity_version += 1 + return super(_AbstractVersionedEntity, self).save(*args, **kwargs) def publish(self): super(_AbstractVersionedEntity, self).publish() - self.publish_by('entity_id') + self.publish_by("entity_id") def delete(self, *args, **kwargs): - self.publish_delete('entity_id') + self.publish_delete("entity_id") return super(_AbstractVersionedEntity, self).delete(*args, **kwargs) @@ -40,16 +41,18 @@ class _MigratingToBaseVersionedEntity(_AbstractVersionedEntity): Usage: 1.) change ExistingModel to subclass from _MigratingToBaseVersionedEntity - 2.) ./manage.py makemigrations existing_app # existing_app.ExistingModel should get a migration that adds entity fields, default=None + 2.) ./manage.py makemigrations existing_app + # existing_app.ExistingModel should get a migration that adds entity fields, default=None 3.) ./manage.py makemigrations existing_app --empty # build a data migration that will populate the new fields: example: operations = [ entity.db.migrations.PopulateEntityIdsMigration('existing_app', 'ExistingModel'), - ] - 4.) change ExistingModel to subclass from BaseVersionedEntity instead of _MigratingToBaseVersionedEntity - 5.) ./manage.py makemigrations existing_app # make migration that sets unique=True - + ] + 4.) change ExistingModel to subclass from BaseVersionedEntity + instead of _MigratingToBaseVersionedEntity + 5.) ./manage.py makemigrations existing_app # make migration that sets unique=True """ + entity_id = models.CharField(max_length=254, blank=False, null=True, default=None) class Meta: @@ -57,13 +60,15 @@ class Meta: class BaseVersionedEntity(_AbstractVersionedEntity): - entity_id = models.CharField(max_length=254, unique=True, default=None) # default=None is required + entity_id = models.CharField( + max_length=254, unique=True, default=None + ) # default=None is required class Meta: abstract = True def __str__(self): try: - return '{}{}'.format(type(self)._meta.object_name, self.entity_id) + return "{}{}".format(type(self)._meta.object_name, self.entity_id) except AttributeError: return self.entity_id diff --git a/apps/entity/openapi.py b/apps/entity/openapi.py new file mode 100644 index 000000000..38c732bb2 --- /dev/null +++ b/apps/entity/openapi.py @@ -0,0 +1,29 @@ +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from drf_spectacular.extensions import OpenApiSerializerFieldExtension +from drf_spectacular.plumbing import build_basic_type +from drf_spectacular.types import OpenApiTypes + + +class ExplicitCSRFSessionAuthenticationScheme(OpenApiAuthenticationExtension): + """ + Extension for ExplicitCSRFSessionAuthentication. + """ + + target_class = "entity.authentication.ExplicitCSRFSessionAuthentication" + name = "sessionAuth" + + def get_security_definition(self, auto_schema): + return { + "type": "apiKey", + "in": "cookie", + "name": "sessionid", + "description": "Session-based authentication with CSRF protection. " + "Requires a valid Django session cookie and CSRF token in headers.", + } + + +class EntityRelatedFieldV2Extension(OpenApiSerializerFieldExtension): + target_class = "entity.serializers.EntityRelatedFieldV2" + + def map_serializer_field(self, auto_schema, direction): + return build_basic_type(OpenApiTypes.STR) diff --git a/apps/entity/serializers.py b/apps/entity/serializers.py index 13ec7fe00..c9bd09adf 100644 --- a/apps/entity/serializers.py +++ b/apps/entity/serializers.py @@ -11,7 +11,7 @@ class EntityRelatedFieldV2(serializers.RelatedField): def __init__(self, *args, **kwargs): - self.rel_source = kwargs.pop('rel_source', 'entity_id') + self.rel_source = kwargs.pop("rel_source", "entity_id") super(EntityRelatedFieldV2, self).__init__(*args, **kwargs) def to_internal_value(self, data): @@ -19,11 +19,17 @@ def to_internal_value(self, data): obj = self.get_queryset().get(**{self.rel_source: data}) return obj except ObjectDoesNotExist: - raise RestframeworkValidationError('Invalid {rel_source} "{rel_value}" - object does not exist.'.format( - rel_source=self.rel_source, rel_value=data)) + raise RestframeworkValidationError( + 'Invalid {rel_source} "{rel_value}" - object does not exist.'.format( + rel_source=self.rel_source, rel_value=data + ) + ) except (TypeError, ValueError): - raise RestframeworkValidationError('Incorrect type. Expected {rel_source} value, got {data_type}.'.format( - rel_source=self.rel_source, data_type=type(data).__name__)) + raise RestframeworkValidationError( + "Incorrect type. Expected {rel_source} value, got {data_type}.".format( + rel_source=self.rel_source, data_type=type(data).__name__ + ) + ) def to_representation(self, value): return getattr(value, self.rel_source) @@ -50,20 +56,19 @@ def description(self, value): self._description = value def __init__(self, *args, **kwargs): - self.success = kwargs.pop('success', True) - self.description = kwargs.pop('description', 'ok') + self.success = kwargs.pop("success", True) + self.description = kwargs.pop("description", "ok") super(BaseSerializerV2, self).__init__(*args, **kwargs) @staticmethod - def response_envelope(result, success, description, field_errors=None, validation_errors=None): + def response_envelope( + result, success, description, field_errors=None, validation_errors=None + ): # assert isinstance(result, collections.Sequence) envelope = { - "status": { - "success": success, - "description": description - }, - "result": result + "status": {"success": success, "description": description}, + "result": result, } if field_errors is not None: @@ -79,9 +84,11 @@ def to_representation(self, instance): if self.parent is not None: return representation else: - return self.response_envelope(result=[representation], - success=self.success, - description=self.description) + return self.response_envelope( + result=[representation], + success=self.success, + description=self.description, + ) class ListSerializerV2(serializers.ListSerializer, BaseSerializerV2): @@ -90,9 +97,11 @@ def to_representation(self, instance): if self.parent is not None: return representation else: - return BaseSerializerV2.response_envelope(result=representation, - success=self.success, - description=self.description) + return BaseSerializerV2.response_envelope( + result=representation, + success=self.success, + description=self.description, + ) @property def data(self): @@ -100,8 +109,8 @@ def data(self): @property def errors(self): - if not hasattr(self, '_errors'): - msg = 'You must call `.is_valid()` before accessing `.errors`.' + if not hasattr(self, "_errors"): + msg = "You must call `.is_valid()` before accessing `.errors`." raise AssertionError(msg) base_errors_list = self._errors @@ -120,14 +129,16 @@ def errors(self): class DetailSerializerV2(BaseSerializerV2): - entityType = serializers.CharField(source='get_entity_class_name', max_length=254, read_only=True) - entityId = serializers.CharField(source='entity_id', max_length=254, read_only=True) + entityType = serializers.CharField( + source="get_entity_class_name", max_length=254, read_only=True + ) + entityId = serializers.CharField(source="entity_id", max_length=254, read_only=True) class Meta: list_serializer_class = ListSerializerV2 def get_model_class(self): - return getattr(self.Meta, 'model', None) + return getattr(self.Meta, "model", None) def create(self, validated_data): model_cls = self.get_model_class() @@ -143,7 +154,7 @@ def update(self, instance, validated_data): for field_name, value in list(validated_data.items()): setattr(instance, field_name, value) - save_kwargs = self.context.get('save_kwargs', dict()) + save_kwargs = self.context.get("save_kwargs", dict()) instance.save(**save_kwargs) return instance @@ -169,23 +180,27 @@ def validation_errors(self, value): self._validation_errors = value def __init__(self, *args, **kwargs): - self.field_errors = kwargs.pop('field_errors', False) - self.validation_errors = kwargs.pop('validation_errors', ['error']) + self.field_errors = kwargs.pop("field_errors", False) + self.validation_errors = kwargs.pop("validation_errors", ["error"]) super(V2ErrorSerializer, self).__init__(*args, **kwargs) def to_representation(self, instance): try: - self.validation_errors = self.validation_errors + self.field_errors.pop('non_field_errors', []) + self.validation_errors = self.validation_errors + self.field_errors.pop( + "non_field_errors", [] + ) except (TypeError, AttributeError): # In the case where field_errors is a list of dicts, we keep non_field_errors grouped with other field # errors for that object pass - return BaseSerializerV2.response_envelope(result=[], - success=self.success, - description=self.description, - field_errors=self.field_errors, - validation_errors=self.validation_errors) + return BaseSerializerV2.response_envelope( + result=[], + success=self.success, + description=self.description, + field_errors=self.field_errors, + validation_errors=self.validation_errors, + ) class Rfc7591ErrorSerializer(V2ErrorSerializer): @@ -193,8 +208,7 @@ def to_representation(self, instance): errors_list = self.validation_errors for key in self.field_errors.keys(): this_field_errors = self.field_errors.get(key) - errors_list.append("{}: {}".format(key, str(list_of(this_field_errors[0])[0]))) - return { - 'error': errors_list[0], - 'error_description': '; '.join(errors_list) - } + errors_list.append( + "{}: {}".format(key, str(list_of(this_field_errors[0])[0])) + ) + return {"error": errors_list[0], "error_description": "; ".join(errors_list)} diff --git a/apps/entity/views.py b/apps/entity/views.py index 04474c6c0..b674f2af0 100644 --- a/apps/entity/views.py +++ b/apps/entity/views.py @@ -12,21 +12,21 @@ def exception_handler(exc, context): - version = context.get('kwargs', {}).get('version', 'v1') + version = context.get("kwargs", {}).get("version", "v1") - if version in ['v2', 'rfc7591']: - description = 'miscellaneous error' + if version in ["v2", "rfc7591"]: + description = "miscellaneous error" field_errors = {} validation_errors = [] if isinstance(exc, exceptions.ParseError): - description = 'bad request' + description = "bad request" validation_errors = [exc.detail] response_code = status.HTTP_400_BAD_REQUEST elif isinstance(exc, exceptions.ValidationError): - description = 'bad request' + description = "bad request" if isinstance(exc.detail, list): validation_errors = exc.detail @@ -37,16 +37,18 @@ def exception_handler(exc, context): response_code = status.HTTP_400_BAD_REQUEST - elif isinstance(exc, (exceptions.AuthenticationFailed, exceptions.NotAuthenticated)): - description = 'no valid auth token found' + elif isinstance( + exc, (exceptions.AuthenticationFailed, exceptions.NotAuthenticated) + ): + description = "no valid auth token found" response_code = status.HTTP_401_UNAUTHORIZED elif isinstance(exc, CSRFPermissionDenied): - description = 'no valid csrf token found' + description = "no valid csrf token found" response_code = status.HTTP_401_UNAUTHORIZED elif isinstance(exc, (http.Http404, exceptions.PermissionDenied)): - description = 'entity not found or insufficient privileges' + description = "entity not found or insufficient privileges" response_code = status.HTTP_404_NOT_FOUND elif isinstance(exc, ProtectedError): @@ -65,46 +67,52 @@ def exception_handler(exc, context): else: # Unrecognized exception, return 500 error return None - if version == 'v2': + if version == "v2": serializer = V2ErrorSerializer( - instance={}, success=False, description=description, - field_errors=field_errors, validation_errors=validation_errors + instance={}, + success=False, + description=description, + field_errors=field_errors, + validation_errors=validation_errors, ) else: serializer = Rfc7591ErrorSerializer( - instance={}, field_errors=field_errors, validation_errors=validation_errors + instance={}, + field_errors=field_errors, + validation_errors=validation_errors, ) return Response(serializer.data, status=response_code) - elif version == 'bcv1': + elif version == "bcv1": # Badge Connect errors error = None status_code = status.HTTP_400_BAD_REQUEST - status_text = 'BAD_REQUEST' + status_text = "BAD_REQUEST" if isinstance(exc, exceptions.ParseError): error = exc.detail elif isinstance(exc, exceptions.ValidationError): error = exc.detail - status_text = 'REQUEST_VALIDATION_ERROR' + status_text = "REQUEST_VALIDATION_ERROR" elif isinstance(exc, exceptions.PermissionDenied): status_code = status.HTTP_401_UNAUTHORIZED - status_text = 'PERMISSION_DENIED' + status_text = "PERMISSION_DENIED" - elif isinstance(exc, (exceptions.AuthenticationFailed, exceptions.NotAuthenticated)): + elif isinstance( + exc, (exceptions.AuthenticationFailed, exceptions.NotAuthenticated) + ): status_code = status.HTTP_401_UNAUTHORIZED - status_text = 'UNAUTHENTICATED' + status_text = "UNAUTHENTICATED" elif isinstance(exc, exceptions.MethodNotAllowed): status_code = status.HTTP_405_METHOD_NOT_ALLOWED - status_text = 'METHOD_NOT_ALLOWED' + status_text = "METHOD_NOT_ALLOWED" - serializer = BadgeConnectErrorSerializer(instance={}, - error=error, - status_text=status_text, - status_code=status_code) + serializer = BadgeConnectErrorSerializer( + instance={}, error=error, status_text=status_text, status_code=status_code + ) return Response(serializer.data, status=status_code) else: diff --git a/apps/externaltools/__init__.py b/apps/externaltools/__init__.py deleted file mode 100644 index d4896b838..000000000 --- a/apps/externaltools/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# encoding: utf-8 - - - diff --git a/apps/externaltools/admin.py b/apps/externaltools/admin.py deleted file mode 100644 index 6602f42ab..000000000 --- a/apps/externaltools/admin.py +++ /dev/null @@ -1,38 +0,0 @@ -# encoding: utf-8 - - -from django.contrib.admin import ModelAdmin, TabularInline - -from externaltools.models import ExternalTool, ExternalToolLaunchpoint -from mainsite.admin import badgr_admin - - -class LaunchpointInline(TabularInline): - model = ExternalToolLaunchpoint - extra = 0 - fields = ('launchpoint', 'label', 'launch_url', 'icon_url') - - -class ExternalToolAdmin(ModelAdmin): - readonly_fields = ('created_at', 'created_by', 'updated_at', 'updated_by', 'entity_id') - list_display = ('name', 'entity_id', 'config_url', 'created_at') - list_filter = ('created_at',) - search_fields = ('name', 'config_url', 'xml_config', 'client_id') - fieldsets = ( - ('Metadata', { - 'fields': ('created_by', 'created_at', 'updated_by', 'updated_at', 'entity_id'), - 'classes': ("collapse",) - }), - (None, { - 'fields': ('is_active', 'requires_user_activation', 'name', 'config_url', 'client_id', 'client_secret') - }), - ('Config', { - 'fields': ('xml_config',) - }) - ) - inlines = [ - LaunchpointInline - ] - - -badgr_admin.register(ExternalTool, ExternalToolAdmin) diff --git a/apps/externaltools/api.py b/apps/externaltools/api.py deleted file mode 100644 index dcdaf5f1a..000000000 --- a/apps/externaltools/api.py +++ /dev/null @@ -1,55 +0,0 @@ -# encoding: utf-8 - - -from apispec_drf.decorators import apispec_list_operation -from rest_framework.exceptions import ValidationError - -from entity.api import BaseEntityListView, BaseEntityDetailView -from externaltools.models import ExternalTool, ExternalToolLaunchpoint -from externaltools.serializers_v1 import ExternalToolSerializerV1, ExternalToolLaunchSerializerV1 -from externaltools.serializers_v2 import ExternalToolSerializerV2, ExternalToolLaunchSerializerV2 -from mainsite.permissions import AuthenticatedWithVerifiedIdentifier - - -class ExternalToolList(BaseEntityListView): - model = ExternalTool - v1_serializer_class = ExternalToolSerializerV1 - v2_serializer_class = ExternalToolSerializerV2 - permission_classes = () - http_method_names = ['get'] - allow_any_unauthenticated_access = True - - def get_objects(self, request, **kwargs): - tools = list(ExternalTool.cached.global_tools()) - if self.request.user.is_authenticated: - tools.extend(request.user.cached_externaltools()) - return tools - - # @apispec_list_operation( - # 'ExternalTool', - # summary="Get a list of registered tools", - # tags=["External Tools"] - # ) - def get(self, request, **kwargs): - return super(ExternalToolList, self).get(request, **kwargs) - - -class ExternalToolLaunch(BaseEntityDetailView): - model = ExternalTool # used by VersionedObjectMixin - v1_serializer_class = ExternalToolLaunchSerializerV1 - v2_serializer_class = ExternalToolLaunchSerializerV2 - permission_classes = (AuthenticatedWithVerifiedIdentifier,) - http_method_names = ['get'] - - def get_context_data(self, **kwargs): - context = super(ExternalToolLaunch, self).get_context_data(**kwargs) - context['tool_launch_context_id'] = self.request.query_params.get('context_id', None) - return context - - def get_object(self, request, **kwargs): - externaltool = super(ExternalToolLaunch, self).get_object(request, **kwargs) - try: - launchpoint = externaltool.get_launchpoint(kwargs.get('launchpoint')) - except ExternalToolLaunchpoint.DoesNotExist: - raise ValidationError(["Unknown launchpoint"]) - return launchpoint diff --git a/apps/externaltools/migrations/0001_initial.py b/apps/externaltools/migrations/0001_initial.py deleted file mode 100644 index 2252371b8..000000000 --- a/apps/externaltools/migrations/0001_initial.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.7 on 2018-02-07 16:46 - - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ExternalTool', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('entity_version', models.PositiveIntegerField(default=1)), - ('entity_id', models.CharField(default=None, max_length=254, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('name', models.CharField(max_length=254)), - ('description', models.CharField(blank=True, max_length=254, null=True)), - ('xml_config', models.TextField()), - ('config_url', models.URLField(blank=True, null=True)), - ('client_id', models.CharField(blank=True, max_length=254, null=True)), - ('client_secret', models.CharField(blank=True, max_length=254, null=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), - ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='ExternalToolLaunchpoint', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('launchpoint', models.CharField(max_length=254)), - ('launch_url', models.URLField()), - ('label', models.CharField(max_length=254)), - ('icon_url', models.URLField(blank=True, null=True)), - ('externaltool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='externaltools.ExternalTool')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/apps/externaltools/migrations/0003_auto_20180411_0650.py b/apps/externaltools/migrations/0003_auto_20180411_0650.py deleted file mode 100644 index b3cadac4d..000000000 --- a/apps/externaltools/migrations/0003_auto_20180411_0650.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.7 on 2018-04-11 13:50 - - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('externaltools', '0002_externaltooluseractivation'), - ] - - operations = [ - migrations.AddField( - model_name='externaltool', - name='is_active', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='externaltool', - name='requires_user_activation', - field=models.BooleanField(default=True), - ), - ] diff --git a/apps/externaltools/migrations/0004_auto_20180411_0653.py b/apps/externaltools/migrations/0004_auto_20180411_0653.py deleted file mode 100644 index ab71a7f22..000000000 --- a/apps/externaltools/migrations/0004_auto_20180411_0653.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.7 on 2018-04-11 13:53 - - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('externaltools', '0003_auto_20180411_0650'), - ] - - operations = [ - migrations.AlterField( - model_name='externaltool', - name='xml_config', - field=models.TextField(blank=True, null=True), - ), - ] diff --git a/apps/externaltools/migrations/0005_auto_20180802_1026.py b/apps/externaltools/migrations/0005_auto_20180802_1026.py deleted file mode 100644 index 46e857c6a..000000000 --- a/apps/externaltools/migrations/0005_auto_20180802_1026.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-08-02 17:26 - - -from django.db import migrations -import django.db.models.manager - - -class Migration(migrations.Migration): - - dependencies = [ - ('externaltools', '0004_auto_20180411_0653'), - ] - - operations = [ - migrations.AlterModelManagers( - name='externaltool', - managers=[ - ('cached', django.db.models.manager.Manager()), - ], - ), - ] diff --git a/apps/externaltools/migrations/0006_auto_20181102_1438.py b/apps/externaltools/migrations/0006_auto_20181102_1438.py deleted file mode 100644 index e940dbafe..000000000 --- a/apps/externaltools/migrations/0006_auto_20181102_1438.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-11-02 21:38 - - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('externaltools', '0005_auto_20180802_1026'), - ] - - operations = [ - migrations.AlterField( - model_name='externaltool', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True), - ), - migrations.AlterField( - model_name='externaltooluseractivation', - name='created_at', - field=models.DateTimeField(auto_now_add=True, db_index=True), - ), - ] diff --git a/apps/externaltools/migrations/0007_auto_20190319_1111.py b/apps/externaltools/migrations/0007_auto_20190319_1111.py deleted file mode 100644 index ac6a349a7..000000000 --- a/apps/externaltools/migrations/0007_auto_20190319_1111.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.20 on 2019-03-19 18:11 - - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('externaltools', '0006_auto_20181102_1438'), - ] - - operations = [ - migrations.AlterField( - model_name='externaltoollaunchpoint', - name='launch_url', - field=models.CharField(max_length=1024), - ), - ] diff --git a/apps/externaltools/migrations/0008_auto_20200106_1621.py b/apps/externaltools/migrations/0008_auto_20200106_1621.py deleted file mode 100644 index 9b78345c5..000000000 --- a/apps/externaltools/migrations/0008_auto_20200106_1621.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.23 on 2020-01-07 00:21 - - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('externaltools', '0007_auto_20190319_1111'), - ] - - operations = [ - migrations.AlterField( - model_name='externaltool', - name='updated_at', - field=models.DateTimeField(auto_now=True, db_index=True), - ), - migrations.AlterField( - model_name='externaltooluseractivation', - name='updated_at', - field=models.DateTimeField(auto_now=True, db_index=True), - ), - ] diff --git a/apps/externaltools/migrations/0009_auto_20200817_1538.py b/apps/externaltools/migrations/0009_auto_20200817_1538.py deleted file mode 100644 index b0975adcb..000000000 --- a/apps/externaltools/migrations/0009_auto_20200817_1538.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 2.2.14 on 2020-08-17 22:38 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('externaltools', '0008_auto_20200106_1621'), - ] - - operations = [ - migrations.AlterField( - model_name='externaltool', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='externaltool', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='externaltooluseractivation', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='externaltooluseractivation', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), - ), - ] diff --git a/apps/externaltools/migrations/__init__.py b/apps/externaltools/migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/externaltools/models.py b/apps/externaltools/models.py deleted file mode 100644 index d93298076..000000000 --- a/apps/externaltools/models.py +++ /dev/null @@ -1,165 +0,0 @@ -# encoding: utf-8 - - -import urllib.request, urllib.parse, urllib.error - -import cachemodel -import lti -from django.core.cache import cache -from django.db import models -from lti import LaunchParams - -from entity.models import BaseVersionedEntity -from issuer.models import BaseAuditedModel, BadgeInstance -from mainsite.utils import OriginSetting, get_tool_consumer_instance_guid - - -class ExternalToolManager(cachemodel.CacheModelManager): - global_tools_cache_key = "global_cached_externaltools" - - def global_tools(self): - tools = cache.get(self.global_tools_cache_key) - if tools is None: - return self.publish_global_tools() - return tools - - def publish_global_tools(self): - tools = self.filter(is_active=True, requires_user_activation=False) - cache.set(self.global_tools_cache_key, tools, timeout=None) - return tools - - -class ExternalTool(BaseAuditedModel, BaseVersionedEntity): - name = models.CharField(max_length=254) - description = models.CharField(max_length=254, blank=True, null=True) - xml_config = models.TextField(blank=True, null=True) - config_url = models.URLField(blank=True, null=True) - client_id = models.CharField(max_length=254, blank=True, null=True) - client_secret = models.CharField(max_length=254, blank=True, null=True) - requires_user_activation = models.BooleanField(default=True) - is_active = models.BooleanField(default=True) - - cached = ExternalToolManager() - - def __str__(self): - return self.name - - def publish(self): - super(ExternalTool, self).publish() - ExternalTool.cached.publish_global_tools() - - def get_lti_config(self): - return lti.ToolConfig.create_from_xml(self.xml_config) - - @cachemodel.cached_method(auto_publish=True) - def cached_launchpoints(self): - return self.externaltoollaunchpoint_set.all() - - def get_launchpoint(self, launchpoint_name): - try: - return next(lp for lp in self.cached_launchpoints() if lp.launchpoint == launchpoint_name) - except StopIteration: - raise ExternalToolLaunchpoint.DoesNotExist - - -class ExternalToolLaunchpoint(cachemodel.CacheModel): - externaltool = models.ForeignKey('externaltools.ExternalTool', - on_delete=models.CASCADE) - launchpoint = models.CharField(max_length=254) - launch_url = models.CharField(max_length=1024) - label = models.CharField(max_length=254) - icon_url = models.URLField(blank=True, null=True) - - def publish(self): - super(ExternalToolLaunchpoint, self).publish() - self.externaltool.publish() - - def delete(self, *args, **kwargs): - super(ExternalToolLaunchpoint, self).delete(*args, **kwargs) - self.externaltool.publish() - - @property - def cached_externaltool(self): - return ExternalTool.cached.get(pk=self.externaltool_id) - - def get_tool_consumer(self, extra_params=None): - params = dict( - lti_message_type="basic-lti-launch-request", - lti_version="1.1", - resource_link_id=self.pk, - ) - if extra_params: - params.update(extra_params) - - return lti.ToolConsumer( - consumer_key=self.cached_externaltool.client_id, - consumer_secret=self.cached_externaltool.client_secret, - launch_url=self.launch_url, - params=params - ) - - def lookup_obj_by_launchpoint(self, launch_data, user, context_id): - roles = [] - obj = None - if self.launchpoint in ['issuer_assertion_action', 'earner_assertion_action']: - obj = BadgeInstance.cached.get_by_slug_or_entity_id(context_id) - if obj: - launch_data.update( - custom_badgr_assertion_recipient=obj.recipient_identifier, - custom_badgr_assertion_id=obj.entity_id, - custom_badgr_badgeclass_id=obj.cached_badgeclass.entity_id, - custom_badgr_badgeclass_name=urllib.parse.quote_plus(obj.cached_badgeclass.name.encode('utf-8')), - custom_badgr_issuer_id=obj.cached_issuer.entity_id, - custom_badgr_issuer_name=urllib.parse.quote_plus(obj.cached_issuer.name.encode('utf-8')), - ) - if any(s.user.id == user.id for s in obj.cached_issuer.cached_issuerstaff()): - roles.append('issuer') - - if obj.recipient_identifier in user.all_verified_recipient_identifiers: - roles.append('earner') - - launch_data['roles'] = roles - return obj - - def generate_launch_data(self, user=None, context_id=None, **additional_launch_data): - params = dict( - tool_consumer_instance_guid=get_tool_consumer_instance_guid(), - custom_badgr_api_url=OriginSetting.HTTP, - custom_launchpoint=self.launchpoint - ) - params.update(additional_launch_data) - if user is not None: - params.update(dict( - custom_badgr_user_id=user.entity_id, - lis_person_name_family=urllib.parse.quote_plus(user.last_name.encode('utf-8')), - lis_person_name_given=urllib.parse.quote_plus(user.first_name.encode('utf-8')), - lis_person_contact_email_primary=urllib.parse.quote_plus(user.primary_email.encode('utf-8')) - )) - if context_id is not None: - params['custom_context_id'] = context_id - context_obj = self.lookup_obj_by_launchpoint(params, user, context_id) - - tool_consumer = self.get_tool_consumer(extra_params=params) - launch_data = tool_consumer.generate_launch_data() - return launch_data - - -class ExternalToolUserActivation(BaseAuditedModel, cachemodel.CacheModel): - externaltool = models.ForeignKey('externaltools.ExternalTool', - on_delete=models.CASCADE) - user = models.ForeignKey('badgeuser.BadgeUser', - on_delete=models.CASCADE) - is_active = models.BooleanField(default=True, db_index=True) - - - def publish(self): - super(ExternalToolUserActivation, self).publish() - self.user.publish() - - def delete(self, *args, **kwargs): - super(ExternalToolUserActivation, self).delete(*args, **kwargs) - self.user.publish() - - @property - def cached_externaltool(self): - return ExternalTool.cached.get(pk=self.externaltool_id) diff --git a/apps/externaltools/serializers_v1.py b/apps/externaltools/serializers_v1.py deleted file mode 100644 index cbb06b8da..000000000 --- a/apps/externaltools/serializers_v1.py +++ /dev/null @@ -1,41 +0,0 @@ -# encoding: utf-8 - - -from django.urls import reverse -from rest_framework import serializers - -from mainsite.serializers import StripTagsCharField -from mainsite.utils import OriginSetting - - -class ExternalToolSerializerV1(serializers.Serializer): - name = StripTagsCharField(max_length=254) - client_id = StripTagsCharField(max_length=254) - slug = StripTagsCharField(max_length=255, source='entity_id', read_only=True) - - def to_representation(self, instance): - representation = super(ExternalToolSerializerV1, self).to_representation(instance) - representation['launchpoints'] = { - lp.launchpoint: { - "url": "{}{}".format(OriginSetting.HTTP, reverse("v1_api_externaltools_launch", kwargs=dict( - launchpoint=lp.launchpoint, - slug=lp.cached_externaltool.entity_id - ))), - "launch_url": lp.launch_url, - "label": lp.label, - "icon_url": lp.icon_url - } for lp in instance.cached_launchpoints() - } - return representation - - -class ExternalToolLaunchSerializerV1(serializers.Serializer): - launch_url = serializers.URLField() - - def to_representation(self, instance): - representation = super(ExternalToolLaunchSerializerV1, self).to_representation(instance) - requesting_user = self.context['request'].user if 'request' in self.context else None - context_id = self.context.get('tool_launch_context_id', None) - representation['launch_data'] = instance.generate_launch_data(user=requesting_user, context_id=context_id) - - return representation diff --git a/apps/externaltools/serializers_v2.py b/apps/externaltools/serializers_v2.py deleted file mode 100644 index e5fff7f42..000000000 --- a/apps/externaltools/serializers_v2.py +++ /dev/null @@ -1,39 +0,0 @@ -# encoding: utf-8 - - -from django.urls import reverse -from rest_framework import serializers - -from entity.serializers import DetailSerializerV2 -from externaltools.models import ExternalTool -from mainsite.serializers import StripTagsCharField -from mainsite.utils import OriginSetting - - -class ExternalToolSerializerV2(DetailSerializerV2): - name = StripTagsCharField(max_length=254) - clientId = StripTagsCharField(max_length=254, source='client_id') - - class Meta(DetailSerializerV2.Meta): - model = ExternalTool - # apispec_definition = ('ExternalTool', {}) - - def to_representation(self, instance): - representation = super(ExternalToolSerializerV2, self).to_representation(instance) - representation['launchpoints'] = { - lp.launchpoint: { - "url": "{}{}".format(OriginSetting.HTTP, reverse("v2_api_externaltools_launch", kwargs=dict( - launchpoint=lp.launchpoint, - entity_id=lp.cached_externaltool.entity_id - ))), - "launchUrl": lp.launch_url, - "label": lp.label, - "iconUrl": lp.icon_url - } for lp in instance.cached_launchpoints() - } - return representation - - -class ExternalToolLaunchSerializerV2(DetailSerializerV2): - launchUrl = serializers.URLField(source='launch_url') - launchData = serializers.DictField(source='generate_launch_data') \ No newline at end of file diff --git a/apps/externaltools/v1_api_urls.py b/apps/externaltools/v1_api_urls.py deleted file mode 100644 index 96d2bd820..000000000 --- a/apps/externaltools/v1_api_urls.py +++ /dev/null @@ -1,11 +0,0 @@ -# encoding: utf-8 - - -from django.conf.urls import url - -from externaltools.api import ExternalToolList, ExternalToolLaunch - -urlpatterns = [ - url(r'^$', ExternalToolList.as_view(), name='v1_api_externaltools_list'), - url(r'^launch/(?P[^/]+)/(?P[^/]+)$', ExternalToolLaunch.as_view(), name='v1_api_externaltools_launch'), -] \ No newline at end of file diff --git a/apps/externaltools/v2_api_urls.py b/apps/externaltools/v2_api_urls.py deleted file mode 100644 index f9fa96d1b..000000000 --- a/apps/externaltools/v2_api_urls.py +++ /dev/null @@ -1,11 +0,0 @@ -# encoding: utf-8 - - -from django.conf.urls import url - -from externaltools.api import ExternalToolList, ExternalToolLaunch - -urlpatterns = [ - url(r'^$', ExternalToolList.as_view(), name='v2_api_externaltools_list'), - url(r'^launch/(?P[^/]+)/(?P[^/]+)$', ExternalToolLaunch.as_view(), name='v2_api_externaltools_launch'), -] diff --git a/apps/health/urls.py b/apps/health/urls.py index 413eb2cb3..74dd9a3cf 100644 --- a/apps/health/urls.py +++ b/apps/health/urls.py @@ -1,7 +1,7 @@ -from django.conf.urls import url +from django.urls import re_path from .views import health urlpatterns = [ - url(r'^$', health, name='server_health'), + re_path(r"^$", health, name="server_health"), ] diff --git a/apps/health/views.py b/apps/health/views.py index e9f6a845a..3b5f0907e 100644 --- a/apps/health/views.py +++ b/apps/health/views.py @@ -3,16 +3,16 @@ Thanks to edx.org for endpoint design pattern. Licensed by edX under aGPL. https://github.com/edx/ecommerce/blob/master/LICENSE.txt """ + # import logger # TODO integrate logging into results of health endpoint queries # import requests # use for making requests to any dependency HTTP APIs. from rest_framework import status -from django.conf import settings from django.db import connection, DatabaseError from django.http import JsonResponse -OK = 'OK' -UNAVAILABLE = 'UNAVAILABLE' +OK = "OK" +UNAVAILABLE = "UNAVAILABLE" def health(req): @@ -48,11 +48,11 @@ def health(req): overall_status = OK if (database_status == OK) else UNAVAILABLE data = { - 'overall_status': overall_status, - 'detailed_status': { - 'database_status': database_status + "overall_status": overall_status, + "detailed_status": { + "database_status": database_status # Future: Report any other dependency statuses here. - } + }, } if overall_status == OK: diff --git a/apps/issuer/__init__.py b/apps/issuer/__init__.py index e69de29bb..e9d6ba85a 100644 --- a/apps/issuer/__init__.py +++ b/apps/issuer/__init__.py @@ -0,0 +1 @@ +default_app_config = "issuer.apps.IssuerConfig" diff --git a/apps/issuer/admin.py b/apps/issuer/admin.py index 9edcc3cdb..a85ebae1e 100644 --- a/apps/issuer/admin.py +++ b/apps/issuer/admin.py @@ -1,57 +1,276 @@ +from django.db import models from django.contrib.admin import ModelAdmin, StackedInline, TabularInline from django.urls import reverse -from django.http import HttpResponseRedirect +from django.http import HttpResponseRedirect, HttpResponse from django_object_actions import DjangoObjectActions from django.utils.safestring import mark_safe +from django import forms +from django.contrib import admin from mainsite.admin import badgr_admin -from mainsite.mixins import ResizeUploadedImage -from .models import Issuer, BadgeClass, BadgeInstance, BadgeInstanceEvidence, BadgeClassAlignment, BadgeClassTag, \ - BadgeClassExtension, IssuerExtension, BadgeInstanceExtension +from .models import ( + BadgeClassNetworkShare, + ImportedBadgeAssertionExtension, + Issuer, + BadgeClass, + Area, + BadgeInstance, + BadgeInstanceEvidence, + BadgeClassAlignment, + BadgeClassTag, + BadgeClassExtension, + IssuerExtension, + BadgeInstanceExtension, + IssuerStaff, + LearningPath, + LearningPathBadge, + LearningPathTag, + NetworkInvite, + NetworkMembership, + RequestedBadge, + QrCode, + RequestedLearningPath, + IssuerStaffRequest, + ImportedBadgeAssertion, +) from .tasks import resend_notifications +import csv + + +@admin.action(description="Export selected institutions to CSV") +def export_institutions_csv(modeladmin, request, queryset): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="institutions.csv"' + + writer = csv.writer(response) + writer.writerow(["Institution", "Email", "Member", "Badges", "Assertions"]) + + for issuer in queryset: + staff_entries = IssuerStaff.objects.filter(issuer=issuer).select_related("user") + + staff_list = [ + f"{staff.user.get_full_name()} – {staff.role} - {staff.user.email}" + for staff in staff_entries + ] + + badge_count = issuer.badgeclasses.count() if issuer.badgeclasses else 0 + + assertion_count = ( + BadgeClass.objects.filter(issuer=issuer) + .annotate( + number_of_assertions=models.Count( + "badgeinstances", filter=models.Q(badgeinstances__revoked=False) + ) + ) + .aggregate(total=models.Sum("number_of_assertions"))["total"] + or 0 + ) + + writer.writerow( + [ + issuer.name, + issuer.email, + "\n".join(staff_list), + badge_count, + assertion_count, + ] + ) + + return response + + +class ReadOnlyInline(TabularInline): + def has_change_permission(self, request, obj=None): + return False + + def has_add_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def get_readonly_fields(self, request, obj=None): + return list(super().get_fields(request, obj)) class IssuerStaffInline(TabularInline): model = Issuer.staff.through extra = 0 - raw_id_fields = ('user',) + raw_id_fields = ("user",) class IssuerExtensionInline(TabularInline): model = IssuerExtension extra = 0 - fields = ('name', 'original_json') + fields = ("name", "original_json") + + +class IssuerBadgeclasses(ReadOnlyInline): + model = BadgeClass + extra = 0 + fields = ("name", "assertion_count", "qrcode_count") + + def get_queryset(self, request): + qs = super(IssuerBadgeclasses, self).get_queryset(request) + qs = qs.annotate( + number_of_assertions=models.Count( + "badgeinstances", + filter=models.Q(badgeinstances__revoked=False), + distinct=True, + ) + ) + qs = qs.annotate(number_of_qrcodes=models.Count("qrcodes", distinct=True)) + return qs + + def assertion_count(self, obj): + return obj.number_of_assertions + + def qrcode_count(self, obj): + return obj.number_of_qrcodes + + +class NetworkMembershipsInline(ReadOnlyInline): + """Inline to show which networks this issuer is a member of""" + + model = NetworkMembership + fk_name = "issuer" + extra = 0 + fields = ("network_name", "network_link") + + def network_name(self, obj): + return obj.network.name if obj.network else "N/A" + + network_name.short_description = "Network Name" + + def network_link(self, obj): + if obj.network: + return mark_safe( + '{}'.format( + reverse("admin:issuer_issuer_change", args=(obj.network.id,)), + obj.network.name, + ) + ) + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.select_related("network") + + +class PartnerIssuersInline(ReadOnlyInline): + """Inline to show partner issuers for networks""" + + model = NetworkMembership + fk_name = "network" + extra = 0 + fields = ("issuer_name", "issuer_link", "badge_count") + + def issuer_name(self, obj): + return obj.issuer.name if obj.issuer else "N/A" + + issuer_name.short_description = "Partner Issuer" + + def issuer_link(self, obj): + if obj.issuer: + return mark_safe( + '{}'.format( + reverse("admin:issuer_issuer_change", args=(obj.issuer.id,)), + obj.issuer.name, + ) + ) + + def badge_count(self, obj): + if obj.issuer: + return obj.issuer.badgeclasses.count() + return 0 + + badge_count.short_description = "Badge Classes" + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.select_related("issuer") class IssuerAdmin(DjangoObjectActions, ModelAdmin): - readonly_fields = ('created_by', 'created_at', 'updated_at', 'old_json', 'source', 'source_url', 'entity_id', 'slug') - list_display = ('img', 'name', 'entity_id', 'created_by', 'created_at') - list_display_links = ('img', 'name') - list_filter = ('created_at',) - search_fields = ('name', 'entity_id') + readonly_fields = ( + "created_by", + "created_at", + "updated_at", + "old_json", + "source", + "source_url", + "entity_id", + "slug", + ) + list_display = ("img", "name", "created_by", "created_at", "badge_count", "zip") + list_display_links = ("img", "name") + list_filter = ("created_at",) + search_fields = ("name", "entity_id") fieldsets = ( - ('Metadata', { - 'fields': ('created_by', 'created_at', 'updated_at', 'source', 'source_url', 'entity_id', 'slug'), - 'classes': ("collapse",) - }), - (None, { - 'fields': ('image', 'name', 'url', 'email', 'verified', 'description', 'category', 'street', 'streetnumber', 'zip', 'city', 'badgrapp', 'lat', 'lon') - }), - ('JSON', { - 'fields': ('old_json',) - }), + ( + "Metadata", + { + "fields": ( + "created_by", + "created_at", + "updated_at", + "source", + "source_url", + "entity_id", + "slug", + ), + "classes": ("collapse",), + }, + ), + ( + None, + { + "fields": ( + "image", + "name", + "url", + "email", + "verified", + "intendedUseVerified", + "is_network", + "description", + "category", + "street", + "streetnumber", + "zip", + "city", + "badgrapp", + "lat", + "lon", + ) + }, + ), + ("JSON", {"fields": ("old_json",)}), ) - inlines = [ - IssuerStaffInline, - IssuerExtensionInline - ] - change_actions = ['redirect_badgeclasses'] + + def get_inlines(self, request, obj): + inlines = [IssuerStaffInline, IssuerExtensionInline, IssuerBadgeclasses] + + if obj: + if obj.is_network: + inlines.extend([PartnerIssuersInline]) + else: + inlines.extend([NetworkMembershipsInline]) + + return inlines + + change_actions = ["redirect_badgeclasses"] + actions = [export_institutions_csv] + + def get_queryset(self, request): + qs = super(IssuerAdmin, self).get_queryset(request) + qs = qs.annotate(number_of_badges=models.Count("badgeclasses")) + return qs def save_model(self, request, obj, form, change): force_resize = False - if 'image' in form.changed_data: + if "image" in form.changed_data: force_resize = True obj.save(force_resize=force_resize) @@ -60,145 +279,309 @@ def img(self, obj): return mark_safe(''.format(obj.image.url)) except ValueError: return obj.image - img.short_description = 'Image' + + img.short_description = "Image" img.allow_tags = True + def badge_count(self, obj): + return obj.number_of_badges + + badge_count.admin_order_field = "number_of_badges" + def redirect_badgeclasses(self, request, obj): return HttpResponseRedirect( - reverse('admin:issuer_badgeclass_changelist') + '?issuer__id={}'.format(obj.id) + reverse("admin:issuer_badgeclass_changelist") + + "?issuer__id={}".format(obj.id) ) + redirect_badgeclasses.label = "BadgeClasses" redirect_badgeclasses.short_description = "See this issuer's defined BadgeClasses" + badgr_admin.register(Issuer, IssuerAdmin) class BadgeClassAlignmentInline(TabularInline): model = BadgeClassAlignment extra = 0 - fields = ('target_name','target_url','target_description', 'target_framework','target_code') + fields = ( + "target_name", + "target_url", + "target_description", + "target_framework", + "target_code", + ) class BadgeClassTagInline(TabularInline): model = BadgeClassTag extra = 0 - fields = ('name',) + fields = ("name",) class BadgeClassExtensionInline(TabularInline): model = BadgeClassExtension extra = 0 - fields = ('name', 'original_json') + fields = ("name", "original_json") + + +class BinaryMultipleChoiceField(forms.MultipleChoiceField): + widget = forms.CheckboxSelectMultiple + + def to_python(self, value): + if not value: + return 0 + else: + return sum(map(int, value)) + + def prepare_value(self, value): + binary = bin(value)[:1:-1] + ret = [pow(int(x) * 2, i) for i, x in enumerate(binary) if int(x)] + return ret + + def validate(self, value): + return isinstance(value, int) + + def has_changed(self, initial, data): + return initial != data + + +class BadgeModelForm(forms.ModelForm): + copy_permissions = BinaryMultipleChoiceField( + required=False, + choices=BadgeClass.COPY_PERMISSIONS_CHOICES, + ) + + class Meta: + exclude = [] + model = BadgeClass class BadgeClassAdmin(DjangoObjectActions, ModelAdmin): - readonly_fields = ('created_by', 'created_at', 'updated_at', 'old_json', 'source', 'source_url', 'entity_id', 'slug') - list_display = ('badge_image', 'name', 'entity_id', 'issuer_link') - list_display_links = ('badge_image', 'name',) - list_filter = ('created_at',) - search_fields = ('name', 'entity_id', 'issuer__name',) - raw_id_fields = ('issuer',) + form = BadgeModelForm + + readonly_fields = ( + "created_by", + "created_at", + "updated_at", + "old_json", + "source", + "source_url", + "entity_id", + "slug", + "criteria", + ) + list_display = ("badge_image", "name", "issuer_link", "assertion_count") + list_display_links = ( + "badge_image", + "name", + ) + list_filter = ("created_at",) + search_fields = ( + "name", + "entity_id", + "issuer__name", + ) + raw_id_fields = ("issuer",) fieldsets = ( - ('Metadata', { - 'fields': ('created_by', 'created_at', 'updated_at', 'source', 'source_url', 'entity_id', 'slug'), - 'classes': ("collapse",) - }), - (None, { - 'fields': ('issuer', 'image', 'name', 'description') - }), - ('Configuration', { - 'fields': ('criteria_url', 'criteria_text', 'expires_duration', 'expires_amount',) - }), - ('JSON', { - 'fields': ('old_json',) - }), + ( + "Metadata", + { + "fields": ( + "created_by", + "created_at", + "updated_at", + "source", + "source_url", + "entity_id", + "slug", + ), + "classes": ("collapse",), + }, + ), + (None, {"fields": ("issuer", "image", "imageFrame", "name", "description")}), + ( + "Configuration", + { + "fields": ( + "criteria_url", + "criteria_text", + "expiration", + "copy_permissions", + "course_url", + "areas", + ) + }, + ), + ("JSON", {"fields": ("old_json", "criteria")}), ) + filter_horizontal = ("areas",) inlines = [ BadgeClassTagInline, BadgeClassAlignmentInline, BadgeClassExtensionInline, ] - change_actions = ['redirect_issuer', 'redirect_instances'] + change_actions = ["redirect_issuer", "redirect_instances"] + + def get_queryset(self, request): + qs = super(BadgeClassAdmin, self).get_queryset(request) + qs = qs.annotate( + number_of_assertions=models.Count( + "badgeinstances", filter=models.Q(badgeinstances__revoked=False) + ) + ) + return qs def save_model(self, request, obj, form, change): force_resize = False - if 'image' in form.changed_data: + if "image" in form.changed_data: force_resize = True obj.save(force_resize=force_resize) def badge_image(self, obj): - return mark_safe(''.format(obj.image.url)) if obj.image else '' - badge_image.short_description = 'Badge' + return ( + mark_safe(''.format(obj.image.url)) + if obj.image + else "" + ) + + badge_image.short_description = "Badge" badge_image.allow_tags = True def issuer_link(self, obj): - return mark_safe('{}'.format(reverse("admin:issuer_issuer_change", args=(obj.issuer.id,)), obj.issuer.name)) - issuer_link.allow_tags=True + return mark_safe( + '{}'.format( + reverse("admin:issuer_issuer_change", args=(obj.issuer.id,)), + obj.issuer.name, + ) + ) + + issuer_link.allow_tags = True + issuer_link.admin_order_field = "issuer" def redirect_instances(self, request, obj): return HttpResponseRedirect( - reverse('admin:issuer_badgeinstance_changelist') + '?badgeclass__id={}'.format(obj.id) + reverse("admin:issuer_badgeinstance_changelist") + + "?badgeclass__id={}".format(obj.id) ) + redirect_instances.label = "Instances" redirect_instances.short_description = "See awarded instances of this BadgeClass" def redirect_issuer(self, request, obj): return HttpResponseRedirect( - reverse('admin:issuer_issuer_change', args=(obj.issuer.id,)) + reverse("admin:issuer_issuer_change", args=(obj.issuer.id,)) ) + redirect_issuer.label = "Issuer" redirect_issuer.short_description = "See this Issuer" + def assertion_count(self, obj): + return obj.number_of_assertions + + assertion_count.admin_order_field = "number_of_assertions" + + badgr_admin.register(BadgeClass, BadgeClassAdmin) class BadgeEvidenceInline(StackedInline): model = BadgeInstanceEvidence - fields = ('evidence_url', 'narrative',) + fields = ( + "evidence_url", + "narrative", + ) extra = 0 class BadgeInstanceExtensionInline(TabularInline): model = BadgeInstanceExtension extra = 0 - fields = ('name', 'original_json') + fields = ("name", "original_json") class BadgeInstanceAdmin(DjangoObjectActions, ModelAdmin): - readonly_fields = ('created_at', 'created_by', 'updated_at', 'image', 'entity_id', 'old_json', 'salt', 'entity_id', 'slug', 'source', 'source_url') - list_display = ('badge_image', 'recipient_identifier', 'entity_id', 'badgeclass', 'issuer') - list_display_links = ('badge_image', 'recipient_identifier', ) - list_filter = ('created_at',) - search_fields = ('recipient_identifier', 'entity_id', 'badgeclass__name', 'issuer__name') - raw_id_fields = ('badgeclass', 'issuer') + readonly_fields = ( + "created_at", + "created_by", + "updated_at", + "image", + "entity_id", + "old_json", + "salt", + "entity_id", + "slug", + "source", + "source_url", + ) + list_display = ( + "badge_image", + "recipient_identifier", + "entity_id", + "badgeclass", + "issuer", + ) + list_display_links = ( + "badge_image", + "recipient_identifier", + ) + list_filter = ("created_at",) + search_fields = ( + "recipient_identifier", + "entity_id", + "badgeclass__name", + "issuer__name", + ) + raw_id_fields = ("badgeclass", "issuer") fieldsets = ( - ('Metadata', { - 'fields': ('source', 'source_url', 'created_by', 'created_at', 'updated_at', 'slug', 'salt'), - 'classes': ("collapse",) - }), - ('Badgeclass', { - 'fields': ('badgeclass', 'issuer') - }), - ('Assertion', { - 'fields': ('entity_id', 'acceptance', 'recipient_type', 'recipient_identifier', 'image', 'issued_on', 'expires_at', 'narrative') - }), - ('Revocation', { - 'fields': ('revoked', 'revocation_reason') - }), - ('JSON', { - 'fields': ('old_json',) - }), - ) - actions = ['rebake', 'resend_notifications'] - change_actions = ['redirect_issuer', 'redirect_badgeclass'] - inlines = [ - BadgeEvidenceInline, - BadgeInstanceExtensionInline - ] + ( + "Metadata", + { + "fields": ( + "source", + "source_url", + "created_by", + "created_at", + "updated_at", + "slug", + "salt", + ), + "classes": ("collapse",), + }, + ), + ("Badgeclass", {"fields": ("badgeclass", "issuer")}), + ( + "Assertion", + { + "fields": ( + "entity_id", + "acceptance", + "recipient_type", + "recipient_identifier", + "image", + "issued_on", + "expires_at", + "activity_start_date", + "activity_end_date", + "activity_zip", + "activity_city", + "activity_online", + "course_url", + "narrative", + ) + }, + ), + ("Revocation", {"fields": ("revoked", "revocation_reason")}), + ("JSON", {"fields": ("old_json",)}), + ) + actions = ["rebake", "resend_notifications"] + change_actions = ["redirect_issuer", "redirect_badgeclass"] + inlines = [BadgeEvidenceInline, BadgeInstanceExtensionInline] def rebake(self, request, queryset): for obj in queryset: obj.rebake(save=True) + rebake.short_description = "Rebake selected badge instances" def badge_image(self, obj): @@ -206,7 +589,8 @@ def badge_image(self, obj): return mark_safe(''.format(obj.image.url)) except ValueError: return obj.image - badge_image.short_description = 'Badge' + + badge_image.short_description = "Badge" badge_image.allow_tags = True def has_add_permission(self, request): @@ -214,34 +598,236 @@ def has_add_permission(self, request): def redirect_badgeclass(self, request, obj): return HttpResponseRedirect( - reverse('admin:issuer_badgeclass_change', args=(obj.badgeclass.id,)) + reverse("admin:issuer_badgeclass_change", args=(obj.badgeclass.id,)) ) + redirect_badgeclass.label = "BadgeClass" redirect_badgeclass.short_description = "See this BadgeClass" def redirect_issuer(self, request, obj): return HttpResponseRedirect( - reverse('admin:issuer_issuer_change', args=(obj.issuer.id,)) + reverse("admin:issuer_issuer_change", args=(obj.issuer.id,)) ) + redirect_issuer.label = "Issuer" redirect_issuer.short_description = "See this Issuer" def resend_notifications(self, request, queryset): - ids_dict = queryset.only('entity_id').values() - ids = [i['entity_id'] for i in ids_dict] + ids_dict = queryset.only("entity_id").values() + ids = [i["entity_id"] for i in ids_dict] resend_notifications.delay(ids) def save_model(self, request, obj, form, change): obj.rebake(save=False) super().save_model(request, obj, form, change) + badgr_admin.register(BadgeInstance, BadgeInstanceAdmin) +class ImportedBadgeAssertionExtensionInline(TabularInline): + model = ImportedBadgeAssertionExtension + extra = 0 + fields = ("name", "original_json") + + +class ImportedBadgeAssertionAdmin(ModelAdmin): + readonly_fields = ( + "created_at", + "created_by", + "updated_at", + "entity_id", + "issuer_image_url", + "badge_image_url", + ) + list_display = ( + "recipient_identifier", + "entity_id", + "badge_name", + "badge_description", + ) + list_display_links = ("recipient_identifier",) + list_filter = ("created_at",) + inlines = [ImportedBadgeAssertionExtensionInline] + fieldsets = ( + ( + "Metadata", + { + "fields": ( + "source", + "source_url", + "created_by", + "created_at", + "updated_at", + "salt", + ), + "classes": ("collapse",), + }, + ), + ( + "Assertion", + { + "fields": ( + "entity_id", + "acceptance", + "recipient_type", + "recipient_identifier", + "issued_on", + "expires_at", + "narrative", + "badge_image_url", + "issuer_image_url", + ) + }, + ), + ("Revocation", {"fields": ("revoked", "revocation_reason")}), + ("JSON", {"fields": ("original_json",)}), + ) + + +badgr_admin.register(ImportedBadgeAssertion, ImportedBadgeAssertionAdmin) + + class ExtensionAdmin(ModelAdmin): - list_display = ('name',) - search_fields = ('name', 'original_json') + list_display = ("name",) + search_fields = ("name", "original_json") + badgr_admin.register(IssuerExtension, ExtensionAdmin) badgr_admin.register(BadgeClassExtension, ExtensionAdmin) badgr_admin.register(BadgeInstanceExtension, ExtensionAdmin) +badgr_admin.register(ImportedBadgeAssertionExtension, ExtensionAdmin) + + +class ReqeustedBadgeAdmin(ModelAdmin): + list_display = ( + "firstName", + "lastName", + "email", + "badgeclass", + "user", + "requestedOn", + "status", + ) + readonly_fields = ("requestedOn", "status") + + +badgr_admin.register(RequestedBadge, ReqeustedBadgeAdmin) + + +class IssuerStaffRequestAdmin(ModelAdmin): + list_display = ("issuer", "user", "requestedOn", "status") + readonly_fields = ("requestedOn", "status") + + +badgr_admin.register(IssuerStaffRequest, IssuerStaffRequestAdmin) + + +class NetworkInviteAdmin(ModelAdmin): + list_display = ("network", "issuer", "invitedOn", "status") + readonly_fields = ("invitedOn", "status") + + +badgr_admin.register(NetworkInvite, NetworkInviteAdmin) + + +class QrCodeAdmin(ModelAdmin): + list_display = ("title", "createdBy", "valid_from", "expires_at") + + +badgr_admin.register(QrCode, QrCodeAdmin) + + +class ReqeustedLearningPathAdmin(ModelAdmin): + list_display = ("learningpath", "user", "requestedOn", "status") + readonly_fields = ("requestedOn", "status") + + +badgr_admin.register(RequestedLearningPath, ReqeustedLearningPathAdmin) + + +class LearningPathTagInline(TabularInline): + model = LearningPathTag + extra = 0 + fields = ("name",) + + +class LearningPathBadgeInline(TabularInline): + model = LearningPathBadge + extra = 0 + fields = ("badge", "order") + + +class LearningPathAdmin(ModelAdmin): + list_display = ("name", "issuer", "required_badges_count") + search_fields = ("name", "description") + inlines = [LearningPathTagInline, LearningPathBadgeInline] + + +badgr_admin.register(LearningPath, LearningPathAdmin) + + +class BadgeClassNetworkShareAdmin(ModelAdmin): + list_display = ( + "badgeclass_name", + "issuer_name", + "network_name", + "shared_by_user", + "shared_at", + "is_active", + ) + list_filter = ( + "is_active", + "shared_at", + "network__name", + ) + search_fields = ( + "badgeclass__name", + "badgeclass__issuer__name", + "network__name", + "shared_by_user__email", + "shared_by_user__first_name", + "shared_by_user__last_name", + ) + readonly_fields = ( + "shared_at", + "shared_by_issuer_display", + ) + date_hierarchy = "shared_at" + + def badgeclass_name(self, obj): + """Display the badge class name""" + return obj.badgeclass.name + + badgeclass_name.short_description = "Badge Class" + badgeclass_name.admin_order_field = "badgeclass__name" + + def issuer_name(self, obj): + """Display the issuer name that owns the badge""" + return obj.badgeclass.issuer.name + + issuer_name.short_description = "Issuer" + issuer_name.admin_order_field = "badgeclass__issuer__name" + + def network_name(self, obj): + """Display the network name""" + return obj.network.name + + network_name.short_description = "Network" + network_name.admin_order_field = "network__name" + + def shared_by_issuer_display(self, obj): + """Display the issuer the sharing user was acting on behalf of""" + if obj.shared_by_issuer: + return obj.shared_by_issuer.name + return "N/A" + + shared_by_issuer_display.short_description = "Shared by Issuer" + + +badgr_admin.register(BadgeClassNetworkShare, BadgeClassNetworkShareAdmin) + +class AreaAdmin(admin.ModelAdmin): + list_display = ("name",) + +badgr_admin.register(Area, AreaAdmin) diff --git a/apps/issuer/api.py b/apps/issuer/api.py index 5854d34a8..9c5807356 100644 --- a/apps/issuer/api.py +++ b/apps/issuer/api.py @@ -1,116 +1,373 @@ -from collections import OrderedDict - import datetime +from collections import defaultdict +import json import dateutil.parser -from django.conf import settings +from allauth.account.adapter import get_adapter +from drf_spectacular.utils import ( + extend_schema, + OpenApiParameter, + OpenApiResponse, + OpenApiRequest, + inline_serializer, +) +from drf_spectacular.types import OpenApiTypes +from celery import shared_task +from celery.result import AsyncResult +from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError -from django.db.models import Q +from django.db import transaction +from django.db.models import Q, Count, F from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.urls import reverse from django.utils import timezone +from entity.api import ( + BaseEntityDetailView, + BaseEntityListView, + BaseEntityView, + UncachedPaginatedViewMixin, + VersionedObjectMixin, +) +from entity.serializers import BaseSerializerV2, V2ErrorSerializer +from issuer.models import ( + BadgeClass, + BadgeClassNetworkShare, + BadgeInstance, + Issuer, + IssuerStaff, + IssuerStaffRequest, + LearningPath, + NetworkInvite, + NetworkMembership, + QrCode, + RequestedBadge, +) +from issuer.permissions import ( + ApprovedIssuersOnly, + AuthorizationIsBadgrOAuthToken, + BadgrOAuthTokenHasEntityScope, + BadgrOAuthTokenHasScope, + IsEditor, + IsEditorButOwnerForDelete, + IsStaff, + MayEditBadgeClass, + MayIssueBadgeClass, + MayIssueLearningPath, + is_learningpath_editor, + is_editor, +) +from issuer.serializers_v1 import ( + BadgeClassSerializerV1, + BadgeInstanceSerializerV1, + IssuerSerializerV1, + IssuerStaffRequestSerializer, + LearningPathParticipantSerializerV1, + LearningPathSerializerV1, + NetworkBadgeInstanceSerializerV1, + NetworkInviteSerializer, + NetworkSerializerV1, + QrCodeSerializerV1, + RequestedBadgeSerializer, + BadgeClassNetworkShareSerializerV1, +) +from issuer.serializers_v2 import ( + BadgeClassSerializerV2, + BadgeInstanceSerializerV2, + IssuerAccessTokenSerializerV2, + IssuerSerializerV2, +) +from mainsite.models import AccessTokenProxy, BadgrApp +from mainsite.permissions import AuthenticatedWithVerifiedIdentifier, IsServerAdmin +from mainsite.serializers import CursorPaginatedListSerializer from oauthlib.oauth2.rfc6749.tokens import random_token_generator -from rest_framework import status, serializers +from rest_framework import serializers, status +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated, AllowAny +from django.http import JsonResponse from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from rest_framework.status import HTTP_404_NOT_FOUND, HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN +from rest_framework.status import ( + HTTP_200_OK, + HTTP_204_NO_CONTENT, + HTTP_400_BAD_REQUEST, + HTTP_404_NOT_FOUND, +) -import badgrlog -from entity.api import BaseEntityListView, BaseEntityDetailView, VersionedObjectMixin, BaseEntityView, \ - UncachedPaginatedViewMixin -from entity.serializers import BaseSerializerV2, V2ErrorSerializer -from issuer.models import Issuer, BadgeClass, BadgeInstance, IssuerStaff -from issuer.permissions import (MayIssueBadgeClass, MayEditBadgeClass, IsEditor, IsEditorButOwnerForDelete, - IsStaff, ApprovedIssuersOnly, BadgrOAuthTokenHasScope, - BadgrOAuthTokenHasEntityScope, AuthorizationIsBadgrOAuthToken) -from issuer.serializers_v1 import (IssuerSerializerV1, BadgeClassSerializerV1, - BadgeInstanceSerializerV1) -from issuer.serializers_v2 import IssuerSerializerV2, BadgeClassSerializerV2, BadgeInstanceSerializerV2, \ - IssuerAccessTokenSerializerV2 -from apispec_drf.decorators import apispec_get_operation, apispec_put_operation, \ - apispec_delete_operation, apispec_list_operation, apispec_post_operation -from mainsite.permissions import AuthenticatedWithVerifiedIdentifier, IsServerAdmin -from mainsite.serializers import CursorPaginatedListSerializer -from mainsite.models import AccessTokenProxy +from apps.mainsite.utils import OriginSetting +from issuer.services.image_composer import ImageComposer + +import logging -logger = badgrlog.BadgrLogger() +logger = logging.getLogger("Badgr.Events") class IssuerList(BaseEntityListView): """ Issuer list resource for the authenticated user """ + model = Issuer v1_serializer_class = IssuerSerializerV1 v2_serializer_class = IssuerSerializerV2 permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & BadgrOAuthTokenHasScope & ApprovedIssuersOnly) + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & BadgrOAuthTokenHasScope + & ApprovedIssuersOnly + ) ] valid_scopes = ["rw:issuer"] - create_event = badgrlog.IssuerCreatedEvent - def get_objects(self, request, **kwargs): - return self.request.user.cached_issuers() - - @apispec_list_operation('Issuer', - summary="Get a list of Issuers for authenticated user", - tags=["Issuers"], + # return self.request.user.cached_issuers() + # Note: The issue with the commented line above is that When deleting an entity using the delete method, + # it is removed from the database, but the cache is not invalidated. So this is a temporary workaround + # till figuring out how to invalidate/refresh cache. + # Force fresh data from the database + return Issuer.objects.filter( + staff__id=request.user.id, is_network=False + ).distinct() + + @extend_schema( + summary="Get a list of Issuers for authenticated user", tags=["Issuers"] ) def get(self, request, **kwargs): return super(IssuerList, self).get(request, **kwargs) - @apispec_post_operation('Issuer', - summary="Create a new Issuer", - tags=["Issuers"], - ) + @extend_schema(summary="Create a new Issuer", tags=["Issuers"]) def post(self, request, **kwargs): return super(IssuerList, self).post(request, **kwargs) +class NetworkList(BaseEntityListView): + """ + Network list resource for the authenticated user + """ + + model = Issuer + v1_serializer_class = NetworkSerializerV1 + permission_classes = [ + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & BadgrOAuthTokenHasScope + & ApprovedIssuersOnly + ) + ] + valid_scopes = ["rw:issuer"] + + def get_objects(self, request, **kwargs): + return Issuer.objects.filter( + Q(staff__id=request.user.id) + | Q(memberships__issuer__staff__id=request.user.id), + is_network=True, + ).distinct() + + @extend_schema( + summary="Get a list of networks for the authenticated user", tags=["Networks"] + ) + def get(self, request, **kwargs): + return super(NetworkList, self).get(request, **kwargs) + + @extend_schema(summary="Create a network", tags=["Networks"]) + def post(self, request, **kwargs): + return super(NetworkList, self).post(request, **kwargs) + + +class NetworkDetail(BaseEntityDetailView): + model = Issuer + v1_serializer_class = NetworkSerializerV1 + permission_classes = [ + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & IsEditorButOwnerForDelete + & BadgrOAuthTokenHasScope + ) + | BadgrOAuthTokenHasEntityScope + ] + valid_scopes = ["rw:issuer", "rw:issuer:*", "rw:serverAdmin"] + + @extend_schema(summary="Get a single Network", tags=["Networks"]) + def get(self, request, **kwargs): + return super(NetworkDetail, self).get(request, **kwargs) + + @extend_schema(summary="Update a single Network", tags=["Network"]) + def put(self, request, **kwargs): + return super(NetworkDetail, self).put(request, **kwargs) + + +class NetworkUserIssuersList(BaseEntityListView): + """ + List of issuers within a specific network that the authenticated user is a member in + """ + + model = Issuer + v1_serializer_class = IssuerSerializerV1 + v2_serializer_class = IssuerSerializerV2 + permission_classes = [ + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & BadgrOAuthTokenHasScope + & ApprovedIssuersOnly + ) + ] + valid_scopes = ["rw:issuer"] + + def get_objects(self, request, **kwargs): + networkSlug = kwargs.get("networkSlug") + + if not networkSlug: + return Issuer.objects.none() + + try: + network = Issuer.objects.get(entity_id=networkSlug, is_network=True) + except Issuer.DoesNotExist: + return Issuer.objects.none() + + return Issuer.objects.filter( + issuerstaff__user=request.user, + is_network=False, + network_memberships__network_id=network.id, + ).distinct() + + @extend_schema( + summary="Get a list of Issuers within a network for authenticated user", + tags=["Networks", "Issuers"], + ) + def get(self, request, **kwargs): + return super(NetworkUserIssuersList, self).get(request, **kwargs) + + class IssuerDetail(BaseEntityDetailView): model = Issuer v1_serializer_class = IssuerSerializerV1 v2_serializer_class = IssuerSerializerV2 permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & IsEditorButOwnerForDelete & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & IsEditorButOwnerForDelete + & BadgrOAuthTokenHasScope + ) + | BadgrOAuthTokenHasEntityScope ] valid_scopes = ["rw:issuer", "rw:issuer:*", "rw:serverAdmin"] - @apispec_get_operation('Issuer', - summary="Get a single Issuer", - tags=["Issuers"], - ) + @extend_schema(summary="Get a single Issuer", tags=["Issuers"]) def get(self, request, **kwargs): return super(IssuerDetail, self).get(request, **kwargs) - @apispec_put_operation('Issuer', - summary="Update a single Issuer", - tags=["Issuers"], - ) + @extend_schema(summary="Update a single Issuer", tags=["Issuers"]) def put(self, request, **kwargs): return super(IssuerDetail, self).put(request, **kwargs) - @apispec_delete_operation('Issuer', - summary="Delete a single Issuer", - tags=["Issuers"], - ) + @extend_schema(summary="Delete a single Issuer", tags=["Issuers"]) def delete(self, request, **kwargs): return super(IssuerDetail, self).delete(request, **kwargs) +@extend_schema(exclude=True) +class NetworkIssuerDetail(BaseEntityDetailView): + model = Issuer + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsEditor & BadgrOAuthTokenHasScope) + ] + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def get_object(self, network, issuer_slug): + try: + return network.partner_issuers.get(entity_id=issuer_slug) + except Issuer.DoesNotExist: + raise Http404("Issuer not found in this network") + + @extend_schema( + summary="Remove an issuer from a network", + description="Authenticated user must have owner, editor, or staff status on the Network.", + parameters=[ + OpenApiParameter( + name="slug", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The network's entity_id", + ), + OpenApiParameter( + name="issuer_slug", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="The issuer's entity_id", + ), + ], + responses={ + 204: OpenApiResponse( + description="Issuer successfully removed from the network" + ), + 403: OpenApiResponse(description="Not authorized to remove issuer"), + 404: OpenApiResponse(description="Network or membership not found"), + }, + ) + def delete(self, request, slug, issuer_slug, **kwargs): + try: + network = Issuer.objects.get(entity_id=slug, is_network=True) + except Issuer.DoesNotExist: + raise Exception("Network not found") + + if not is_editor(request.user, network): + return Response( + {"error": "You are not authorized to remove this issuer."}, + status=status.HTTP_403_FORBIDDEN, + ) + + issuer = self.get_object(network, issuer_slug) + + try: + membership = NetworkMembership.objects.get(network=network, issuer=issuer) + membership.delete() + + NetworkInvite.objects.filter( + network=network, + issuer=issuer, + status=NetworkInvite.Status.APPROVED, + revoked=False, + ).update(revoked=True, status=NetworkInvite.Status.REVOKED) + + except NetworkMembership.DoesNotExist: + return Response( + {"error": "Membership not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + owners = issuer.cached_issuerstaff().filter(role=IssuerStaff.ROLE_OWNER) + + email_context = {"issuer": issuer, "network": network} + + adapter = get_adapter() + + for owner in owners: + adapter.send_mail( + "issuer/email/notify_issuer_network_update", + owner.user.email, + email_context, + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + class AllBadgeClassesList(UncachedPaginatedViewMixin, BaseEntityListView): """ GET a list of badgeclasses within one issuer context or POST to create a new badgeclass within the issuer context """ + model = BadgeClass permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & IsEditor & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsEditor & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope ] v1_serializer_class = BadgeClassSerializerV1 v2_serializer_class = BadgeClassSerializerV2 @@ -119,45 +376,49 @@ class AllBadgeClassesList(UncachedPaginatedViewMixin, BaseEntityListView): def get_queryset(self, request, **kwargs): if self.get_page_size(request) is None: return request.user.cached_badgeclasses() - return BadgeClass.objects.filter(issuer__staff=request.user).order_by('created_at') + return BadgeClass.objects.filter(issuer__staff=request.user).order_by( + "created_at" + ) - @apispec_list_operation('BadgeClass', + @extend_schema( summary="Get a list of BadgeClasses for authenticated user", tags=["BadgeClasses"], ) def get(self, request, **kwargs): return super(AllBadgeClassesList, self).get(request, **kwargs) - @apispec_post_operation('BadgeClass', + @extend_schema( summary="Create a new BadgeClass", tags=["BadgeClasses"], parameters=[ - { - 'in': 'query', - 'name': "num", - 'type': "string", - 'description': 'Request pagination of results' - }, - ] + OpenApiParameter( + name="num", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Request pagination of results", + ) + ], ) def post(self, request, **kwargs): return super(AllBadgeClassesList, self).post(request, **kwargs) -class IssuerBadgeClassList(UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView): +class IssuerBadgeClassList( + UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView +): """ GET a list of badgeclasses within one issuer context or POST to create a new badgeclass within the issuer context """ + model = Issuer # used by get_object() permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & IsEditor & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsEditor & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope ] v1_serializer_class = BadgeClassSerializerV1 v2_serializer_class = BadgeClassSerializerV2 - create_event = badgrlog.BadgeClassCreatedEvent valid_scopes = ["rw:issuer", "rw:issuer:*"] def get_queryset(self, request=None, **kwargs): @@ -169,91 +430,348 @@ def get_queryset(self, request=None, **kwargs): def get_context_data(self, **kwargs): context = super(IssuerBadgeClassList, self).get_context_data(**kwargs) - context['issuer'] = self.get_object(self.request, **kwargs) + context["issuer"] = self.get_object(self.request, **kwargs) return context - @apispec_list_operation('BadgeClass', + @extend_schema( summary="Get a list of BadgeClasses for a single Issuer", description="Authenticated user must have owner, editor, or staff status on the Issuer", tags=["Issuers", "BadgeClasses"], parameters=[ - { - 'in': 'query', - 'name': "num", - 'type': "string", - 'description': 'Request pagination of results' - }, - ] + OpenApiParameter( + name="num", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Request pagination of results", + ) + ], ) def get(self, request, **kwargs): return super(IssuerBadgeClassList, self).get(request, **kwargs) - @apispec_post_operation('BadgeClass', + @extend_schema( summary="Create a new BadgeClass associated with an Issuer", description="Authenticated user must have owner, editor, or staff status on the Issuer", tags=["Issuers", "BadgeClasses"], ) def post(self, request, **kwargs): - issuer = self.get_object(request, **kwargs) # trigger a has_object_permissions() check + self.get_object(request, **kwargs) # trigger a has_object_permissions() check return super(IssuerBadgeClassList, self).post(request, **kwargs) +class NetworkBadgeClassesList(UncachedPaginatedViewMixin, BaseEntityListView): + """ + GET a list of badgeclasses within a network context + """ + + model = BadgeClass + v1_serializer_class = BadgeClassSerializerV1 + v2_serializer_class = BadgeClassSerializerV2 + valid_scopes = ["rw:issuer"] + + allow_any_unauthenticated_access = True + + def get_queryset(self, request=None, **kwargs): + network_slug = kwargs.get("slug") + if not network_slug: + return BadgeClass.objects.none() + + try: + return BadgeClass.objects.filter( + issuer__entity_id=network_slug, issuer__is_network=True + ).order_by("created_at") + + except Issuer.DoesNotExist: + return BadgeClass.objects.none() + + @extend_schema( + summary="Get a list of BadgeClasses for network members", + tags=["BadgeClasses"], + ) + def get(self, request, **kwargs): + return super(NetworkBadgeClassesList, self).get(request, **kwargs) + + +class IssuerAwardableBadgeClassList( + UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView +): + """ + GET a list of badgeclasses that this issuer can award (own badges + network badges + shared badges) + """ + + model = Issuer # used by get_object() + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsEditor & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + v1_serializer_class = BadgeClassSerializerV1 + v2_serializer_class = BadgeClassSerializerV2 + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def get_object(self, request, **kwargs): + issuerSlug = kwargs.get("slug") + return Issuer.objects.get(entity_id=issuerSlug) + + def get_queryset(self, request=None, **kwargs): + issuer = self.get_object(request, **kwargs) + + own_badges = BadgeClass.objects.filter(issuer=issuer) + + network_badges = BadgeClass.objects.filter( + issuer__is_network=True, issuer__memberships__issuer=issuer + ) + + # Badges shared with networks where this issuer is a partner + shared_badges = BadgeClass.objects.filter( + network_shares__network__memberships__issuer=issuer, + network_shares__is_active=True, + ) + + awardable_badges = own_badges.union(network_badges, shared_badges) + + return awardable_badges + + def get_context_data(self, **kwargs): + context = super(IssuerAwardableBadgeClassList, self).get_context_data(**kwargs) + context["issuer"] = self.get_object(self.request, **kwargs) + return context + + @extend_schema( + summary="Get a list of BadgeClasses that this Issuer can award", + description="Returns own BadgeClasses plus network-shared BadgeClasses.", + tags=["Issuers", "BadgeClasses"], + parameters=[ + OpenApiParameter( + name="num", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Request pagination of results", + ) + ], + ) + def get(self, request, **kwargs): + return super(IssuerAwardableBadgeClassList, self).get(request, **kwargs) + + +class IssuerLearningPathList( + UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView +): + """ + GET a list of learningpaths within one issuer context or + POST to create a new learningpath within the issuer context + """ + + model = Issuer # used by get_object() + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsEditor & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + v1_serializer_class = LearningPathSerializerV1 + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def get_queryset(self, request=None, **kwargs): + issuer = self.get_object(request, **kwargs) + return LearningPath.objects.filter(issuer=issuer) + + def get_context_data(self, **kwargs): + context = super(IssuerLearningPathList, self).get_context_data(**kwargs) + context["issuer"] = self.get_object(self.request, **kwargs) + return context + + @extend_schema( + summary="Get a list of LearningPaths for a single Issuer", + description="Authenticated user must have owner, editor, or staff status", + tags=["Issuers", "LearningPaths"], + parameters=[ + OpenApiParameter( + name="num", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Request pagination of results", + ), + ], + ) + def get(self, request, **kwargs): + return super(IssuerLearningPathList, self).get(request, **kwargs) + + @extend_schema( + summary="Create a new LearningPath associated with an Issuer", + description="Authenticated user must have owner, editor, or staff status", + tags=["Issuers", "LearningPaths"], + ) + def post(self, request, **kwargs): + self.get_object(request, **kwargs) # trigger a has_object_permissions() check + return super(IssuerLearningPathList, self).post(request, **kwargs) + + +class LearningPathParticipantsList(BaseEntityView): + """ + GET a list of learningpath participants + """ + + serializer_class = LearningPathParticipantSerializerV1 + valid_scopes = ["rw:issuer"] + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return BadgeInstance.objects.none() + + learning_path_slug = self.kwargs.get("slug") + learning_path = LearningPath.objects.get(entity_id=learning_path_slug) + badge_instances = BadgeInstance.objects.filter( + badgeclass=learning_path.participationBadge, + user__isnull=False, + revoked=False, + ) + return badge_instances + + @extend_schema( + summary="Get a list of participants for this LearningPath", + tags=["LearningPaths"], + ) + def get(self, request, **kwargs): + data = self.get_queryset() + results = LearningPathParticipantSerializerV1(data, many=True).data + return Response(results) + + class BadgeClassDetail(BaseEntityDetailView): """ GET details on one BadgeClass. PUT and DELETE should be restricted to BadgeClasses that haven't been issued yet. """ + model = BadgeClass permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & MayEditBadgeClass & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & MayEditBadgeClass + & BadgrOAuthTokenHasScope + ) + | BadgrOAuthTokenHasEntityScope ] v1_serializer_class = BadgeClassSerializerV1 v2_serializer_class = BadgeClassSerializerV2 valid_scopes = ["rw:issuer", "rw:issuer:*"] - @apispec_get_operation('BadgeClass', - summary='Get a single BadgeClass', - tags=['BadgeClasses'], + @extend_schema( + summary="Get a single BadgeClass", + tags=["BadgeClasses"], ) def get(self, request, **kwargs): return super(BadgeClassDetail, self).get(request, **kwargs) - @apispec_delete_operation('BadgeClass', + @extend_schema( summary="Delete a BadgeClass", - description="Restricted to owners or editors (not staff) of the corresponding Issuer.", - tags=['BadgeClasses'], - responses=OrderedDict([ - ("400", { - 'description': "BadgeClass couldn't be deleted. It may have already been issued." - }), - - ]) + description="Restricted to owners or editors (not staff).", + tags=["BadgeClasses"], + responses={ + 400: OpenApiResponse( + description="BadgeClass couldn't be deleted. It may have already been issued." + ) + }, ) def delete(self, request, **kwargs): base_entity = super(BadgeClassDetail, self) badge_class = base_entity.get_object(request, **kwargs) - logger.event(badgrlog.BadgeClassDeletedEvent(badge_class, request.user)) + logger.info( + "Deleting badge class '%s' requested by '%s'", + badge_class.entity_id, + request.user, + ) return base_entity.delete(request, **kwargs) - @apispec_put_operation('BadgeClass', - summary='Update an existing BadgeClass. Previously issued BadgeInstances will NOT be updated', - tags=['BadgeClasses'], + @extend_schema( + summary="Update an existing BadgeClass", + description="Previously issued BadgeInstances will NOT be updated", + tags=["BadgeClasses"], ) def put(self, request, **kwargs): return super(BadgeClassDetail, self).put(request, **kwargs) +@shared_task(bind=True) +def process_batch_assertions( + self, assertions, user_id, badgeclass_id, issuerSlug, create_notification=False +): + try: + User = get_user_model() + user = User.objects.get(id=user_id) + badgeclass = BadgeClass.objects.get(id=badgeclass_id) + + total = len(assertions) + + processed = 0 + successful = [] + errors = [] + + for assertion in assertions: + assertion["create_notification"] = create_notification + + serializer = BadgeInstanceSerializerV1( + data=assertion, + context={ + "badgeclass": badgeclass, + "user": user, + "issuerSlug": issuerSlug, + }, + ) + + if serializer.is_valid(): + try: + instance = serializer.save(created_by=user) + successful.append(BadgeInstanceSerializerV1(instance).data) + except Exception as e: + errors.append({"assertion": assertion, "error": str(e)}) + else: + errors.append({"assertion": assertion, "error": serializer.errors}) + + processed += 1 + + # Emit progress after each iteration + self.update_state( + state="PROGRESS", + meta={ + "processed": processed, + "total": total, + "data": successful, + "errors": errors, + }, + ) + + return { + "success": len(errors) == 0, + "status": status.HTTP_201_CREATED + if len(errors) == 0 + else status.HTTP_207_MULTI_STATUS, + "data": successful, + "errors": errors, + } + + except Exception as e: + return { + "success": False, + "status": status.HTTP_500_INTERNAL_SERVER_ERROR, + "error": str(e), + } + + class BatchAssertionsIssue(VersionedObjectMixin, BaseEntityView): model = BadgeClass # used by .get_object() permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & MayIssueBadgeClass & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & MayIssueBadgeClass + & BadgrOAuthTokenHasScope + ) + | BadgrOAuthTokenHasEntityScope ] v1_serializer_class = BadgeInstanceSerializerV1 v2_serializer_class = BadgeInstanceSerializerV2 @@ -261,72 +779,86 @@ class BatchAssertionsIssue(VersionedObjectMixin, BaseEntityView): def get_context_data(self, **kwargs): context = super(BatchAssertionsIssue, self).get_context_data(**kwargs) - context['badgeclass'] = self.get_object(self.request, **kwargs) + context["badgeclass"] = self.get_object(self.request, **kwargs) return context - @apispec_post_operation('Assertion', - summary='Issue multiple copies of the same BadgeClass to multiple recipients', - tags=['Assertions'], + @extend_schema( + summary="Get batch assertion task status", + description="Check the status of a batch assertion issuance task", + tags=["Assertions"], parameters=[ + OpenApiParameter( + name="task_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + required=True, + description="The task ID returned from the issuance request", + ) + ], + ) + def get(self, request, task_id, **kwargs): + task = AsyncResult(task_id) + + return Response( { - "in": "body", - "name": "body", - "required": True, - 'schema': { - "type": "array", - 'items': { '$ref': '#/definitions/Assertion' } - }, + "task_id": task_id, + "status": task.status, + "result": task.info, } - ] + ) + + @extend_schema( + summary="Issue multiple copies of a BadgeClass", + tags=["Assertions"], + request=BadgeInstanceSerializerV1(many=True), + responses={202: OpenApiResponse(description="Task started")}, ) def post(self, request, **kwargs): + issuerSlug = kwargs.get("issuerSlug") # verify the user has permission to the badgeclass badgeclass = self.get_object(request, **kwargs) + assertions = request.data.get("assertions", []) if not self.has_object_permissions(request, badgeclass): return Response(status=HTTP_404_NOT_FOUND) try: - create_notification = request.data.get('create_notification', False) + create_notification = request.data.get("create_notification", False) except AttributeError: return Response(status=HTTP_400_BAD_REQUEST) - # update passed in assertions to include create_notification - def _include_create_notification(a): - a['create_notification'] = create_notification - return a - assertions = list(map(_include_create_notification, request.data.get('assertions'))) - - # save serializers - context = self.get_context_data(**kwargs) - serializer_class = self.get_serializer_class() - serializer = serializer_class(many=True, data=assertions, context=context) - if not serializer.is_valid(raise_exception=False): - serializer = V2ErrorSerializer(instance={}, - success=False, - description="bad request", - field_errors=serializer._errors, - validation_errors=[]) - return Response(serializer.data, status=status.HTTP_400_BAD_REQUEST) - new_instances = serializer.save(created_by=request.user) - for new_instance in new_instances: - self.log_create(new_instance) + # Start async task + task = process_batch_assertions.delay( + assertions=assertions, + user_id=request.user.id, + badgeclass_id=badgeclass.id, + issuerSlug=issuerSlug, + create_notification=create_notification, + ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response( + {"task_id": str(task.id), "status": "processing"}, + status=status.HTTP_202_ACCEPTED, + ) class BatchAssertionsRevoke(VersionedObjectMixin, BaseEntityView): model = BadgeInstance permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & MayEditBadgeClass & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & MayEditBadgeClass + & BadgrOAuthTokenHasScope + ) + | BadgrOAuthTokenHasEntityScope ] + serializer_class = BadgeInstanceSerializerV2 v2_serializer_class = BadgeInstanceSerializerV2 valid_scopes = ["rw:issuer", "rw:issuer:*"] def get_context_data(self, **kwargs): context = super(BatchAssertionsRevoke, self).get_context_data(**kwargs) - context['badgeclass'] = self.get_object(self.request, **kwargs) + context["badgeclass"] = self.get_object(self.request, **kwargs) return context def _process_revoke(self, request, revocation): @@ -359,20 +891,10 @@ def _process_revoke(self, request, revocation): return dict(response, revoked=True) - @apispec_post_operation('Assertion', - summary='Revoke multiple Assertions', - tags=['Assertions'], - parameters=[ - { - "in": "body", - "name": "body", - "required": True, - 'schema': { - "type": "array", - 'items': { '$ref': '#/definitions/Assertion' } - }, - } - ] + @extend_schema( + summary="Revoke multiple Assertions", + tags=["Assertions"], + request=BadgeInstanceSerializerV2(many=True), ) def post(self, request, **kwargs): result = [ @@ -380,206 +902,514 @@ def post(self, request, **kwargs): for revocation in self.request.data ] - response_data = BaseSerializerV2.response_envelope(result=result, success=True, description="revoked badges") + response_data = BaseSerializerV2.response_envelope( + result=result, success=True, description="revoked badges" + ) return Response(status=HTTP_200_OK, data=response_data) -class BadgeInstanceList(UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView): +class BadgeInstanceList( + UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView +): """ GET a list of assertions for a single badgeclass POST to issue a new assertion """ + model = BadgeClass # used by get_object() permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & MayIssueBadgeClass & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & MayIssueBadgeClass + & BadgrOAuthTokenHasScope + ) + | BadgrOAuthTokenHasEntityScope ] v1_serializer_class = BadgeInstanceSerializerV1 v2_serializer_class = BadgeInstanceSerializerV2 - create_event = badgrlog.BadgeInstanceCreatedEvent valid_scopes = ["rw:issuer", "rw:issuer:*"] def get_queryset(self, request=None, **kwargs): badgeclass = self.get_object(request, **kwargs) queryset = BadgeInstance.objects.filter(badgeclass=badgeclass) - recipients = request.query_params.getlist('recipient', None) + recipients = request.query_params.getlist("recipient", None) if recipients: queryset = queryset.filter(recipient_identifier__in=recipients) - if request.query_params.get('include_expired', '').lower() not in ['1', 'true']: + if request.query_params.get("include_expired", "").lower() not in ["1", "true"]: queryset = queryset.filter( - Q(expires_at__gte=datetime.datetime.now()) | Q(expires_at__isnull=True)) - if request.query_params.get('include_revoked', '').lower() not in ['1', 'true']: + Q(expires_at__gte=timezone.now()) | Q(expires_at__isnull=True) + ) + if request.query_params.get("include_revoked", "").lower() not in ["1", "true"]: queryset = queryset.filter(revoked=False) return queryset def get_context_data(self, **kwargs): context = super(BadgeInstanceList, self).get_context_data(**kwargs) - context['badgeclass'] = self.get_object(self.request, **kwargs) + context["badgeclass"] = self.get_object(self.request, **kwargs) + context["issuerSlug"] = kwargs.get("issuerSlug") return context - @apispec_list_operation('Assertion', + @extend_schema( summary="Get a list of Assertions for a single BadgeClass", - tags=['Assertions', 'BadgeClasses'], + tags=["Assertions", "BadgeClasses"], parameters=[ - { - 'in': 'query', - 'name': "recipient", - 'type': "string", - 'description': 'A recipient identifier to filter by' - }, - { - 'in': 'query', - 'name': "num", - 'type': "string", - 'description': 'Request pagination of results' - }, - { - 'in': 'query', - 'name': "include_expired", - 'type': "boolean", - 'description': 'Include expired assertions' - }, - { - 'in': 'query', - 'name': "include_revoked", - 'type': "boolean", - 'description': 'Include revoked assertions' - } - ] + OpenApiParameter( + name="recipient", + description="A recipient identifier to filter by", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + many=True, + ), + OpenApiParameter( + name="num", + description="Request pagination of results", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + ), + OpenApiParameter( + name="include_expired", + description="Include expired assertions", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + ), + OpenApiParameter( + name="include_revoked", + description="Include revoked assertions", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + ), + ], ) def get(self, request, **kwargs): # verify the user has permission to the badgeclass - badgeclass = self.get_object(request, **kwargs) + self.get_object(request, **kwargs) return super(BadgeInstanceList, self).get(request, **kwargs) - @apispec_post_operation('Assertion', + @extend_schema( summary="Issue an Assertion to a single recipient", - tags=['Assertions', 'BadgeClasses'], + tags=["Assertions", "BadgeClasses"], ) def post(self, request, **kwargs): - # verify the user has permission to the badgeclass - badgeclass = self.get_object(request, **kwargs) + self.get_object(request, **kwargs) return super(BadgeInstanceList, self).post(request, **kwargs) -class IssuerBadgeInstanceList(UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView): +class IssuerNetworkBadgeClassList( + UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView +): """ - Retrieve all assertions within one issuer + GET a list of badge classes that this issuer has awarded + where the badgeclass belongs to a network issuer + OR has been shared with a network issuer, + grouped by network issuer """ - model = Issuer # used by get_object() + + model = Issuer permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope ] - v1_serializer_class = BadgeInstanceSerializerV1 - v2_serializer_class = BadgeInstanceSerializerV2 - create_event = badgrlog.BadgeInstanceCreatedEvent + v1_serializer_class = BadgeClassSerializerV1 + v2_serializer_class = BadgeClassSerializerV2 valid_scopes = ["rw:issuer", "rw:issuer:*"] + def get_object(self, request=None, **kwargs): + """ + Get the issuer by entity_id from the URL slug + """ + issuer_slug = kwargs.get("issuerSlug") + try: + issuer = Issuer.objects.get(entity_id=issuer_slug) + return issuer + except Issuer.DoesNotExist: + raise ValidationError(f"Issuer with slug '{issuer_slug}' not found") + def get_queryset(self, request=None, **kwargs): + """ + Get badge classes that this issuer has awarded instances of, + where the badgeclass either: + - belongs to a network issuer, OR + - has been shared with a network + """ issuer = self.get_object(request, **kwargs) - queryset = BadgeInstance.objects.filter(issuer=issuer) - recipients = request.query_params.getlist('recipient', None) - if recipients: - queryset = queryset.filter(recipient_identifier__in=recipients) - if request.query_params.get('include_expired', '').lower() not in ['1', 'true']: - queryset = queryset.filter( - Q(expires_at__gte=datetime.datetime.now()) | Q(expires_at__isnull=True)) - if request.query_params.get('include_revoked', '').lower() not in ['1', 'true']: - queryset = queryset.filter(revoked=False) + + owned_badges = BadgeClass.objects.filter( + issuer__is_network=True, + badgeinstances__issuer=issuer, + ) + + shared_badges = BadgeClass.objects.filter( + network_shares__network__is_network=True, + badgeinstances__issuer=issuer, + network_shares__is_active=True, + ) + + queryset = (owned_badges | shared_badges).distinct() + + queryset = queryset.annotate( + awarded_count=Count( + "badgeinstances", + filter=Q(badgeinstances__issuer=issuer) + & ( + Q(issuer__is_network=True) + | Q(badgeinstances__issued_on__gte=F("network_shares__shared_at")) + ), + ) + ) + return queryset - @apispec_list_operation('Assertion', - summary='Get a list of Assertions for a single Issuer', - tags=['Assertions', 'Issuers'], - parameters=[ - { - 'in': 'query', - 'name': "recipient", - 'type': "string", - 'description': 'A recipient identifier to filter by' - }, - { - 'in': 'query', - 'name': "num", - 'type': "string", - 'description': 'Request pagination of results' - }, - { - 'in': 'query', - 'name': "include_expired", - 'type': "boolean", - 'description': 'Include expired assertions' - }, - { - 'in': 'query', - 'name': "include_revoked", - 'type': "boolean", - 'description': 'Include revoked assertions' - }, - ] + @extend_schema( + summary="Get badge classes awarded by this issuer from networks (owned or shared), grouped by network", + tags=["BadgeClasses", "Issuers", "Networks"], ) def get(self, request, **kwargs): - return super(IssuerBadgeInstanceList, self).get(request, **kwargs) + queryset = self.get_queryset(request, **kwargs) - @apispec_post_operation('Assertion', - summary="Issue a new Assertion to a recipient", - tags=['Assertions', 'Issuers'] - ) - def post(self, request, **kwargs): - kwargs['issuer'] = self.get_object(request, **kwargs) # trigger a has_object_permissions() check + grouped_data = defaultdict(list) + + for badge_class in queryset: + if badge_class.issuer.is_network: + network_issuer = badge_class.issuer + else: + share = badge_class.network_shares.filter(is_active=True).first() + if not share: + continue + network_issuer = share.network + + badge_data = self.get_serializer_class()(badge_class).data + badge_data["awarded_count"] = getattr(badge_class, "awarded_count", 0) + + grouped_data[network_issuer.entity_id].append(badge_data) + + response_data = [] + for network_issuer_slug, badge_classes in grouped_data.items(): + try: + network_issuer = Issuer.objects.get(entity_id=network_issuer_slug) + network_data = { + "network_issuer": { + "slug": network_issuer.entity_id, + "name": network_issuer.name, + "image": network_issuer.image.url + if network_issuer.image + else None, + "description": network_issuer.description, + }, + "badge_classes": badge_classes, + "total_badges": len(badge_classes), + "total_instances_awarded": sum( + badge["awarded_count"] for badge in badge_classes + ), + } + response_data.append(network_data) + except Issuer.DoesNotExist: + continue + + response_data.sort(key=lambda x: x["network_issuer"]["name"]) + + return Response(response_data) + + +class IssuerNetworkBadgeInstanceList( + UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView +): + """ + GET a list of badge instances issued by this issuer + where the badgeclass belongs to a network issuer + """ + + model = Issuer + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + v1_serializer_class = BadgeInstanceSerializerV1 + v2_serializer_class = BadgeInstanceSerializerV2 + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def get_object(self, request=None, **kwargs): + """ + Get the issuer by entity_id from the URL slug + """ + issuer_slug = kwargs.get("issuerSlug") + try: + issuer = Issuer.objects.get(entity_id=issuer_slug) + return issuer + except Issuer.DoesNotExist: + raise ValidationError(f"Issuer with slug '{issuer_slug}' not found") + + def get_queryset(self, request=None, **kwargs): + """ + Get badge instances issued by this issuer where the badgeclass + belongs to a network issuer + """ + issuer = self.get_object(request, **kwargs) + + queryset = BadgeInstance.objects.filter( + issuer=issuer, + badgeclass__issuer__is_network=True, + ) + + return queryset + + def get_context_data(self, **kwargs): + context = super(IssuerNetworkBadgeInstanceList, self).get_context_data(**kwargs) + context["issuer"] = self.get_object(self.request, **kwargs) + return context + + def get_serializer_context(self): + """ + Add user context for serializer, similar to your existing class + """ + ctx = super(IssuerNetworkBadgeInstanceList, self).get_serializer_context() + ctx["user"] = self.request.user + return ctx + + @extend_schema( + summary="Get badge instances issued by this issuer from network badge classes", + tags=["Assertions", "Issuers", "Networks"], + ) + def get(self, request, **kwargs): + self.get_object(request, **kwargs) + return super(IssuerNetworkBadgeInstanceList, self).get(request, **kwargs) + + +class IssuerBadgeInstanceList( + UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView +): + """ + Retrieve all assertions within one issuer + """ + + model = Issuer # used by get_object() + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + v1_serializer_class = BadgeInstanceSerializerV1 + v2_serializer_class = BadgeInstanceSerializerV2 + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def get_queryset(self, request=None, **kwargs): + issuer = self.get_object(request, **kwargs) + queryset = BadgeInstance.objects.filter(issuer=issuer) + recipients = request.query_params.getlist("recipient", None) + if recipients: + queryset = queryset.filter(recipient_identifier__in=recipients) + if request.query_params.get("include_expired", "").lower() not in ["1", "true"]: + queryset = queryset.filter( + Q(expires_at__gte=timezone.now()) | Q(expires_at__isnull=True) + ) + if request.query_params.get("include_revoked", "").lower() not in ["1", "true"]: + queryset = queryset.filter(revoked=False) + return queryset + + @extend_schema( + summary="Get a list of Assertions for a single Issuer", + tags=["Assertions", "Issuers"], + parameters=[ + OpenApiParameter( + name="recipient", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="A recipient identifier to filter by", + many=True, + ), + OpenApiParameter( + name="num", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Request pagination of results", + ), + OpenApiParameter( + name="include_expired", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Include expired assertions", + ), + OpenApiParameter( + name="include_revoked", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Include revoked assertions", + ), + ], + ) + def get(self, request, **kwargs): + return super(IssuerBadgeInstanceList, self).get(request, **kwargs) + + @extend_schema( + summary="Issue a new Assertion to a recipient", + tags=["Assertions", "Issuers"], + ) + def post(self, request, **kwargs): + kwargs["issuer"] = self.get_object( + request, **kwargs + ) # trigger a has_object_permissions() check return super(IssuerBadgeInstanceList, self).post(request, **kwargs) +class NetworkBadgeInstanceList( + UncachedPaginatedViewMixin, VersionedObjectMixin, BaseEntityListView +): + """ + GET a list of assertions for a badgeclass across all network partner issuers + """ + + model = BadgeClass + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + v1_serializer_class = NetworkBadgeInstanceSerializerV1 + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def get_object(self, request=None, **kwargs): + badgeSlug = kwargs.get("slug") + badgeclass = BadgeClass.objects.get(entity_id=badgeSlug) + if not badgeclass.issuer.is_network: + raise ValidationError( + "This endpoint is only available for badges created by networks" + ) + return badgeclass + + def get_queryset(self, request=None, **kwargs): + badgeclass = self.get_object(request, **kwargs) + network = badgeclass.issuer + + queryset = BadgeInstance.objects.filter( + badgeclass=badgeclass, issuer__network_memberships__network=network + ).select_related("issuer", "user") + + return queryset + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx["user"] = self.request.user + return ctx + + @extend_schema( + summary="Get network badge instances across all partner issuers", + description=( + "Retrieve all badge instances for a network badge class, grouped by issuing " + "organization. Only available for badges created by networks." + ), + tags=["Assertions", "Networks", "BadgeClasses"], + parameters=[ + OpenApiParameter( + name="num", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Request pagination of results", + ), + ], + ) + def get(self, request, **kwargs): + response = super().get(request, **kwargs) + instances = response.data + badgeclass = self.get_object(request, **kwargs) + + grouped_data = self.group_instances_by_issuer(instances, request, badgeclass) + + response.data = {"grouped_results": grouped_data} + return response + + def _extract_slug_from_issuer_url(self, url): + if not url: + return None + return url.rstrip("/").split("/")[-1] + + def group_instances_by_issuer(self, instances, request, badgeclass): + grouped = {} + request_user = request.user + network_issuer = badgeclass.issuer + partner_issuers = network_issuer.partner_issuers.all() + + for partner in partner_issuers: + user_has_access = self.user_has_access_to_issuer(request_user, partner) + grouped[partner.entity_id] = { + "issuer": { + "slug": partner.entity_id, + "name": partner.name, + "image": partner.image.url if partner.image else None, + }, + "has_access": user_has_access, + "instances": [], + "instance_count": 0, + } + + for instance_data in instances: + issuer_url = instance_data.get("issuer") + issuer_slug = self._extract_slug_from_issuer_url(issuer_url) + if issuer_slug and issuer_slug in grouped: + if grouped[issuer_slug]["has_access"]: + grouped[issuer_slug]["instances"].append(instance_data) + grouped[issuer_slug]["instance_count"] += 1 + + for slug, group_data in grouped.items(): + if not group_data["has_access"]: + partner = partner_issuers.get(entity_id=slug) + group_data["instance_count"] = group_data["instance_count"] + group_data["instances"] = [] + + return list(grouped.values()) + + def user_has_access_to_issuer(self, user, issuer): + return user in issuer.staff.all() + + class BadgeInstanceDetail(BaseEntityDetailView): """ Endpoints for (GET)ting a single assertion or revoking a badge (DELETE) """ + model = BadgeInstance permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & MayEditBadgeClass & BadgrOAuthTokenHasScope) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & MayEditBadgeClass + & BadgrOAuthTokenHasScope + ) + | BadgrOAuthTokenHasEntityScope ] v1_serializer_class = BadgeInstanceSerializerV1 v2_serializer_class = BadgeInstanceSerializerV2 valid_scopes = ["rw:issuer", "rw:issuer:*"] - @apispec_get_operation('Assertion', + @extend_schema( summary="Get a single Assertion", - tags=['Assertions'] + tags=["Assertions"], ) def get(self, request, **kwargs): return super(BadgeInstanceDetail, self).get(request, **kwargs) - @apispec_delete_operation('Assertion', + @extend_schema( summary="Revoke an Assertion", - tags=['Assertions'], - responses=OrderedDict([ - ('400', { - 'description': "Assertion is already revoked" - }) - ]), - parameters=[{ - "in": "body", - "name": "body", - "required": True, - "schema": { + tags=["Assertions"], + request=OpenApiRequest( + { "type": "object", "properties": { "revocation_reason": { "type": "string", - "format": "string", - 'description': "The reason for revoking this assertion", - 'required': False - }, - } + "description": "The reason for revoking this assertion", + } + }, + "required": ["revocation_reason"], } - }] + ), + responses={ + 200: OpenApiResponse(response=BadgeInstanceSerializerV2), + 400: OpenApiResponse(description="Assertion is already revoked"), + }, ) def delete(self, request, **kwargs): # verify the user has permission to the assertion @@ -587,23 +1417,29 @@ def delete(self, request, **kwargs): if not self.has_object_permissions(request, assertion): return Response(status=HTTP_404_NOT_FOUND) - revocation_reason = request.data.get('revocation_reason', None) + revocation_reason = request.data.get("revocation_reason", None) if not revocation_reason: - raise ValidationError({'revocation_reason': "This field is required"}) + raise ValidationError({"revocation_reason": "This field is required"}) try: assertion.revoke(revocation_reason) except DjangoValidationError as e: raise ValidationError(e.message) - serializer = self.get_serializer_class()(assertion, context={'request': request}) + serializer = self.get_serializer_class()( + assertion, context={"request": request} + ) - logger.event(badgrlog.BadgeAssertionRevokedEvent(assertion, request.user)) + logger.info( + "Badge assertion '%s' revoking requested by '%s'", + assertion.entity_id, + request.user, + ) return Response(status=HTTP_200_OK, data=serializer.data) - @apispec_put_operation('Assertion', + @extend_schema( summary="Update an Assertion", - tags=['Assertions'], + tags=["Assertions"], ) def put(self, request, **kwargs): return super(BadgeInstanceDetail, self).put(request, **kwargs) @@ -611,16 +1447,35 @@ def put(self, request, **kwargs): class IssuerTokensList(BaseEntityListView): model = AccessTokenProxy - permission_classes = (AuthenticatedWithVerifiedIdentifier, BadgrOAuthTokenHasScope, AuthorizationIsBadgrOAuthToken) + permission_classes = ( + AuthenticatedWithVerifiedIdentifier, + BadgrOAuthTokenHasScope, + AuthorizationIsBadgrOAuthToken, + ) + serializer_class = IssuerAccessTokenSerializerV2 v2_serializer_class = IssuerAccessTokenSerializerV2 valid_scopes = ["rw:issuer"] - @apispec_post_operation('AccessToken', + @extend_schema( summary="Retrieve issuer tokens", tags=["Issuers"], + request=OpenApiRequest( + { + "type": "object", + "properties": { + "issuers": { + "type": "array", + "items": {"type": "string"}, + "description": "List of issuer entity IDs", + } + }, + "required": ["issuers"], + } + ), + responses=IssuerAccessTokenSerializerV2(many=True), ) def post(self, request, **kwargs): - issuer_entityids = request.data.get('issuers', None) + issuer_entityids = request.data.get("issuers", None) if not issuer_entityids: raise serializers.ValidationError({"issuers": "field is required"}) @@ -629,7 +1484,7 @@ def post(self, request, **kwargs): try: issuer = Issuer.cached.get(entity_id=issuer_entityid) self.check_object_permissions(request, issuer) - except Issuer.DoesNotExist as e: + except Issuer.DoesNotExist: raise serializers.ValidationError({"issuers": "unknown issuer"}) else: issuers.append(issuer) @@ -647,30 +1502,26 @@ def post(self, request, **kwargs): staff, staff_created = IssuerStaff.cached.get_or_create( issuer=issuer, user=application_user, - defaults=dict( - role=IssuerStaff.ROLE_STAFF - ) + defaults=dict(role=IssuerStaff.ROLE_STAFF), ) accesstoken, created = AccessTokenProxy.objects.get_or_create( user=application_user, application=request.auth.application, scope=scope, - defaults=dict( - expires=expires, - token=random_token_generator(request) - ) + defaults=dict(expires=expires, token=random_token_generator(request)), + ) + tokens.append( + { + "issuer": issuer.entity_id, + "token": accesstoken.token, + "expires": accesstoken.expires, + } ) - tokens.append({ - 'issuer': issuer.entity_id, - 'token': accesstoken.token, - 'expires': accesstoken.expires, - }) - serializer = IssuerAccessTokenSerializerV2(data=tokens, many=True, context=dict( - request=request, - kwargs=kwargs - )) + serializer = IssuerAccessTokenSerializerV2( + data=tokens, many=True, context=dict(request=request, kwargs=kwargs) + ) serializer.is_valid(raise_exception=True) return Response(serializer.data) @@ -679,16 +1530,21 @@ class PaginatedAssertionsSinceSerializer(CursorPaginatedListSerializer): child = BadgeInstanceSerializerV2() def __init__(self, *args, **kwargs): - self.timestamp = timezone.now() # take timestamp now before SQL query is run in super.__init__ + self.timestamp = ( + timezone.now() + ) # take timestamp now before SQL query is run in super.__init__ super(PaginatedAssertionsSinceSerializer, self).__init__(*args, **kwargs) def to_representation(self, data): - representation = super(PaginatedAssertionsSinceSerializer, self).to_representation(data) - representation['timestamp'] = self.timestamp.isoformat() + representation = super( + PaginatedAssertionsSinceSerializer, self + ).to_representation(data) + representation["timestamp"] = self.timestamp.isoformat() return representation class AssertionsChangedSince(BaseEntityView): + schema = None permission_classes = (BadgrOAuthTokenHasScope,) valid_scopes = ["r:issuer", "rw:issuer", "rw:serverAdmin"] @@ -705,15 +1561,30 @@ def get_queryset(self, request, since=None): qs = BadgeInstance.objects.filter(expr).distinct() return qs + @extend_schema( + summary="Get Assertions updated since a timestamp", + tags=["Assertions"], + parameters=[ + OpenApiParameter( + name="since", + description="ISO-8601 timestamp (with time zone) specifying the earliest update time", + type=OpenApiTypes.DATETIME, + location=OpenApiParameter.QUERY, + ) + ], + responses=PaginatedAssertionsSinceSerializer, + ) def get(self, request, **kwargs): - since = request.GET.get('since', None) + since = request.GET.get("since", None) if since is not None: try: since = dateutil.parser.isoparse(since) - except ValueError as e: + except ValueError: err = V2ErrorSerializer( - data={}, field_errors={'since': ["must be ISO-8601 format with time zone"]}, - validation_errors=[]) + data={}, + field_errors={"since": ["must be ISO-8601 format with time zone"]}, + validation_errors=[], + ) err._success = False err._description = "bad request" err.is_valid(raise_exception=False) @@ -722,9 +1593,8 @@ def get(self, request, **kwargs): queryset = self.get_queryset(request, since=since) context = self.get_context_data(**kwargs) serializer = PaginatedAssertionsSinceSerializer( - queryset=queryset, - request=request, - context=context) + queryset=queryset, request=request, context=context + ) serializer.is_valid() return Response(serializer.data) @@ -733,16 +1603,21 @@ class PaginatedBadgeClassesSinceSerializer(CursorPaginatedListSerializer): child = BadgeClassSerializerV2() def __init__(self, *args, **kwargs): - self.timestamp = timezone.now() # take timestamp now before SQL query is run in super.__init__ + self.timestamp = ( + timezone.now() + ) # take timestamp now before SQL query is run in super.__init__ super(PaginatedBadgeClassesSinceSerializer, self).__init__(*args, **kwargs) def to_representation(self, data): - representation = super(PaginatedBadgeClassesSinceSerializer, self).to_representation(data) - representation['timestamp'] = self.timestamp.isoformat() + representation = super( + PaginatedBadgeClassesSinceSerializer, self + ).to_representation(data) + representation["timestamp"] = self.timestamp.isoformat() return representation class BadgeClassesChangedSince(BaseEntityView): + schema = None permission_classes = (BadgrOAuthTokenHasScope,) valid_scopes = ["r:issuer", "rw:issuer", "rw:serverAdmin"] @@ -757,15 +1632,30 @@ def get_queryset(self, request, since=None): qs = BadgeClass.objects.filter(expr).distinct() return qs + @extend_schema( + summary="Get BadgeClasses updated since a timestamp", + tags=["BadgeClasses"], + parameters=[ + OpenApiParameter( + name="since", + description="ISO-8601 timestamp (with time zone)", + type=OpenApiTypes.DATETIME, + location=OpenApiParameter.QUERY, + ) + ], + responses=PaginatedBadgeClassesSinceSerializer, + ) def get(self, request, **kwargs): - since = request.GET.get('since', None) + since = request.GET.get("since", None) if since is not None: try: since = dateutil.parser.isoparse(since) - except ValueError as e: + except ValueError: err = V2ErrorSerializer( - data={}, field_errors={'since': ["must be ISO-8601 format with time zone"]}, - validation_errors=[]) + data={}, + field_errors={"since": ["must be ISO-8601 format with time zone"]}, + validation_errors=[], + ) err._success = False err._description = "bad request" err.is_valid(raise_exception=False) @@ -774,9 +1664,8 @@ def get(self, request, **kwargs): queryset = self.get_queryset(request, since=since) context = self.get_context_data(**kwargs) serializer = PaginatedBadgeClassesSinceSerializer( - queryset=queryset, - request=request, - context=context) + queryset=queryset, request=request, context=context + ) serializer.is_valid() return Response(serializer.data) @@ -785,16 +1674,21 @@ class PaginatedIssuersSinceSerializer(CursorPaginatedListSerializer): child = IssuerSerializerV2() def __init__(self, *args, **kwargs): - self.timestamp = timezone.now() # take timestamp now before SQL query is run in super.__init__ + self.timestamp = ( + timezone.now() + ) # take timestamp now before SQL query is run in super.__init__ super(PaginatedIssuersSinceSerializer, self).__init__(*args, **kwargs) def to_representation(self, data): - representation = super(PaginatedIssuersSinceSerializer, self).to_representation(data) - representation['timestamp'] = self.timestamp.isoformat() + representation = super(PaginatedIssuersSinceSerializer, self).to_representation( + data + ) + representation["timestamp"] = self.timestamp.isoformat() return representation class IssuersChangedSince(BaseEntityView): + schema = None permission_classes = (BadgrOAuthTokenHasScope,) valid_scopes = ["r:issuer", "rw:issuer", "rw:serverAdmin"] @@ -809,15 +1703,30 @@ def get_queryset(self, request, since=None): qs = Issuer.objects.filter(expr).distinct() return qs + @extend_schema( + summary="Get Issuers updated since a timestamp", + tags=["Issuers"], + parameters=[ + OpenApiParameter( + name="since", + type=OpenApiTypes.DATETIME, + description="ISO-8601 timestamp (with time zone)", + location=OpenApiParameter.QUERY, + ) + ], + responses=PaginatedIssuersSinceSerializer, + ) def get(self, request, **kwargs): - since = request.GET.get('since', None) + since = request.GET.get("since", None) if since is not None: try: since = dateutil.parser.isoparse(since) - except ValueError as e: + except ValueError: err = V2ErrorSerializer( - data={}, field_errors={'since': ["must be ISO-8601 format with time zone"]}, - validation_errors=[]) + data={}, + field_errors={"since": ["must be ISO-8601 format with time zone"]}, + validation_errors=[], + ) err._success = False err._description = "bad request" err.is_valid(raise_exception=False) @@ -826,8 +1735,1046 @@ def get(self, request, **kwargs): queryset = self.get_queryset(request, since=since) context = self.get_context_data(**kwargs) serializer = PaginatedIssuersSinceSerializer( - queryset=queryset, - request=request, - context=context) + queryset=queryset, request=request, context=context + ) serializer.is_valid() return Response(serializer.data) + + +class QRCodeList(BaseEntityListView): + """List and create QR codes for a specific badge""" + + model = QrCode + v1_serializer_class = QrCodeSerializerV1 + permission_classes = (BadgrOAuthTokenHasScope, AuthenticatedWithVerifiedIdentifier) + valid_scopes = ["rw:issuer"] + + def get_objects(self, request, **kwargs): + """Get QR codes filtered by badge and issuer""" + badgeSlug = kwargs.get("badgeSlug") + issuerSlug = kwargs.get("issuerSlug") + + try: + issuer = Issuer.objects.get(entity_id=issuerSlug) + except Issuer.DoesNotExist: + return [] + + if issuer.is_network: + qr_codes = QrCode.objects.filter( + badgeclass__entity_id=badgeSlug, + issuer__network_memberships__network=issuer, + ).select_related("issuer") + + # Permission check for each QR code + return [ + qr_code + for qr_code in qr_codes + if request.user.has_perm("issuer.is_staff", qr_code.issuer) + ] + + return QrCode.objects.filter( + badgeclass__entity_id=badgeSlug, issuer__entity_id=issuerSlug + ) + + @extend_schema( + summary="Get all QR codes for a specific badge and issuer", + tags=["QrCodes"], + responses=QrCodeSerializerV1(many=True), + ) + def get(self, request, **kwargs): + return super().get(request, **kwargs) + + @extend_schema( + summary="Create a new QR code for a badge", + tags=["QrCodes"], + request=QrCodeSerializerV1, + responses=QrCodeSerializerV1, + ) + def post(self, request, **kwargs): + return super().post(request, **kwargs) + + +class QRCodeDetail(BaseEntityView): + """Retrieve, update, or delete a specific QR code""" + + model = QrCode + v1_serializer_class = QrCodeSerializerV1 + permission_classes = (BadgrOAuthTokenHasScope, AuthenticatedWithVerifiedIdentifier) + valid_scopes = ["rw:issuer"] + + def get_object(self, request, **kwargs): + qr_code_id = kwargs.get("slug") + return QrCode.objects.get(entity_id=qr_code_id) + + @extend_schema( + summary="Get a specific QR code by slug", + tags=["QrCodes"], + responses={ + 200: QrCodeSerializerV1, + 404: OpenApiResponse(description="QR code not found"), + }, + ) + def get(self, request, **kwargs): + try: + qr_code = self.get_object(request, **kwargs) + serializer_class = self.get_serializer_class() + serializer = serializer_class(qr_code) + return Response(serializer.data, status=status.HTTP_200_OK) + except QrCode.DoesNotExist: + return Response( + {"detail": "QR code not found"}, status=status.HTTP_404_NOT_FOUND + ) + + @extend_schema( + summary="Update a specific QR code", + tags=["QrCodes"], + request=QrCodeSerializerV1, + responses=QrCodeSerializerV1, + ) + def put(self, request, **kwargs): + qr_code = self.get_object(request, **kwargs) + context = self.get_context_data(**kwargs) + serializer_class = self.get_serializer_class() + serializer = serializer_class(qr_code, data=request.data, context=context) + serializer.is_valid(raise_exception=True) + serializer.save(updated_by=request.user) + return Response(serializer.data, status=HTTP_200_OK) + + @extend_schema( + summary="Delete a specific QR code", + tags=["QrCodes"], + responses={204: OpenApiResponse(description="Deleted")}, + ) + def delete(self, request, **kwargs): + qr_code = self.get_object(request, **kwargs) + qr_code.delete() + return Response(status=HTTP_204_NO_CONTENT) + + +class NetworkBadgeQRCodeList(BaseEntityView): + """ + QrCode list resource for a specific badge across all issuers in a network, grouped by issuer + """ + + model = QrCode + v1_serializer_class = QrCodeSerializerV1 + permission_classes = (BadgrOAuthTokenHasScope,) + valid_scopes = ["rw:issuer"] + + def get_network_badge_qrcodes(self, request, **kwargs): + network_slug = kwargs.get("networkSlug") + badge_slug = kwargs.get("badgeSlug") + + try: + network = Issuer.objects.get(entity_id=network_slug, is_network=True) + except Issuer.DoesNotExist: + return None + + member_issuers = Issuer.objects.filter(network_memberships__network=network) + + qrcodes_by_issuer = {} + for issuer in member_issuers: + qrcodes = QrCode.objects.filter( + issuer__entity_id=issuer.entity_id, badgeclass__entity_id=badge_slug + ) + if qrcodes.exists(): + qrcodes_by_issuer[issuer.entity_id] = { + "issuer": IssuerSerializerV1(issuer).data, + "qrcodes": qrcodes, + "staff": self.user_is_staff(request.user, issuer), + } + + return qrcodes_by_issuer + + @extend_schema( + summary="Get all QrCodes for a specific badge in a network grouped by issuer", + tags=["QrCodes"], + responses={ + 200: OpenApiTypes.OBJECT, + 404: OpenApiResponse(description="Network or QR codes not found"), + }, + ) + def get(self, request, **kwargs): + qrcodes_by_issuer = self.get_network_badge_qrcodes(request, **kwargs) + + if qrcodes_by_issuer is None: + return Response( + {"detail": "Network not found"}, status=status.HTTP_404_NOT_FOUND + ) + + if not qrcodes_by_issuer: + return Response( + {"detail": "No QR codes found for this badge in the network"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer_class = self.get_serializer_class() + response_data = {} + + for issuer_slug, issuer_data in qrcodes_by_issuer.items(): + serializer = serializer_class(issuer_data["qrcodes"], many=True) + response_data[issuer_slug] = { + "issuer": issuer_data["issuer"], + "qrcodes": serializer.data, + "staff": issuer_data["staff"], + } + + return Response(response_data, status=status.HTTP_200_OK) + + def user_is_staff(self, user, issuer): + if not user or not user.is_authenticated: + return False + + return issuer.staff_items.filter(user=user).exists() + + +class BadgeRequestList(BaseEntityListView): + model = RequestedBadge + v1_serializer_class = RequestedBadgeSerializer + permission_classes = [ + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & BadgrOAuthTokenHasScope + & ApprovedIssuersOnly + ) + ] + valid_scopes = ["rw:issuer"] + + @extend_schema( + summary="Delete multiple badge requests", + tags=["Requested Badges"], + request=OpenApiTypes.OBJECT, + responses={ + 200: OpenApiResponse(description="Successfully deleted badge requests"), + 404: OpenApiResponse(description="Some requests not found"), + 400: OpenApiResponse(description="Invalid request"), + }, + ) + def post(self, request, **kwargs): + try: + ids = request.data.get("ids", []) + + with transaction.atomic(): + deletion_queryset = RequestedBadge.objects.filter( + entity_id__in=ids, + ) + + found_ids = set(deletion_queryset.values_list("entity_id", flat=True)) + missing_ids = set(map(str, ids)) - set(map(str, found_ids)) + + if missing_ids: + return Response( + { + "error": "Some requests not found", + "missing_ids": list(missing_ids), + }, + status=HTTP_404_NOT_FOUND, + ) + + deleted_count = deletion_queryset.delete()[0] + + return Response( + { + "message": f"Successfully deleted {deleted_count} badge requests", + "deleted_count": deleted_count, + }, + status=HTTP_200_OK, + ) + + except DjangoValidationError as e: + return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) + except Exception as e: + return Response( + {"error": "An unexpected error occurred (" + str(e) + ")"}, + status=HTTP_400_BAD_REQUEST, + ) + + +class LearningPathDetail(BaseEntityDetailView): + model = LearningPath + v1_serializer_class = LearningPathSerializerV1 + permission_classes = (BadgrOAuthTokenHasScope, MayIssueLearningPath) + valid_scopes = ["rw:issuer"] + + @extend_schema( + summary="Get a single LearningPath", + tags=["LearningPaths"], + responses=LearningPathSerializerV1, + ) + def get(self, request, **kwargs): + return super(LearningPathDetail, self).get(request, **kwargs) + + @extend_schema( + summary="Update a single LearningPath", + tags=["LearningPaths"], + request=LearningPathSerializerV1, + responses=LearningPathSerializerV1, + ) + def put(self, request, **kwargs): + if not is_learningpath_editor(request.user, self.get_object(request, **kwargs)): + return Response( + {"error": "You are not authorized to delete this learning path."}, + status=status.HTTP_403_FORBIDDEN, + ) + return super(LearningPathDetail, self).put(request, **kwargs) + + @extend_schema( + summary="Delete a single LearningPath", + tags=["LearningPaths"], + responses={204: OpenApiResponse(description="Deleted")}, + ) + def delete(self, request, **kwargs): + if not is_learningpath_editor(request.user, self.get_object(request, **kwargs)): + return Response( + {"error": "You are not authorized to delete this learning path."}, + status=status.HTTP_403_FORBIDDEN, + ) + return super(LearningPathDetail, self).delete(request, **kwargs) + + +class IssuerStaffRequestList(BaseEntityListView): + model = IssuerStaffRequest + v1_serializer_class = IssuerStaffRequestSerializer + v2_serializer_class = IssuerStaffRequestSerializer + permission_classes = [ + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & BadgrOAuthTokenHasScope + & ApprovedIssuersOnly + & MayEditBadgeClass + ) + ] + valid_scopes = { + "post": ["*"], + "get": ["r:profile", "rw:profile"], + "put": ["rw:profile"], + "delete": ["rw:profile"], + } + + @extend_schema( + summary="Get a list of staff membership requests for the institution", + description="Use issuerSlug to retrieve staff membership requests", + tags=["IssuerStaffRequest"], + responses=IssuerStaffRequestSerializer(many=True), + ) + def get_objects(self, request, **kwargs): + issuerSlug = kwargs.get("issuerSlug") + try: + Issuer.objects.get(entity_id=issuerSlug) + except Issuer.DoesNotExist: + return Response( + {"response": "Institution not found"}, status=status.HTTP_404_NOT_FOUND + ) + + return IssuerStaffRequest.objects.filter( + issuer__entity_id=issuerSlug, + revoked=False, + status=IssuerStaffRequest.Status.PENDING, + ) + + def get(self, request, **kwargs): + return super(IssuerStaffRequestList, self).get(request, **kwargs) + + +class IssuerStaffRequestDetail(BaseEntityDetailView): + model = IssuerStaffRequest + v1_serializer_class = IssuerStaffRequestSerializer + permission_classes = [ + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & BadgrOAuthTokenHasScope + & ApprovedIssuersOnly + & MayEditBadgeClass + ) + ] + valid_scopes = ["rw:issuer"] + + @extend_schema( + summary="Get a single IssuerStaffRequest", + tags=["IssuerStaffRequest"], + responses=IssuerStaffRequestSerializer, + ) + def get(self, request, **kwargs): + return super(IssuerStaffRequestDetail, self).get(request, **kwargs) + + @extend_schema( + summary="Update a single IssuerStaffRequest", + tags=["IssuerStaffRequest"], + request=IssuerStaffRequestSerializer, + responses=IssuerStaffRequestSerializer, + ) + def put(self, request, **kwargs): + return super(IssuerStaffRequestDetail).put(request, **kwargs) + + @extend_schema( + summary="Delete a single IssuerStaffRequest", + tags=["IssuerStaffRequest"], + responses={ + 200: OpenApiResponse(IssuerStaffRequestSerializer), + 400: OpenApiResponse(description="Bad request"), + 404: OpenApiResponse(description="Not found"), + }, + ) + def delete(self, request, **kwargs): + try: + staff_request = IssuerStaffRequest.objects.get( + entity_id=kwargs.get("requestId") + ) + + if staff_request.status != IssuerStaffRequest.Status.PENDING: + if staff_request.status == IssuerStaffRequest.Status.REVOKED: + return Response( + { + "detail": "Request has already been revoked.", + }, + status=status.HTTP_200_OK, + ) + return Response( + {"detail": "Only pending requests can be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Update status to rejected instead of hard delete + staff_request.status = IssuerStaffRequest.Status.REJECTED + staff_request.save() + + serializer = self.v1_serializer_class(staff_request) + return Response(serializer.data, status=status.HTTP_200_OK) + + except IssuerStaffRequest.DoesNotExist: + return Response( + {"detail": "Staff request not found"}, status=status.HTTP_404_NOT_FOUND + ) + + +class IssuerStaffRequestConfirm(BaseEntityDetailView): + """Separate view for confirming requests""" + + model = IssuerStaffRequest + v1_serializer_class = IssuerStaffRequestSerializer + permission_classes = [ + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & BadgrOAuthTokenHasScope + & ApprovedIssuersOnly + & MayEditBadgeClass + ) + ] + valid_scopes = ["rw:issuer"] + + @extend_schema( + summary="Confirm a staff membership request", + description="Approve a pending staff request and grant access", + tags=["IssuerStaffRequest"], + request=IssuerStaffRequestSerializer, + responses=IssuerStaffRequestSerializer, + ) + def put(self, request, **kwargs): + try: + staff_request = IssuerStaffRequest.objects.get( + entity_id=kwargs.get("requestId") + ) + + badgrapp = BadgrApp.objects.get_by_id_or_default() + + if staff_request.status != IssuerStaffRequest.Status.PENDING: + return Response( + {"detail": "Only pending requests can be confirmed"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + staff_request.status = IssuerStaffRequest.Status.APPROVED + staff_request.save() + + serializer = self.v1_serializer_class(staff_request) + + email_context = { + "issuer": staff_request.issuer, + "activate_url": badgrapp.ui_login_redirect.rstrip("/"), + "call_to_action_label": "Jetzt loslegen", + } + get_adapter().send_mail( + "account/email/staff_request_confirmed", + staff_request.user.email, + email_context, + ) + return Response(serializer.data) + + except IssuerStaffRequest.DoesNotExist: + return Response( + {"detail": "Staff request not found"}, status=status.HTTP_404_NOT_FOUND + ) + pass + + +class BadgeImageComposition(APIView): + permission_classes = (IsAuthenticated,) + + @extend_schema( + summary="Compose a badge image", + description="Generate a composed image from badge + issuer/network logos.", + tags=["BadgeClasses"], + request=inline_serializer( + name="BadgeImageCompositionRequest", + fields={ + "badgeSlug": serializers.CharField(), + "issuerSlug": serializers.CharField(), + "category": serializers.CharField(), + "useIssuerImage": serializers.BooleanField(required=False), + }, + ), + responses={ + 200: inline_serializer( + name="BadgeImageCompositionResponse", + fields={ + "success": serializers.BooleanField(), + "image_url": serializers.CharField(), + "message": serializers.CharField(), + }, + ), + 400: OpenApiResponse(description="Invalid request"), + 404: OpenApiResponse(description="Badge or issuer not found"), + }, + ) + def post(self, request, *args, **kwargs): + try: + badgeSlug = request.data.get("badgeSlug") + issuerSlug = request.data.get("issuerSlug") + category = request.data.get("category") + useIssuerImage = request.data.get("useIssuerImage", True) + + try: + badge = BadgeClass.objects.get(entity_id=badgeSlug) + except Issuer.DoesNotExist: + return JsonResponse( + {"error": f"Badgeclass with slug {badgeSlug} not found"}, status=404 + ) + + if not issuerSlug: + return JsonResponse( + {"error": "Missing required field: issuerSlug"}, status=400 + ) + + if not category: + return JsonResponse( + {"error": "Missing required field: category"}, status=400 + ) + + try: + issuer = Issuer.objects.get(entity_id=issuerSlug) + except Issuer.DoesNotExist: + return JsonResponse( + {"error": f"Issuer with slug {issuerSlug} not found"}, status=404 + ) + + issuer_image = issuer.image if (useIssuerImage and issuer.image) else None + + network_image = None + + composer = ImageComposer(category=category) + + extensions = badge.cached_extensions() + org_img_ext = extensions.get(name="extensions:OrgImageExtension") + original_image = json.loads(org_img_ext.original_json)["OrgImage"] + + if badge.cached_issuer.is_network: + network_image = badge.cached_issuer.image + else: + shared_network = ( + BadgeClassNetworkShare.objects.filter( + badgeclass=badge, + network__memberships__issuer=issuer, + is_active=True, + ) + .select_related("network") + .first() + ) + + if shared_network and shared_network.network.image: + network_image = shared_network.network.image + + image_url = composer.compose_badge_from_uploaded_image( + original_image, issuer_image, network_image, draw_frame=badge.imageFrame + ) + + if not image_url: + return JsonResponse( + {"error": "Failed to compose badge image"}, status=500 + ) + + return JsonResponse( + { + "success": True, + "image_url": image_url, + "message": "Badge image composed successfully", + } + ) + + except Exception as e: + print(f"Error in BadgeImageComposition: {e}") + return JsonResponse( + {"error": f"Internal server error: {str(e)}"}, status=500 + ) + + +class NetworkInvitation(BaseEntityDetailView): + model = NetworkInvite + v1_serializer_class = NetworkInviteSerializer + permission_classes = [ + IsServerAdmin | (AuthenticatedWithVerifiedIdentifier & BadgrOAuthTokenHasScope) + ] + valid_scopes = ["rw:issuer"] + + @extend_schema( + summary="Get a single NetworkInvitation", + tags=["NetworkInvite"], + responses=NetworkInviteSerializer, + ) + def get(self, request, **kwargs): + return super(NetworkInvitation, self).get(request, **kwargs) + + @extend_schema( + summary="Revoke a network invitation", + tags=["NetworkInvite"], + responses={ + 200: NetworkInviteSerializer, + 400: OpenApiResponse(description="Invalid state"), + 404: OpenApiResponse(description="Not found"), + }, + ) + def delete(self, request, **kwargs): + try: + invite = NetworkInvite.objects.get(entity_id=kwargs.get("slug")) + + if invite.status != NetworkInvite.Status.PENDING: + if invite.status == NetworkInvite.Status.REVOKED: + return Response( + { + "detail": "Request has already been revoked.", + }, + status=status.HTTP_200_OK, + ) + return Response( + {"detail": "Only pending requests can be revoked"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + invite.status = NetworkInvite.Status.REVOKED + invite.revoked = True + invite.save() + + serializer = self.v1_serializer_class(invite) + return Response(serializer.data, status=status.HTTP_200_OK) + + except NetworkInvite.DoesNotExist: + return Response( + {"detail": "Network invitation not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + +class NetworkInvitationConfirm(BaseEntityDetailView): + """ + Confirm a network invitation. + """ + + model = NetworkInvite + v1_serializer_class = NetworkInviteSerializer + permission_classes = [ + IsServerAdmin | (AuthenticatedWithVerifiedIdentifier & BadgrOAuthTokenHasScope) + ] + valid_scopes = ["rw:issuer"] + + @extend_schema( + summary="Confirm network invitation", + description="Approve a pending network invitation.", + tags=["NetworkInvite"], + request=None, + responses=NetworkInviteSerializer, + ) + def put(self, request, **kwargs): + try: + invitation = NetworkInvite.objects.get(entity_id=kwargs.get("slug")) + + if invitation.status == NetworkInvite.Status.APPROVED: + return Response( + {"detail": "Issuer is already a partner of this network"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if invitation.status != NetworkInvite.Status.PENDING: + return Response( + {"detail": "Link expired"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + invitation.status = NetworkInvite.Status.APPROVED + invitation.acceptedOn = timezone.now() + invitation.save() + + if invitation.issuer: + NetworkMembership.objects.get_or_create( + network=invitation.network, issuer=invitation.issuer + ) + + serializer = self.v1_serializer_class(invitation) + return Response(serializer.data) + + except NetworkInvite.DoesNotExist: + return Response( + {"detail": "Invitation not found"}, status=status.HTTP_404_NOT_FOUND + ) + + +class NetworkInvitationList(BaseEntityListView): + model = NetworkInvite + v1_serializer_class = NetworkInviteSerializer + permission_classes = [ + IsServerAdmin | (AuthenticatedWithVerifiedIdentifier & BadgrOAuthTokenHasScope) + ] + valid_scopes = ["rw:issuer"] + + def get_objects(self, request, **kwargs): + status_filter = request.GET.get("status", "").lower() + + try: + network = Issuer.objects.get(entity_id=kwargs.get("networkSlug")) + except Issuer.DoesNotExist: + Exception("Network not found") + + queryset = NetworkInvite.objects.filter(network=network) + + if status_filter == "pending": + queryset = queryset.filter(status=NetworkInvite.Status.PENDING) + elif status_filter == "approved": + queryset = queryset.filter(status=NetworkInvite.Status.APPROVED) + else: + # return all + pass + + return queryset + + @extend_schema( + summary="Get network invitations", + description="Use the 'status' query parameter to filter.", + tags=["NetworkInvite"], + parameters=[ + OpenApiParameter( + name="status", + type=str, + location=OpenApiParameter.QUERY, + required=False, + enum=["pending", "approved"], + description="Filter invitations", + ) + ], + responses=NetworkInviteSerializer(many=True), + ) + def get(self, request, **kwargs): + return super(NetworkInvitationList, self).get(request, **kwargs) + + @extend_schema( + summary="Create network invitations", + tags=["NetworkInvite"], + request=NetworkInviteSerializer(many=True), + responses={ + 201: OpenApiResponse(description="Invitations created"), + 400: OpenApiResponse(description="Bad request"), + 403: OpenApiResponse(description="Forbidden"), + 404: OpenApiResponse(description="Not found"), + }, + ) + def post(self, request, **kwargs): + try: + network_slug = kwargs.get("networkSlug") + network = Issuer.objects.get(entity_id=network_slug, is_network=True) + except Issuer.DoesNotExist: + return Response( + {"response": "Network not found"}, status=status.HTTP_404_NOT_FOUND + ) + + if not is_editor(request.user, network): + return Response( + {"error": "You are not authorized to invite issuers."}, + status=status.HTTP_403_FORBIDDEN, + ) + + issuers_data = request.data + if not issuers_data: + return Response( + {"response": "No issuers provided"}, status=status.HTTP_400_BAD_REQUEST + ) + + slugs = [] + for issuer_data in issuers_data: + slug = issuer_data.get("slug") + if not slug: + return Response( + {"response": "All issuers must have a slug"}, + status=status.HTTP_400_BAD_REQUEST, + ) + slugs.append(slug) + + issuers = Issuer.objects.filter(entity_id__in=slugs, is_network=False) + found_slugs = set(issuers.values_list("entity_id", flat=True)) + + missing_slugs = set(slugs) - found_slugs + if missing_slugs: + return Response( + {"response": f"Issuers not found: {', '.join(missing_slugs)}"}, + status=status.HTTP_404_NOT_FOUND, + ) + + existing_partner_ids = set( + network.partner_issuers.filter(entity_id__in=slugs).values_list( + "entity_id", flat=True + ) + ) + + if existing_partner_ids: + existing_names = list( + issuers.filter(entity_id__in=existing_partner_ids).values_list( + "name", flat=True + ) + ) + return Response( + { + "response": f"Diese Institutionen sind bereits Teil des Netzwerks: {', '.join(existing_names)}" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + existing_invitations = NetworkInvite.objects.filter( + issuer__entity_id__in=slugs, + network=network, + status=NetworkInvite.Status.PENDING, + ).select_related("issuer") + + try: + with transaction.atomic(): + created_invitations = [] + for issuer in issuers: + # Revoke existing pending invitations to prevent duplicates + if existing_invitations.exists(): + existing_invitations.filter( + issuer=issuer, network=network + ).delete() + + invitation = NetworkInvite.objects.create( + issuer=issuer, network=network + ) + created_invitations.append(invitation) + + owners = issuer.cached_issuerstaff().filter( + role=IssuerStaff.ROLE_OWNER + ) + + email_context = { + "network": network, + "issuer": issuer, + "activate_url": OriginSetting.HTTP + + reverse( + "v1_api_user_confirm_network_invite", + current_app="badgeuser", + kwargs={"inviteSlug": invitation.entity_id}, + ), + "call_to_action_label": "Einladung bestätigen", + } + + adapter = get_adapter() + for owner in owners: + adapter.send_mail( + "issuer/email/notify_issuer_network_invitation", + owner.user.email, + email_context, + ) + + return Response( + { + "response": f"Successfully created {len(created_invitations)} network invitations", + "created_count": len(created_invitations), + "created_for": [inv.issuer.name for inv in created_invitations], + }, + status=status.HTTP_201_CREATED, + ) + except Exception as e: + return Response( + {"response": f"Failed to create invitations: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +class NetworkSharedBadgesView(BaseEntityListView): + """ + Get all badges shared with a specific network + """ + + model = BadgeClassNetworkShare + v1_serializer_class = BadgeClassNetworkShareSerializerV1 + permission_classes = [AllowAny] # partner badges are shown on public network page + valid_scopes = ["rw:issuer"] + + allow_any_unauthenticated_access = True + + def get_objects(self, request, **kwargs): + """Get all badge shares for a specific network""" + + entity_id = kwargs.get("entity_id") + network = get_object_or_404(Issuer, entity_id=entity_id, is_network=True) + + return ( + BadgeClassNetworkShare.objects.filter(network=network, is_active=True) + .select_related( + "badgeclass", "badgeclass__issuer", "network", "shared_by_user" + ) + .order_by("-shared_at") + ) + + @extend_schema( + summary="Get all badges shared with a network", + tags=["Networks"], + responses=BadgeClassNetworkShareSerializerV1(many=True), + ) + def get(self, request, **kwargs): + """ + Get all badges that have been shared with a network. + """ + + return super(NetworkSharedBadgesView, self).get(request, **kwargs) + + +class IssuerSharedNetworkBadgesView(BaseEntityListView): + """ + Get all badges that a specific issuer has shared with networks + """ + + model = BadgeClassNetworkShare + v1_serializer_class = BadgeClassNetworkShareSerializerV1 + permission_classes = [AllowAny] + valid_scopes = ["rw:issuer"] + + allow_any_unauthenticated_access = True + + def get_objects(self, request, **kwargs): + """Get all badge shares from an issuer to any network""" + + entity_id = kwargs.get("entity_id") + issuer = get_object_or_404(Issuer, entity_id=entity_id) + + return ( + BadgeClassNetworkShare.objects.filter( + shared_by_issuer=issuer, is_active=True + ) + .select_related( + "badgeclass", "badgeclass__issuer", "network", "shared_by_user" + ) + .order_by("-shared_at") + ) + + @extend_schema( + summary="Get all badges shared by an issuer with networks", + tags=["Issuers"], + responses=BadgeClassNetworkShareSerializerV1(many=True), + ) + def get(self, request, **kwargs): + """ + Get all badges that an issuer has shared with networks. + """ + + return super(IssuerSharedNetworkBadgesView, self).get(request, **kwargs) + + +class BadgeClassNetworkShareView(BaseEntityDetailView): + """ + Share a badge class with a network + """ + + model = BadgeClassNetworkShare + v1_serializer_class = BadgeClassNetworkShareSerializerV1 + permission_classes = [ + IsServerAdmin + | ( + AuthenticatedWithVerifiedIdentifier + & BadgrOAuthTokenHasScope + & ApprovedIssuersOnly + ) + ] + valid_scopes = ["rw:issuer"] + + def get_objects(self, request, **kwargs): + """Get all badge shares for the authenticated user's issuers""" + user_issuers = Issuer.objects.filter(staff__id=request.user.id).values_list( + "id", flat=True + ) + + return BadgeClassNetworkShare.objects.filter( + badgeclass__issuer__id__in=user_issuers + ).distinct() + + @extend_schema( + summary="Share a badge with a network", + tags=["BadgeClasses", "Networks"], + request=None, # no body, uses path params + responses=BadgeClassNetworkShareSerializerV1, + ) + def post(self, request, **kwargs): + """ + Share a badge class with a network + """ + badgeclass_id = kwargs.get("badgeSlug") + network_id = kwargs.get("networkSlug") + + if not badgeclass_id or not network_id: + return Response( + {"error": "Both badgeclass_id and network_id are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + badgeclass = get_object_or_404( + BadgeClass, entity_id=badgeclass_id, issuer__staff=request.user + ) + + network = get_object_or_404(Issuer, entity_id=network_id, is_network=True) + + if not NetworkMembership.objects.filter( + network=network, issuer=badgeclass.issuer + ).exists(): + return Response( + {"error": "Your issuer is not a member of this network"}, + status=status.HTTP_403_FORBIDDEN, + ) + + if BadgeClassNetworkShare.objects.filter(badgeclass=badgeclass).exists(): + return Response( + {"error": "Badge is already shared with a network."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + share = BadgeClassNetworkShare.objects.create( + badgeclass=badgeclass, + network=network, + shared_by_user=request.user, + shared_by_issuer=badgeclass.issuer, + ) + + extensions = badgeclass.cached_extensions() + category_ext = extensions.get(name="extensions:CategoryExtension") + category = json.loads(category_ext.original_json)["Category"] + + org_img_ext = extensions.get(name="extensions:OrgImageExtension") + original_image = json.loads(org_img_ext.original_json)["OrgImage"] + + badgeclass.generate_badge_image( + category, original_image, badgeclass.issuer.image, network.image + ) + + badgeclass.copy_permissions = BadgeClass.COPY_PERMISSIONS_NONE + badgeclass.save(update_fields=["image", "copy_permissions", "issuer"]) + + serializer = self.get_serializer_class()(share) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/apps/issuer/api_v1.py b/apps/issuer/api_v1.py index 1e432b8c4..7a28ee5fc 100644 --- a/apps/issuer/api_v1.py +++ b/apps/issuer/api_v1.py @@ -1,25 +1,31 @@ # encoding: utf-8 - -from apispec_drf.decorators import apispec_list_operation, apispec_operation +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + OpenApiParameter, + OpenApiExample, + inline_serializer, +) from django.contrib.auth import get_user_model -from rest_framework import status, authentication +from rest_framework import status, authentication, serializers from rest_framework.exceptions import ValidationError, PermissionDenied, NotFound from rest_framework.response import Response from rest_framework.views import APIView -import badgrlog from badgeuser.models import CachedEmailAddress, UserRecipientIdentifier from entity.api import VersionedObjectMixin from issuer.models import Issuer, IssuerStaff from issuer.permissions import IsOwnerOrStaff, BadgrOAuthTokenHasEntityScope -from issuer.serializers_v1 import BadgeClassSerializerV1, IssuerRoleActionSerializerV1, IssuerStaffSerializerV1 +from issuer.serializers_v1 import ( + BadgeClassSerializerV1, + IssuerRoleActionSerializerV1, + IssuerStaffSerializerV1, +) from issuer.utils import get_badgeclass_by_identifier from mainsite.permissions import AuthenticatedWithVerifiedIdentifier, IsServerAdmin from mainsite.utils import throttleable -logger = badgrlog.BadgrLogger() - class AbstractIssuerAPIEndpoint(APIView): authentication_classes = ( @@ -30,7 +36,7 @@ class AbstractIssuerAPIEndpoint(APIView): permission_classes = (AuthenticatedWithVerifiedIdentifier,) def get_object(self, slug, queryset=None): - """ Ensure user has permissions on Issuer """ + """Ensure user has permissions on Issuer""" queryset = queryset if queryset is not None else self.queryset try: @@ -46,7 +52,7 @@ def get_object(self, slug, queryset=None): return obj def get_list(self, slug=None, queryset=None, related=None): - """ Ensure user has permissions on Issuer, and return badgeclass queryset if so. """ + """Ensure user has permissions on Issuer, and return badgeclass queryset if so.""" queryset = queryset if queryset is not None else self.queryset obj = queryset @@ -66,91 +72,131 @@ def get_list(self, slug=None, queryset=None, related=None): return obj +@extend_schema_view( + get=extend_schema( + summary="Get a list of users associated with a role on an Issuer", + tags=["Issuers"], + parameters=[ + OpenApiParameter( + "slug", + type=str, + location=OpenApiParameter.PATH, + description="The slug of the issuer", + required=True, + ) + ], + responses={ + 200: IssuerStaffSerializerV1(many=True), + 404: inline_serializer( + name="IssuerStaffNotFound", + fields={"error": serializers.CharField()}, + ), + }, + ), + post=extend_schema( + summary="Add or remove a user from a role on an issuer. Limited to Owner users only", + tags=["Issuers"], + parameters=[ + OpenApiParameter( + "slug", + type=str, + location=OpenApiParameter.PATH, + description="The slug of the issuer whose roles to modify", + required=True, + ) + ], + request=IssuerRoleActionSerializerV1, + responses={ + 200: inline_serializer( + name="IssuerStaffRemoved", + fields={"message": serializers.CharField()}, + ), + 201: IssuerStaffSerializerV1, + 400: inline_serializer( + name="IssuerStaffBadRequest", + fields={"error": serializers.CharField()}, + ), + 404: inline_serializer( + name="IssuerStaffUserNotFound", + fields={"error": serializers.CharField()}, + ), + }, + examples=[ + OpenApiExample( + "Add user by email", + value={ + "action": "add", + "email": "user@example.com", + "role": "staff", + }, + request_only=True, + ), + OpenApiExample( + "Add user by username", + value={ + "action": "add", + "username": "johndoe", + "role": "editor", + }, + request_only=True, + ), + OpenApiExample( + "Modify user role", + value={ + "action": "modify", + "email": "user@example.com", + "role": "owner", + }, + request_only=True, + ), + OpenApiExample( + "Remove user", + value={ + "action": "remove", + "email": "user@example.com", + }, + request_only=True, + ), + ], + ), +) class IssuerStaffList(VersionedObjectMixin, APIView): - """ View or modify an issuer's staff members and privileges """ - role = 'staff' + """View or modify an issuer's staff members and privileges""" + + role = "staff" queryset = Issuer.objects.all() model = Issuer permission_classes = [ - IsServerAdmin | - (AuthenticatedWithVerifiedIdentifier & IsOwnerOrStaff) | - BadgrOAuthTokenHasEntityScope + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsOwnerOrStaff) + | BadgrOAuthTokenHasEntityScope ] valid_scopes = { "get": ["rw:issuerOwner:*"], "post": ["rw:issuerOwner:*"], - "@apispec_scopes": {} + "@apispec_scopes": {}, } - @apispec_list_operation('IssuerStaff', - tags=['Issuers'], - summary="Get a list of users associated with a role on an Issuer" - ) def get(self, request, **kwargs): current_issuer = self.get_object(request, **kwargs) if not self.has_object_permissions(request, current_issuer): return Response( - "Issuer %s not found. Authenticated user must have owner, editor or staff rights on the issuer.".format( - kwargs.get('slug') + "Issuer {} not found. Authenticated user must have owner, editor or staff rights on the issuer.".format( + kwargs.get("slug") ), - status=status.HTTP_404_NOT_FOUND + status=status.HTTP_404_NOT_FOUND, ) serializer = IssuerStaffSerializerV1( - IssuerStaff.objects.filter(issuer=current_issuer), - many=True + IssuerStaff.objects.filter(issuer=current_issuer), many=True ) if len(serializer.data) == 0: return Response([], status=status.HTTP_200_OK) return Response(serializer.data) - @apispec_operation( - tags=['Issuers'], - summary="Add or remove a user from a role on an issuer. Limited to Owner users only" - ) @throttleable def post(self, request, **kwargs): - """ - --- - parameters: - - name: slug - type: string - paramType: path - description: The slug of the issuer whose roles to modify. - required: true - - name: action - type: string - paramType: form - description: The action to perform on the user. Must be one of 'add', 'modify', or 'remove'. - required: true - - name: username - type: string - paramType: form - description: The username of the user whose role will be added, removed or modified. - required: false - - name: email - type: string - paramType: form - description: A verified email address of the user whose role will be added, removed or modified. - required: false - - name: url - type: string - paramType: form - description: A verified user recipient identifier of the user whose role will be added, removed or modified. Must be of type url. - required: false - - name: telephone - type: string - paramType: form - description: A verified user recipient identifier of the user whose role will be added, removed or modified. Must be of type telephone. - required: false - - name: role - type: string - paramType: form - description: Role to set user as. One of 'owner', 'editor', or 'staff' - defaultValue: staff - required: false - """ # validate POST data serializer = IssuerRoleActionSerializerV1(data=request.data) if not serializer.is_valid(): @@ -160,68 +206,90 @@ def post(self, request, **kwargs): user_id = None try: - if serializer.validated_data.get('username'): - user_id = serializer.validated_data.get('username') + if serializer.validated_data.get("username"): + user_id = serializer.validated_data.get("username") user_to_modify = get_user_model().objects.get(username=user_id) - elif serializer.validated_data.get('url'): - user_id = serializer.validated_data.get('url') + elif serializer.validated_data.get("url"): + user_id = serializer.validated_data.get("url") user_to_modify = UserRecipientIdentifier.objects.get( identifier=user_id, verified=True ).user - elif serializer.validated_data.get('telephone'): - user_id = serializer.validated_data.get('telephone') + elif serializer.validated_data.get("telephone"): + user_id = serializer.validated_data.get("telephone") user_to_modify = UserRecipientIdentifier.objects.get( identifier=user_id, verified=True ).user else: - user_id = serializer.validated_data.get('email') + user_id = serializer.validated_data.get("email") user_to_modify = CachedEmailAddress.objects.get( - email=user_id, verified=True).user - except (get_user_model().DoesNotExist, CachedEmailAddress.DoesNotExist, UserRecipientIdentifier.DoesNotExist): - error_text = "User not found. Email must correspond to an existing user." + email=user_id, verified=True + ).user + except ( + get_user_model().DoesNotExist, + CachedEmailAddress.DoesNotExist, + UserRecipientIdentifier.DoesNotExist, + ): + error_text = "Wir haben die E-Mail-Adresse nicht im System gefunden. Es kÃļnnen nur Personen als Mitglied hinzugefÃŧgt werden, die sich bereits einen Account auf OEB angelegt haben." if user_id is None: - error_text = 'User not found. please provide a valid email address, username, url or telephone identifier.' - return Response( - error_text, status=status.HTTP_404_NOT_FOUND - ) + error_text = ( + "User not found. please provide a valid email address, " + "username, url or telephone identifier." + ) + return Response(error_text, status=status.HTTP_404_NOT_FOUND) if user_to_modify == request.user: - return Response("Cannot modify your own permissions on an issuer profile", - status=status.HTTP_400_BAD_REQUEST) + return Response( + "Cannot modify your own permissions on an issuer profile", + status=status.HTTP_400_BAD_REQUEST, + ) - action = serializer.validated_data.get('action') - if action == 'add': - role = serializer.validated_data.get('role') + action = serializer.validated_data.get("action") + if action == "add": + role = serializer.validated_data.get("role") staff_instance, created = IssuerStaff.objects.get_or_create( - user=user_to_modify, - issuer=current_issuer, - defaults={ - 'role': role - } + user=user_to_modify, issuer=current_issuer, defaults={"role": role} ) if created is False: - raise ValidationError("Could not add user to staff list. User already in staff list.") + raise ValidationError( + "Could not add user to staff list. User already in staff list." + ) - elif action == 'modify': - role = serializer.validated_data.get('role') + elif action == "modify": + role = serializer.validated_data.get("role") try: staff_instance = IssuerStaff.objects.get( - user=user_to_modify, - issuer=current_issuer + user=user_to_modify, issuer=current_issuer ) staff_instance.role = role - staff_instance.save(update_fields=('role',)) + staff_instance.save(update_fields=("role",)) except IssuerStaff.DoesNotExist: - raise ValidationError("Cannot modify staff record. Matching staff record does not exist.") + raise ValidationError( + "Cannot modify staff record. Matching staff record does not exist." + ) - elif action == 'remove': - IssuerStaff.objects.filter(user=user_to_modify, issuer=current_issuer).delete() + elif action == "remove": + issuer_staffs = IssuerStaff.objects.filter( + user=user_to_modify, issuer=current_issuer + ) + # Do the deletion one by one so that the issuer_staffs custom delete method is called + for issuer_staff in issuer_staffs: + # Update the current issuer with a reference to the issuer of the issuer_staff, + # since it's getting updated in the deletion process + current_issuer = issuer_staff.issuer + issuer_staff.delete() current_issuer.publish(publish_staff=False) user_to_modify.publish() + + # update cached issuers and badgeclasses for user + current_issuer.save() + user_to_modify.save() + return Response( - "User %s has been removed from %s staff." % (user_to_modify.username, current_issuer.name), - status=status.HTTP_200_OK) + "User %s has been removed from %s staff." + % (user_to_modify.username, current_issuer.name), + status=status.HTTP_200_OK, + ) # update cached issuers and badgeclasses for user user_to_modify.save() @@ -229,30 +297,47 @@ def post(self, request, **kwargs): return Response(IssuerStaffSerializerV1(staff_instance).data) +@extend_schema_view( + get=extend_schema( + summary="Get a specific BadgeClass by searching by identifier", + tags=["BadgeClasses"], + parameters=[ + OpenApiParameter( + "identifier", + type=str, + location=OpenApiParameter.QUERY, + description="The identifier of the badge. Possible values: JSONld identifier, BadgeClass.id, or BadgeClass.slug", + required=True, + ) + ], + responses={ + 200: BadgeClassSerializerV1, + 404: inline_serializer( + name="BadgeClassNotFound", + fields={"detail": serializers.CharField()}, + ), + }, + examples=[ + OpenApiExample( + "Find by slug", + value={"identifier": "my-badge-class"}, + request_only=False, + ) + ], + ), +) class FindBadgeClassDetail(APIView): """ GET a specific BadgeClass by searching by identifier """ + permission_classes = (AuthenticatedWithVerifiedIdentifier,) - @apispec_operation( - summary="Get a specific BadgeClass by searching by identifier", - tags=['BadgeClasses'], - parameters=[ - { - "in": "query", - "name": "identifier", - 'required': True, - "description": "The identifier of the badge possible values: JSONld identifier, BadgeClass.id, or BadgeClass.slug" - } - ] - ) def get(self, request, **kwargs): - identifier = request.query_params.get('identifier') + identifier = request.query_params.get("identifier") badge = get_badgeclass_by_identifier(identifier) if badge is None: raise NotFound("No BadgeClass found by identifier: {}".format(identifier)) serializer = BadgeClassSerializerV1(badge) return Response(serializer.data) - diff --git a/apps/issuer/api_v3.py b/apps/issuer/api_v3.py new file mode 100644 index 000000000..f5d31884a --- /dev/null +++ b/apps/issuer/api_v3.py @@ -0,0 +1,709 @@ +import json +from django.conf import settings +from django.http import ( + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + JsonResponse, +) +from django.utils import timezone +from django_filters import rest_framework as filters +from oauth2_provider.models import AccessToken, Application +from oauthlib.oauth2.rfc6749.tokens import random_token_generator +from rest_framework import viewsets, permissions, serializers +from rest_framework.response import Response +from rest_framework.filters import OrderingFilter +from rest_framework.pagination import LimitOffsetPagination + + +from issuer.serializers_v3 import ( + RequestIframeBadgeProcessSerializer, + RequestIframeSerializer, +) +from backpack.api import BackpackAssertionList +from badgeuser.api import LearningPathList +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + inline_serializer, + OpenApiParameter, +) +from rest_framework.views import APIView + +from backpack.utils import get_skills_tree +from mainsite.models import IframeUrl +from issuer.permissions import ( + BadgrOAuthTokenHasEntityScope, + BadgrOAuthTokenHasScope, + IsStaff, +) +from mainsite.permissions import AuthenticatedWithVerifiedIdentifier, IsServerAdmin +from entity.api_v3 import ( + EntityFilter, + EntityViewSet, + TagFilter, +) + +from .serializers_v1 import ( + BadgeClassSerializerV1, + BadgeInstanceSerializerV1, + IssuerSerializerV1, + LearningPathSerializerV1, + NetworkSerializerV1, +) +from .models import ( + BadgeClass, + BadgeClassTag, + BadgeInstance, + Issuer, + LearningPath, + BadgeInstanceExtension, + LearningPathBadge, +) +from django.db.models import Q, Count + + +class BadgeFilter(EntityFilter): + tags = TagFilter(field_name="badgeclasstag__name", lookup_expr="icontains") + + +class IssuerFilter(EntityFilter): + category = filters.CharFilter( + field_name="category", + lookup_expr="icontains", + ) + + +class BadgeInstanceV3FilterSet(filters.FilterSet): + issuer = filters.CharFilter(field_name="issuer__entity_id", lookup_expr="exact") + badgeclass = filters.CharFilter( + field_name="badgeclass__entity_id", lookup_expr="exact" + ) + recipient = filters.CharFilter(method="filter_recipient") + + def filter_recipient(self, queryset, name, value): + if not value: + return queryset + + matching_extensions = BadgeInstanceExtension.objects.filter( + name="extensions:recipientProfile", original_json__icontains=value + ).values_list("badgeinstance_id", flat=True) + + return queryset.filter( + Q(recipient_identifier__icontains=value) | Q(pk__in=matching_extensions) + ).distinct() + + class Meta: + model = BadgeInstance + fields = ["issuer", "badgeclass", "recipient"] + + +@extend_schema_view( + list=extend_schema( + summary="Get a list of Badges", + tags=["BadgeClasses"], + parameters=[ + OpenApiParameter( + "tags", + type=str, + description="Filter by tag name (case-insensitive partial match)", + ), + OpenApiParameter( + "ordering", + type=str, + description="Order by field. Available fields: name, created_at", + ), + ], + ), + retrieve=extend_schema( + summary="Get a specific Badge by ID", + tags=["BadgeClasses"], + ), + create=extend_schema( + summary="Create a new Badge", + tags=["BadgeClasses"], + ), + update=extend_schema( + summary="Update a Badge", + tags=["BadgeClasses"], + ), + partial_update=extend_schema( + summary="Partially update a Badge", + tags=["BadgeClasses"], + ), + destroy=extend_schema( + summary="Delete a Badge", + tags=["BadgeClasses"], + ), +) +class Badges(EntityViewSet): + queryset = BadgeClass.objects.all() + serializer_class = BadgeClassSerializerV1 + filterset_class = BadgeFilter + ordering_fields = ["name", "created_at"] + + def get(self, request, **kwargs): + pass + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.distinct() + + +class BadgeInstances(EntityViewSet): + queryset = BadgeInstance.objects.filter(revoked=False) + serializer_class = BadgeInstanceSerializerV1 + pagination_class = LimitOffsetPagination + filter_backends = [filters.DjangoFilterBackend, OrderingFilter] + filterset_class = BadgeInstanceV3FilterSet + ordering_fields = ["created_at", "recipient_identifier"] + ordering = ["-created_at"] + + +@extend_schema_view( + list=extend_schema( + summary="Get a list of all available badge tags", + tags=["BadgeClasses"], + description="Fetch all available tags that existing Badges may be filtered by", + responses={ + 200: inline_serializer( + name="BadgeTagsResponse", + fields={"tags": serializers.ListField(child=serializers.CharField())}, + ) + }, + ), +) +class BadgeTags(viewsets.ViewSet): + """A ViewSet to fetch all available tags the existing Badges may be filtered by""" + + permission_classes = [permissions.AllowAny] # anybody may see all badge tags + + def list(self, request, **kwargs): + tag_names = ( + BadgeClassTag.objects.order_by("name") + .values_list("name", flat=True) + .distinct() + ) + return Response(list(tag_names)) + + +@extend_schema_view( + list=extend_schema( + summary="Get a list of Issuers", + tags=["Issuers"], + ), + retrieve=extend_schema( + summary="Get a specific Issuer by ID", + tags=["Issuers"], + ), + create=extend_schema( + summary="Create a new Issuer", + tags=["Issuers"], + ), + update=extend_schema( + summary="Update an Issuer", + tags=["Issuers"], + ), + partial_update=extend_schema( + summary="Partially update an Issuer", + tags=["Issuers"], + ), + destroy=extend_schema( + summary="Delete an Issuer", + tags=["Issuers"], + ), +) +class Issuers(EntityViewSet): + queryset = Issuer.objects.all() + serializer_class = IssuerSerializerV1 + filterset_class = IssuerFilter + + ordering_fields = ["name", "created_at", "badge_count"] + ordering = ["-badge_count"] + + def get_queryset(self): + return Issuer.objects.all().annotate( + badge_count=Count("badgeclasses", distinct=True) + ) + + def get_serializer_context(self): + context = super().get_serializer_context() + # some fields have to be excluded due to data privacy concerns + # in the get routes + if self.request.method == "GET": + context["exclude_fields"] = [ + *context.get("exclude_fields", []), + "staff", + "created_by", + ] + return context + + +@extend_schema_view( + list=extend_schema( + summary="Get a list of Networks", + tags=["Networks"], + ), + retrieve=extend_schema( + summary="Get a specific Network by ID", + tags=["Networks"], + ), + create=extend_schema( + summary="Create a new Network", + tags=["Networks"], + ), + update=extend_schema( + summary="Update a Network", + tags=["Networks"], + ), + partial_update=extend_schema( + summary="Partially update a Network", + tags=["Networks"], + ), + destroy=extend_schema( + summary="Delete a Network", + tags=["Networks"], + ), +) +class Networks(EntityViewSet): + queryset = Issuer.objects.filter(is_network=True) + serializer_class = NetworkSerializerV1 + + def get_serializer_context(self): + context = super().get_serializer_context() + # some fields have to be excluded due to data privacy concerns + # in the get routes + if self.request.method == "GET": + context["exclude_fields"] = [ + *context.get("exclude_fields", []), + "staff", + "created_by", + "partner_issuers", + ] + return context + + +class LearningPathFilter(EntityFilter): + tags = filters.CharFilter( + field_name="learningpathtag__name", lookup_expr="icontains" + ) + + +@extend_schema_view( + list=extend_schema( + summary="Get a list of Learning Paths", + tags=["LearningPaths"], + parameters=[ + OpenApiParameter( + "tags", + type=str, + description="Filter by tag name (case-insensitive partial match)", + ), + ], + ), + retrieve=extend_schema( + summary="Get a specific Learning Path by ID", + tags=["LearningPaths"], + ), + create=extend_schema( + summary="Create a new Learning Path", + tags=["LearningPaths"], + ), + update=extend_schema( + summary="Update a Learning Path", + tags=["LearningPaths"], + ), + partial_update=extend_schema( + summary="Partially update a Learning Path", + tags=["LearningPaths"], + ), + destroy=extend_schema( + summary="Delete a Learning Path", + tags=["LearningPaths"], + ), +) +class LearningPaths(EntityViewSet): + queryset = LearningPath.objects.all() + serializer_class = LearningPathSerializerV1 + filterset_class = LearningPathFilter + + +class RequestIframe(APIView): + def get(self, request, **kwargs): + # for easier in-browser testing + if settings.DEBUG: + request._request.POST = request.GET + return self.post(request, **kwargs) + else: + return HttpResponse(b"", status=405) + + def post(self, request, **kwargs): + return HttpResponse() + + def get_badgeinstances_from_post( + self, request, serializer: RequestIframeSerializer + ): + if not request.user: + raise Exception("Request must contain a user") + + if not serializer.is_valid(): + raise Exception("Serializer must be valid to obtain badge instances") + + emails = serializer.validated_data.get("email").split(",") + issuers = Issuer.objects.filter(staff__id=request.user.id).distinct() + if issuers.count == 0: + return HttpResponseForbidden() + + instances = [] + for issuer in issuers: + instances += BadgeInstance.objects.filter( + issuer=issuer, recipient_identifier__in=emails + ) + + return instances + + +@extend_schema(exclude=True) +class LearnersProfile(RequestIframe): + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def post(self, request, **kwargs): + s = RequestIframeSerializer(data=request.data) + + if not s.is_valid(): + return HttpResponseBadRequest(json.dumps(s.errors)) + + instances = self.get_badgeinstances_from_post(request, s) + if instances is HttpResponseForbidden: + return instances + + language = s.validated_data.get("lang") + tree = get_skills_tree( + BackpackAssertionList().get_filtered_objects(instances, True, False, True), + language, + ) + + iframe = IframeUrl.objects.create( + name="profile", + params={"skills": tree["skills"], "language": language}, + created_by=request.user, + ) + + return JsonResponse({"url": iframe.url}) + + +@extend_schema(exclude=True) +class LearnersCompetencies(RequestIframe): + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def post(self, request, **kwargs): + s = RequestIframeSerializer(data=request.data) + + if not s.is_valid(): + return HttpResponseBadRequest(json.dumps(s.errors)) + + instances = self.get_badgeinstances_from_post(request, s) + if instances is HttpResponseForbidden: + return instances + + language = s.validated_data.get("lang") + badge_serializer = BackpackAssertionList.v1_serializer_class() + badge_serializer.context["format"] = "plain" + iframe = IframeUrl.objects.create( + name="competencies", + params={ + "badges": list( + badge_serializer.to_representation(i) + for i in BackpackAssertionList().get_filtered_objects( + instances, True, False, True + ) + ), + "language": language, + }, + created_by=request.user, + ) + + return JsonResponse({"url": iframe.url}) + + +@extend_schema(exclude=True) +class LearnersBadges(RequestIframe): + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def post(self, request, **kwargs): + s = RequestIframeSerializer(data=request.data) + + if not s.is_valid(): + return HttpResponseBadRequest(json.dumps(s.errors)) + + instances = self.get_badgeinstances_from_post(request, s) + if instances is HttpResponseForbidden: + return instances + + language = s.validated_data.get("lang") + badge_serializer = BackpackAssertionList.v1_serializer_class() + badge_serializer.context["format"] = "plain" + iframe = IframeUrl.objects.create( + name="badges", + params={ + "badges": list( + badge_serializer.to_representation(i) + for i in BackpackAssertionList().get_filtered_objects( + instances, True, False, True + ) + ), + "language": language, + }, + created_by=request.user, + ) + + return JsonResponse({"url": iframe.url}) + + +@extend_schema(exclude=True) +class LearnersLearningPaths(RequestIframe): + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def post(self, request, **kwargs): + s = RequestIframeSerializer(data=request.data) + + if not s.is_valid(): + return HttpResponseBadRequest(json.dumps(s.errors)) + + instances = self.get_badgeinstances_from_post(request, s) + if instances is HttpResponseForbidden: + return instances + + language = s.validated_data.get("lang") + badges = list( + { + badgeinstance.badgeclass + for badgeinstance in instances + if badgeinstance.revoked is False + } + ) + lp_badges = LearningPathBadge.objects.filter(badge__in=badges) + lps = LearningPath.objects.filter( + activated=True, learningpathbadge__in=lp_badges + ).distinct() + + iframe = IframeUrl.objects.create( + name="learningpaths", + params={ + "learningpaths": list( + LearningPathList.v1_serializer_class().to_representation(lp) + for lp in lps + ), + "language": language, + }, + created_by=request.user, + ) + + return JsonResponse({"url": iframe.url}) + + +@extend_schema(exclude=True) +class LearnersBackpack(RequestIframe): + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + valid_scopes = ["rw:issuer", "rw:issuer:*"] + + def post(self, request, **kwargs): + s = RequestIframeSerializer(data=request.data) + + if not s.is_valid(): + return HttpResponseBadRequest(json.dumps(s.errors)) + + instances = self.get_badgeinstances_from_post(request, s) + if instances is HttpResponseForbidden: + return instances + + language = s.validated_data.get("lang") + + badges = list( + { + badgeinstance.badgeclass + for badgeinstance in instances + if badgeinstance.revoked is False + } + ) + lp_badges = LearningPathBadge.objects.filter(badge__in=badges) + lps = LearningPath.objects.filter( + activated=True, learningpathbadge__in=lp_badges + ).distinct() + + filtered_instances = BackpackAssertionList().get_filtered_objects( + instances, True, False, True + ) + + tree = get_skills_tree(filtered_instances, language) + badge_serializer = BackpackAssertionList.v1_serializer_class() + badge_serializer.context["format"] = "plain" + iframe = IframeUrl.objects.create( + name="backpack", + params={ + "skills": tree["skills"], + "badges": list( + badge_serializer.to_representation(i) for i in filtered_instances + ), + "learningpaths": list( + LearningPathList.v1_serializer_class().to_representation(lp) + for lp in lps + ), + "language": language, + }, + created_by=request.user, + ) + + return JsonResponse({"url": iframe.url}) + + +@extend_schema(exclude=True) +class BadgeCreateEmbed(RequestIframe): + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + valid_scopes = ["rw:issuer", "rw:issuer:*", "rw:profile"] + + def post(self, request, **kwargs): + if not request.user: + return HttpResponseForbidden() + + s = RequestIframeBadgeProcessSerializer(data=request.data) + + if not s.is_valid(): + return HttpResponseBadRequest(json.dumps(s.errors)) + language = s.validated_data.get("lang") + + try: + given_issuer = s.validated_data.get("issuer") + issuers = Issuer.objects.filter(staff__id=request.user.id).distinct() + if ( + issuers.count() == 0 + or issuers.filter(entity_id=given_issuer).count() == 0 + ): + return HttpResponseForbidden() + issuer = issuers.get(entity_id=given_issuer) + except AttributeError: + issuer = None + pass + + if request.auth: + application = request.auth.application + else: + # use public oauth app if not token auth + application = Application.objects.get(client_type="public") + + # create short-lived oauth2 access token + token = AccessToken.objects.create( + user=request.user, + application=application, + token=random_token_generator(request, False), + scope="rw:issuer rw:profile", + expires=(timezone.now() + timezone.timedelta(0, 3600)), + ) + + iframe = IframeUrl.objects.create( + name="badge-create-or-edit", + params={ + "language": language, + "token": token.token, + "issuer": issuer.get_json() if issuer else None, + }, + created_by=request.user, + ) + + return JsonResponse({"url": iframe.url}) + + +@extend_schema(exclude=True) +class BadgeEditEmbed(RequestIframe): + permission_classes = [ + IsServerAdmin + | (AuthenticatedWithVerifiedIdentifier & IsStaff & BadgrOAuthTokenHasScope) + | BadgrOAuthTokenHasEntityScope + ] + valid_scopes = ["rw:issuer", "rw:issuer:*", "rw:profile"] + + def post(self, request, **kwargs): + if not request.user: + return HttpResponseForbidden() + + s = RequestIframeBadgeProcessSerializer(data=request.data) + + if not s.is_valid(): + return HttpResponseBadRequest(json.dumps(s.errors)) + language = s.validated_data.get("lang") + + issuers = Issuer.objects.filter(staff__id=request.user.id).distinct() + if issuers.count() == 0: + return HttpResponseForbidden() + + try: + badge_id = s.validated_data.get("badge") + badge = ( + BadgeClass.objects.filter( + entity_id=badge_id, issuer__staff__id=request.user.id + ) + .distinct() + .first() + ) + except KeyError: + badge = None + + if badge and not issuers.get(entity_id=badge.issuer.entity_id): + return HttpResponseForbidden() + + if request.auth: + application = request.auth.application + else: + # use public oauth app if not token auth + application = Application.objects.get(client_type="public") + + # create short-lived oauth2 access token + token = AccessToken.objects.create( + user=request.user, + application=application, + token=random_token_generator(request, False), + scope="rw:issuer rw:profile", + expires=(timezone.now() + timezone.timedelta(0, 3600)), + ) + + iframe = IframeUrl.objects.create( + name="badge-create-or-edit", + params={ + "language": language, + "token": token.token, + "badge": BadgeClassSerializerV1(badge).data if badge else None, + "issuer": badge.issuer.get_json() if badge else None, + "badgeSelection": False if badge else True, + }, + created_by=request.user, + ) + + return JsonResponse({"url": iframe.url}) diff --git a/apps/issuer/apps.py b/apps/issuer/apps.py new file mode 100644 index 000000000..a34dbedb5 --- /dev/null +++ b/apps/issuer/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class IssuerConfig(AppConfig): + name = "issuer" + + def ready(self): + from issuer.jsonld_loader import setup_jsonld_loader + + setup_jsonld_loader() diff --git a/apps/issuer/helpers.py b/apps/issuer/helpers.py index 81350721e..5ee43ed9d 100644 --- a/apps/issuer/helpers.py +++ b/apps/issuer/helpers.py @@ -2,7 +2,7 @@ import uuid -from collections import MutableMapping +from collections.abc import MutableMapping import openbadges from django.conf import settings @@ -14,16 +14,29 @@ from requests_cache.backends import BaseCache import logging -from issuer.models import Issuer, BadgeClass, BadgeInstance -from issuer.utils import OBI_VERSION_CONTEXT_IRIS -from mainsite.utils import first_node_match +from issuer.models import ( + ImportedBadgeAssertion, + ImportedBadgeAssertionExtension, + Issuer, + BadgeClass, + BadgeInstance, +) +from issuer.utils import ( + OBI_VERSION_CONTEXT_IRIS, + assertion_is_v3, + generate_sha256_hashstring, +) import json +import requests + + +logger = logging.getLogger("Badgr.Events") -logger = logging.getLogger(__name__) class DjangoCacheDict(MutableMapping): """TODO: Fix this class, its broken!""" + _keymap_cache_key = "DjangoCacheDict_keys" def __init__(self, namespace, id=None, timeout=None): @@ -33,13 +46,13 @@ def __init__(self, namespace, id=None, timeout=None): if id is None: id = uuid.uuid4().hexdigest() self._id = id - self.keymap_cache_key = self._keymap_cache_key+"_"+self._id + self.keymap_cache_key = self._keymap_cache_key + "_" + self._id def build_key(self, *args): return "{keymap_cache_key}{namespace}{key}".format( keymap_cache_key=self.keymap_cache_key, namespace=self.namespace, - key="".join(args) + key="".join(args), ).encode("utf-8") def timeout(self): @@ -85,16 +98,16 @@ def __iter__(self): yield cache.get(key) def __str__(self): - return '<{}>'.format(self.keymap_cache_key) + return "<{}>".format(self.keymap_cache_key) def clear(self): self._id = uuid.uuid4().hexdigest() - self.keymap_cache_key = self._keymap_cache_key+"_"+self._id + self.keymap_cache_key = self._keymap_cache_key + "_" + self._id class OpenBadgesContextCache(BaseCache): - OPEN_BADGES_CONTEXT_V2_URI = OBI_VERSION_CONTEXT_IRIS.get('2_0') - OPEN_BADGE_CONTEXT_CACHE_KEY = 'OPEN_BADGE_CONTEXT_CACHE_KEY' + OPEN_BADGES_CONTEXT_V2_URI = OBI_VERSION_CONTEXT_IRIS.get("2_0") + OPEN_BADGE_CONTEXT_CACHE_KEY = "OPEN_BADGE_CONTEXT_CACHE_KEY" FORTY_EIGHT_HOURS_IN_SECONDS = 60 * 60 * 24 * 2 def __init__(self, *args, **kwargs): @@ -112,57 +125,427 @@ def _get_cached_content(self): return cache.get(self.OPEN_BADGE_CONTEXT_CACHE_KEY, None) def _set_cached_content(self): - self.session = requests_cache.CachedSession(backend='memory', expire_after=300) - response = self.session.get(self.OPEN_BADGES_CONTEXT_V2_URI, headers={'Accept': 'application/ld+json, application/json'}) + self.session = requests_cache.CachedSession(backend="memory", expire_after=300) + response = self.session.get( + self.OPEN_BADGES_CONTEXT_V2_URI, + headers={"Accept": "application/ld+json, application/json"}, + ) if response.status_code == 200: cache.set( self.OPEN_BADGE_CONTEXT_CACHE_KEY, - { - 'keys_map': self.session.cache.keys_map.copy(), - 'response': self.session.cache.responses.copy() - }, - timeout=self.FORTY_EIGHT_HOURS_IN_SECONDS + {"response": self.session.cache.responses}, + timeout=self.FORTY_EIGHT_HOURS_IN_SECONDS, ) def _intialize_instance_attributes(self, cached): - self.keys_map = cached.get('keys_map', None) - self.responses = cached.get('response', None) + self.responses = cached.get("response", None) class DjangoCacheRequestsCacheBackend(BaseCache): - def __init__(self, namespace='requests-cache', **options): + def __init__(self, namespace="requests-cache", **options): super(DjangoCacheRequestsCacheBackend, self).__init__(**options) - self.responses = DjangoCacheDict(namespace, 'responses') - self.keys_map = DjangoCacheDict(namespace, 'urls') + self.responses = DjangoCacheDict(namespace, "responses") + self.keys_map = DjangoCacheDict(namespace, "urls") + + +def first_node_match(graph, criteria): + """Find the first node in a graph that matches all criteria.""" + for node in graph: + match = True + for k, v in criteria.items(): + if node.get(k) != v: + match = False + break + if match: + return node + return None + + +class ImportedBadgeHelper: + error_map = [ + ( + ["FETCH_HTTP_NODE"], + { + "name": "FETCH_HTTP_NODE", + "description": "Unable to reach URL", + }, + ), + ( + ["VERIFY_RECIPIENT_IDENTIFIER"], + { + "name": "VERIFY_RECIPIENT_IDENTIFIER", + "description": "The recipient does not match any of your verified emails", + }, + ), + ( + ["VERIFY_JWS", "VERIFY_KEY_OWNERSHIP"], + { + "name": "VERIFY_SIGNATURE", + "description": "Could not verify signature", + }, + ), + ( + ["VERIFY_SIGNED_ASSERTION_NOT_REVOKED"], + { + "name": "ASSERTION_REVOKED", + "description": "This assertion has been revoked", + }, + ), + ( + ["VERIFY_EMAIL_VERIFIED"], + { + "name": "EMAIL_NOT_VERIFIED", + "description": "The email of this assertion is not yet verified.", + }, + ), + ] + + @classmethod + def translate_errors(cls, badgecheck_messages): + for m in badgecheck_messages: + if m.get("messageLevel") == "ERROR": + for errors, backpack_error in cls.error_map: + if m.get("name") in errors: + yield backpack_error + yield m + + @classmethod + def badgecheck_options(cls): + from django.conf import settings + + return getattr( + settings, + "BADGECHECK_OPTIONS", + { + "include_original_json": True, + "use_cache": True, + }, + ) + + @classmethod + def get_or_create_imported_badge( + cls, url=None, imagefile=None, assertion=None, user=None + ): + """ + Import a badge directly to the ImportedBadgeAssertion model + without creating Issuer and BadgeClass objects. + """ + # Validate that only one input method is provided + query = (url, imagefile, assertion) + query = [v for v in query if v is not None] + if len(query) != 1: + raise ValidationError("Must provide only 1 of: url, imagefile or assertion") + query = query[0] + + # Prepare recipient profile for verification + if user: + emails = [d.email for d in user.email_items.all()] + badgecheck_recipient_profile = { + "email": emails + [v.email for v in user.cached_email_variants()], + "telephone": user.cached_verified_phone_numbers(), + "url": user.cached_verified_urls(), + } + else: + badgecheck_recipient_profile = None + + try: + if type(query) is dict: + try: + query = json.dumps(query) + except (TypeError, ValueError): + raise ValidationError("Could not parse dict to json") + + # use openbadges library to parse json from images + verifier_store = openbadges.load_store( + query, + recipient_profile=badgecheck_recipient_profile, + **cls.badgecheck_options(), + ) + query_json = verifier_store.get_state()["input"]["value"] + verifier_input = json.loads(query_json) + + # TODO: ob3 as JWT + # try: + # verifier_input = verifier_input['vc'] + # except: + # pass + + is_v3 = assertion_is_v3(verifier_input) + + if is_v3: + # skip openbadges library validator + response = cls.validate_v3(verifier_input, badgecheck_recipient_profile) + + else: + # ob2 validation through openbadges library + response = openbadges.verify( + query, + recipient_profile=badgecheck_recipient_profile, + **cls.badgecheck_options(), + ) + + except ValueError as e: + raise ValidationError([{"name": "INVALID_BADGE", "description": str(e)}]) + + report = response.get("report", {}) + is_valid = report.get("valid") + + if not is_valid: + if report.get("errorCount", 0) > 0: + errors = list(cls.translate_errors(report.get("messages", []))) + else: + errors = [ + { + "name": "UNABLE_TO_VERIFY", + "description": "Unable to verify the assertion", + } + ] + raise ValidationError(errors) + + if not is_v3: + graph = response.get("graph", []) + + assertion_data = first_node_match(graph, dict(type="Assertion")) + if not assertion_data: + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find an assertion", + } + ] + ) + + badgeclass_data = first_node_match( + graph, dict(id=assertion_data.get("badge", None)) + ) + if not badgeclass_data: + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find a badgeclass", + } + ] + ) + + issuer_data = first_node_match( + graph, dict(id=badgeclass_data.get("issuer", None)) + ) + if not issuer_data: + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find an issuer", + } + ] + ) + + original_json = response.get("input").get("original_json", {}) + + else: + assertion_data = response["assertion_obo"] + badgeclass_data = response["badgeclass_obo"] + issuer_data = response["issuer_obo"] + + original_json = response.get("input") + + recipient_profile = report.get("recipientProfile", {}) + if not recipient_profile and user: + recipient_type = "email" + recipient_identifier = user.primary_email + else: + recipient_type, recipient_identifier = list(recipient_profile.items())[0] + + existing_badge = ImportedBadgeAssertion.objects.filter( + user=user, + recipient_identifier=recipient_identifier, + issuer_url=issuer_data.get("url", ""), + badge_name=badgeclass_data.get("name", ""), + original_json__contains=assertion_data.get("id", ""), + ).first() + + existing_instance = BadgeInstance.objects.filter( + user=user, + recipient_identifier=recipient_identifier, + recipient_type=recipient_type, + revoked=False, + badgeclass__name=badgeclass_data.get("name", ""), + issuer__url=issuer_data.get("url", ""), + ).first() + + if existing_badge: + return existing_badge, False + + if existing_instance: + raise ValidationError( + [ + { + "name": "DUPLICATE_BADGE", + "description": "You already have this badge in your backpack.", + } + ] + ) + + badge_image_url = badgeclass_data.get("image", "") + + with transaction.atomic(): + imported_badge = ImportedBadgeAssertion( + user=user, + badge_name=badgeclass_data.get("name", ""), + badge_description=badgeclass_data.get("description", ""), + image=badgeclass_data.get("image", ""), + badge_image_url=badge_image_url, + issuer_name=issuer_data.get("name", ""), + issuer_url=issuer_data.get("url", ""), + issuer_email=issuer_data.get("email", ""), + issuer_image_url=issuer_data.get("image", ""), + issued_on=assertion_data.get( + "issuedOn", assertion_data.get("validFrom", None) + ), + expires_at=assertion_data.get( + "expires", assertion_data.get("validUntil", None) + ), + recipient_identifier=recipient_identifier, + recipient_type=recipient_type, + original_json=original_json + or { + "assertion": assertion_data, + "badgeclass": badgeclass_data, + "issuer": issuer_data, + }, + narrative=assertion_data.get("narrative", ""), + verification_url=assertion_data.get("verification", {}).get("url", ""), + ) + + imported_badge.save() + + for extension_key, extension_data in badgeclass_data.items(): + if extension_key.startswith("extensions:"): + extension = ImportedBadgeAssertionExtension( + importedBadge=imported_badge, + name=extension_key, + original_json=json.dumps(extension_data), + ) + extension.save() + + return imported_badge, True + + @classmethod + def validate_v3(cls, input, recipient_profile_in): + session = requests.Session() + + recipient_profile_out = {} + + # validate hashed email + try: + credential_subject = input.get("credentialSubject") + credential_identifiers = credential_subject.get("identifier") + for credential_identifier in credential_identifiers: + if credential_identifier.get("identityType") == "emailAddress": + identity_hash = credential_identifier.get("identityHash") + identity_salt = credential_identifier.get("salt") + for email in recipient_profile_in["email"]: + hashed_mail = generate_sha256_hashstring(email, identity_salt) + if hashed_mail == identity_hash: + recipient_profile_out["email"] = email + + except KeyError: + pass + + if not recipient_profile_out: + raise ValidationError( + [ + { + "name": "VERIFY_RECIPIENT_IDENTIFIER", + "description": "Recipients do not match", + } + ] + ) + + assertion_obo = input + issuer_id = input.get("issuer").get("id") + issuer_obo = {} + badgeclass_id = input.get("credentialSubject").get("achievement").get("id") + badgeclass_obo = {} + + # load json if ids are urls + try: + result = session.get( + issuer_id, headers={"Accept": "application/ld+json, application/json"} + ) + result_text = result.content.decode() + issuer_obo = json.loads(result_text) + except Exception: + pass + + try: + result = session.get( + badgeclass_id, + headers={"Accept": "application/ld+json, application/json"}, + ) + result_text = result.content.decode() + badgeclass_obo = json.loads(result_text) + except Exception: + pass + + return { + "report": { + "validationSubject": "", + "errorCount": 0, + "warningCount": 0, + "messages": [], + "recipientProfile": recipient_profile_out, + "valid": True, + }, + "graph": [], + "input": input, + "assertion_obo": assertion_obo, + "issuer_obo": issuer_obo, + "badgeclass_obo": badgeclass_obo, + } class BadgeCheckHelper(object): _cache_instance = None error_map = [ - (['FETCH_HTTP_NODE'], { - 'name': "FETCH_HTTP_NODE", - 'description': "Unable to reach URL", - }), - (['VERIFY_RECIPIENT_IDENTIFIER'], { - 'name': 'VERIFY_RECIPIENT_IDENTIFIER', - 'description': "The recipient does not match any of your verified emails", - }), - (['VERIFY_JWS', 'VERIFY_KEY_OWNERSHIP'], { - 'name': "VERIFY_SIGNATURE", - "description": "Could not verify signature", - }), - (['VERIFY_SIGNED_ASSERTION_NOT_REVOKED'], { - 'name': "ASSERTION_REVOKED", - "description": "This assertion has been revoked", - }), + ( + ["FETCH_HTTP_NODE"], + { + "name": "FETCH_HTTP_NODE", + "description": "Unable to reach URL", + }, + ), + ( + ["VERIFY_RECIPIENT_IDENTIFIER"], + { + "name": "VERIFY_RECIPIENT_IDENTIFIER", + "description": "The recipient does not match any of your verified emails", + }, + ), + ( + ["VERIFY_JWS", "VERIFY_KEY_OWNERSHIP"], + { + "name": "VERIFY_SIGNATURE", + "description": "Could not verify signature", + }, + ), + ( + ["VERIFY_SIGNED_ASSERTION_NOT_REVOKED"], + { + "name": "ASSERTION_REVOKED", + "description": "This assertion has been revoked", + }, + ), ] @classmethod def translate_errors(cls, badgecheck_messages): for m in badgecheck_messages: - if m.get('messageLevel') == 'ERROR': + if m.get("messageLevel") == "ERROR": for errors, backpack_error in cls.error_map: - if m.get('name') in errors: + if m.get("name") in errors: yield backpack_error yield m @@ -170,20 +553,27 @@ def translate_errors(cls, badgecheck_messages): def cache_instance(cls): if cls._cache_instance is None: # TODO: note this class is broken and does not work correctly! - cls._cache_instance = DjangoCacheRequestsCacheBackend(namespace='badgr_requests_cache') + cls._cache_instance = DjangoCacheRequestsCacheBackend( + namespace="badgr_requests_cache" + ) return cls._cache_instance @classmethod def badgecheck_options(cls): - return getattr(settings, 'BADGECHECK_OPTIONS', { - 'include_original_json': True, - 'use_cache': True, - # 'cache_backend': cls.cache_instance() # just use locmem cache for now - }) + return getattr( + settings, + "BADGECHECK_OPTIONS", + { + "include_original_json": True, + "use_cache": True, + # 'cache_backend': cls.cache_instance() # just use locmem cache for now + }, + ) @classmethod - def get_or_create_assertion(cls, url=None, imagefile=None, assertion=None, created_by=None): - + def get_or_create_assertion( + cls, url=None, imagefile=None, assertion=None, created_by=None + ): # distill 3 optional arguments into one query argument query = (url, imagefile, assertion) query = [v for v in query if v is not None] @@ -194,9 +584,9 @@ def get_or_create_assertion(cls, url=None, imagefile=None, assertion=None, creat if created_by: emails = [d.email for d in created_by.email_items.all()] badgecheck_recipient_profile = { - 'email': emails + [v.email for v in created_by.cached_email_variants()], - 'telephone': created_by.cached_verified_phone_numbers(), - 'url': created_by.cached_verified_urls() + "email": emails + [v.email for v in created_by.cached_email_variants()], + "telephone": created_by.cached_verified_phone_numbers(), + "url": created_by.cached_verified_urls(), } else: badgecheck_recipient_profile = None @@ -207,76 +597,129 @@ def get_or_create_assertion(cls, url=None, imagefile=None, assertion=None, creat query = json.dumps(query) except (TypeError, ValueError): raise ValidationError("Could not parse dict to json") - response = openbadges.verify(query, recipient_profile=badgecheck_recipient_profile, **cls.badgecheck_options()) + response = openbadges.verify( + query, + recipient_profile=badgecheck_recipient_profile, + **cls.badgecheck_options(), + ) except ValueError as e: - raise ValidationError([{'name': "INVALID_BADGE", 'description': str(e)}]) + raise ValidationError([{"name": "INVALID_BADGE", "description": str(e)}]) - report = response.get('report', {}) - is_valid = report.get('valid') + report = response.get("report", {}) + is_valid = report.get("valid") if not is_valid: - if report.get('errorCount', 0) > 0: - errors = list(cls.translate_errors(report.get('messages', []))) + if report.get("errorCount", 0) > 0: + errors = list(cls.translate_errors(report.get("messages", []))) else: - errors = [{'name': "UNABLE_TO_VERIFY", 'description': "Unable to verify the assertion"}] + errors = [ + { + "name": "UNABLE_TO_VERIFY", + "description": "Unable to verify the assertion", + } + ] raise ValidationError(errors) - graph = response.get('graph', []) + graph = response.get("graph", []) assertion_obo = first_node_match(graph, dict(type="Assertion")) if not assertion_obo: - raise ValidationError([{'name': "ASSERTION_NOT_FOUND", 'description': "Unable to find an assertion"}]) + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find an assertion", + } + ] + ) - badgeclass_obo = first_node_match(graph, dict(id=assertion_obo.get('badge', None))) + badgeclass_obo = first_node_match( + graph, dict(id=assertion_obo.get("badge", None)) + ) if not badgeclass_obo: - raise ValidationError([{'name': "ASSERTION_NOT_FOUND", 'description': "Unable to find a badgeclass"}]) + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find a badgeclass", + } + ] + ) - issuer_obo = first_node_match(graph, dict(id=badgeclass_obo.get('issuer', None))) + issuer_obo = first_node_match( + graph, dict(id=badgeclass_obo.get("issuer", None)) + ) if not issuer_obo: - raise ValidationError([{'name': "ASSERTION_NOT_FOUND", 'description': "Unable to find an issuer"}]) + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find an issuer", + } + ] + ) - original_json = response.get('input').get('original_json', {}) + original_json = response.get("input").get("original_json", {}) - recipient_profile = report.get('recipientProfile', {}) + recipient_profile = report.get("recipientProfile", {}) recipient_type, recipient_identifier = list(recipient_profile.items())[0] issuer_image = Issuer.objects.image_from_ob2(issuer_obo) badgeclass_image = BadgeClass.objects.image_from_ob2(badgeclass_obo) - badgeinstance_image = BadgeInstance.objects.image_from_ob2(badgeclass_image, assertion_obo) + badgeinstance_image = BadgeInstance.objects.image_from_ob2( + badgeclass_image, assertion_obo + ) def commit_new_badge(): with transaction.atomic(): - issuer, issuer_created = Issuer.objects.get_or_create_from_ob2(issuer_obo, original_json=original_json.get(issuer_obo.get('id')), image=issuer_image) - badgeclass, badgeclass_created = BadgeClass.objects.get_or_create_from_ob2(issuer, badgeclass_obo, original_json=original_json.get(badgeclass_obo.get('id')), image=badgeclass_image) - if badgeclass_created and ( - getattr(settings, 'BADGERANK_NOTIFY_ON_BADGECLASS_CREATE', True) or - getattr(settings, 'BADGERANK_NOTIFY_ON_FIRST_ASSERTION', True) - ): - from issuer.tasks import notify_badgerank_of_badgeclass - notify_badgerank_of_badgeclass.delay(badgeclass_pk=badgeclass.pk) + issuer, issuer_created = Issuer.objects.get_or_create_from_ob2( + issuer_obo, + original_json=original_json.get(issuer_obo.get("id")), + image=issuer_image, + ) + # set issuer as verified temporarily so the badge can be created + issuer.verified = True + badgeclass, badgeclass_created = ( + BadgeClass.objects.get_or_create_from_ob2( + issuer, + badgeclass_obo, + original_json=original_json.get(badgeclass_obo.get("id")), + image=badgeclass_image, + ) + ) return BadgeInstance.objects.get_or_create_from_ob2( - badgeclass, assertion_obo, - recipient_identifier=recipient_identifier, recipient_type=recipient_type, - original_json=original_json.get(assertion_obo.get('id')), image=badgeinstance_image + badgeclass, + assertion_obo, + recipient_identifier=recipient_identifier, + recipient_type=recipient_type, + original_json=original_json.get(assertion_obo.get("id")), + image=badgeinstance_image, ) + try: return commit_new_badge() except IntegrityError: - logger.error("Race condition caught when saving new assertion: {}".format(query)) + logger.error( + "Race condition caught when saving new assertion: {}".format(query) + ) return commit_new_badge() @classmethod def get_assertion_obo(cls, badge_instance): try: - response = openbadges.verify(badge_instance.source_url, recipient_profile=None, **cls.badgecheck_options()) - except ValueError as e: + response = openbadges.verify( + badge_instance.source_url, + recipient_profile=None, + **cls.badgecheck_options(), + ) + except ValueError: return None - report = response.get('report', {}) - is_valid = report.get('valid') + report = response.get("report", {}) + is_valid = report.get("valid") if is_valid: - graph = response.get('graph', []) + graph = response.get("graph", []) assertion_obo = first_node_match(graph, dict(type="Assertion")) if assertion_obo: diff --git a/apps/issuer/jsonld_loader.py b/apps/issuer/jsonld_loader.py new file mode 100644 index 000000000..10f6cf90d --- /dev/null +++ b/apps/issuer/jsonld_loader.py @@ -0,0 +1,52 @@ +from pyld.jsonld import set_document_loader, ContextResolver +from cachetools import LRUCache +import requests +import logging + +logger = logging.getLogger(__name__) + +_doc_cache = LRUCache(maxsize=100) +_resolved_context_cache = LRUCache(maxsize=1000) + + +# TODO: remove the logs here after some testing +def cached_document_loader(url, options=None): + if url in _doc_cache: + logger.debug(f"Cache hit for: {url}") + return _doc_cache[url] + logger.debug(f"Cache miss for: {url}") + + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + doc = {"contextUrl": None, "documentUrl": url, "document": response.json()} + _doc_cache[url] = doc + return doc + except Exception as e: + logger.warning(f"Failed to load context from {url}: {e}") + raise + + +_context_resolver = ContextResolver(_resolved_context_cache, cached_document_loader) + + +def setup_jsonld_loader(): + set_document_loader(cached_document_loader) + _precache_common_contexts() + + +def get_context_resolver(): + return _context_resolver + + +def _precache_common_contexts(): + urls = [ + "https://www.w3.org/ns/credentials/v2", + "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json", + "https://purl.imsglobal.org/spec/ob/v3p0/extensions.json", + ] + for url in urls: + try: + cached_document_loader(url) + except Exception: + logger.warning(f"Could not pre-cache: {url}") diff --git a/apps/issuer/management/__init__.py b/apps/issuer/management/__init__.py index d4896b838..bca5f6749 100644 --- a/apps/issuer/management/__init__.py +++ b/apps/issuer/management/__init__.py @@ -1,4 +1 @@ # encoding: utf-8 - - - diff --git a/apps/issuer/management/commands/__init__.py b/apps/issuer/management/commands/__init__.py index d4896b838..bca5f6749 100644 --- a/apps/issuer/management/commands/__init__.py +++ b/apps/issuer/management/commands/__init__.py @@ -1,4 +1 @@ # encoding: utf-8 - - - diff --git a/apps/issuer/management/commands/clean_badge_criteria.py b/apps/issuer/management/commands/clean_badge_criteria.py new file mode 100644 index 000000000..d226d7d5a --- /dev/null +++ b/apps/issuer/management/commands/clean_badge_criteria.py @@ -0,0 +1,44 @@ +import json +from django.core.management import BaseCommand +from issuer.models import BadgeClass +from django.db import transaction + + +class Command(BaseCommand): + help = "Remove the content from the criteria_text field" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", action="store_true", help="Simulate the changes" + ) + parser.add_argument( + "--output-file", + type=str, + default="criteria_changes.json", + help="File to write the changes to during dry run", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + output_file = options["output_file"] + changes_log = [] + + with transaction.atomic(): + badgeclasses = BadgeClass.objects.all() + + for badgeclass in badgeclasses: + if badgeclass.criteria_text is not None: + badgeclass.criteria_text = None + badgeclass.save() + + if dry_run and changes_log: + try: + with open(output_file, "w") as f: + json.dump(changes_log, f, indent=4) + self.stdout.write( + self.style.SUCCESS(f"Successfully wrote changes to {output_file}") + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"Failed to write to {output_file}: {str(e)}") + ) diff --git a/apps/issuer/management/commands/count_badges.py b/apps/issuer/management/commands/count_badges.py new file mode 100644 index 000000000..dd84cbc4c --- /dev/null +++ b/apps/issuer/management/commands/count_badges.py @@ -0,0 +1,87 @@ +from django.core.management.base import BaseCommand +from issuer.models import BadgeClass, BadgeInstance +from json import loads + + +class Command(BaseCommand): + help = "Count badges and assertions by category" + + def handle(self, *args, **options): + badgeclasses = BadgeClass.objects.all() + badgeinstances = BadgeInstance.objects.all() + + participation_count = 0 + competency_count = 0 + md_count = 0 + uncategorized_count = 0 + + for badgeclass in badgeclasses: + extensions = badgeclass.get_extensions_manager() + category_extension = extensions.filter( + name="extensions:CategoryExtension" + ).first() + + if category_extension is not None: + try: + original_json = category_extension.original_json + category = loads(original_json)["Category"] + + if category == "participation": + participation_count += 1 + elif category == "competency": + competency_count += 1 + elif category == "learningpath": + md_count += 1 + else: + uncategorized_count += 1 + + except (KeyError, ValueError): + uncategorized_count += 1 + else: + uncategorized_count += 1 + print(f"No category extension found for badge: {badgeclass.name}") + + self.stdout.write(self.style.SUCCESS("\n" + "=" * 50)) + self.stdout.write(self.style.SUCCESS("BADGE COUNT SUMMARY")) + self.stdout.write(self.style.SUCCESS("=" * 50)) + self.stdout.write(f"Participation badges: {participation_count}") + self.stdout.write(f"Competency badges: {competency_count}") + self.stdout.write(f"MD badges: {md_count}") + self.stdout.write(f"Uncategorized badges: {uncategorized_count}") + + participation_instances = 0 + competency_instances = 0 + md_instances = 0 + uncategorized_instances = 0 + + for instance in badgeinstances: + badgeclass = instance.badgeclass + extensions = badgeclass.get_extensions_manager() + category_extension = extensions.filter( + name="extensions:CategoryExtension" + ).first() + + if category_extension is not None: + try: + original_json = category_extension.original_json + category = loads(original_json)["Category"] + + if category == "participation": + participation_instances += 1 + elif category == "competency": + competency_instances += 1 + elif category == "learningpath": + md_instances += 1 + else: + uncategorized_instances += 1 + except (KeyError, ValueError): + uncategorized_instances += 1 + else: + uncategorized_instances += 1 + + self.stdout.write(self.style.SUCCESS("\nBADGE INSTANCE COUNT SUMMARY")) + self.stdout.write(self.style.SUCCESS("=" * 50)) + self.stdout.write(f"Participation instances: {participation_instances}") + self.stdout.write(f"Competency instances: {competency_instances}") + self.stdout.write(f"MD instances: {md_instances}") + self.stdout.write(f"Uncategorized instances: {uncategorized_instances}") diff --git a/apps/issuer/management/commands/fix_badgeclass_images.py b/apps/issuer/management/commands/fix_badgeclass_images.py index 26d2f6fbf..05486cf8e 100644 --- a/apps/issuer/management/commands/fix_badgeclass_images.py +++ b/apps/issuer/management/commands/fix_badgeclass_images.py @@ -14,57 +14,88 @@ class Command(BaseCommand): def handle(self, *args, **options): - # save the placeholder image to storage if needed store = DefaultStorage() placeholder_storage_name = "placeholder/badge-failed.svg" if not store.exists(placeholder_storage_name): - with open(os.path.join(TOP_DIR, 'apps', 'mainsite', 'static', 'badgr-ui', 'images', 'badge-failed.svg'), 'rb') as fh: + with open( + os.path.join( + TOP_DIR, + "apps", + "mainsite", + "static", + "badgr-ui", + "images", + "badge-failed.svg", + ), + "rb", + ) as fh: store.save(placeholder_storage_name, fh) report = { - 'total': 0, - 'saved': 0, - 'placeholders_saved': 0, - 'status_codes': {}, - 'ioerrors': [], - 'no_image_url': [], - 'json_error': [] + "total": 0, + "saved": 0, + "placeholders_saved": 0, + "status_codes": {}, + "ioerrors": [], + "no_image_url": [], + "json_error": [], } - badgeclasses_missing_images = BadgeClass.objects.filter(image='') - report['total'] = len(badgeclasses_missing_images) - self.stdout.write("Processing {} badgeclasses missing images...".format(report['total'])) + badgeclasses_missing_images = BadgeClass.objects.filter(image="") + report["total"] = len(badgeclasses_missing_images) + self.stdout.write( + "Processing {} badgeclasses missing images...".format(report["total"]) + ) for badgeclass in badgeclasses_missing_images: try: original_json = json.loads(badgeclass.original_json) except ValueError: - report['json_error'].append(badgeclass.pk) + report["json_error"].append(badgeclass.pk) else: - remote_image_url = original_json.get('image', None) + remote_image_url = original_json.get("image", None) if remote_image_url: try: - status_code, image = fetch_remote_file_to_storage(remote_image_url, upload_to=badgeclass.image.field.upload_to, - allowed_mime_types=['image/png', 'image/svg+xml']) + status_code, image = fetch_remote_file_to_storage( + remote_image_url, + upload_to=badgeclass.image.field.upload_to, + allowed_mime_types=["image/png", "image/svg+xml"], + ) except IOError as e: - self.stdout.write("IOError fetching '{}': {}".format(remote_image_url, str(e))) - report['ioerrors'].append((remote_image_url, str(e))) + self.stdout.write( + "IOError fetching '{}': {}".format(remote_image_url, str(e)) + ) + report["ioerrors"].append((remote_image_url, str(e))) else: - report['status_codes'][status_code] = report['status_codes'].get(status_code, []) + [remote_image_url] + report["status_codes"][status_code] = report[ + "status_codes" + ].get(status_code, []) + [remote_image_url] if status_code == 200: badgeclass.image = image badgeclass.save() - report['saved'] += 1 - self.stdout.write("Saved missing image for badgeclass(pk={}) from '{}'".format(badgeclass.pk, remote_image_url)) + report["saved"] += 1 + self.stdout.write( + "Saved missing image for badgeclass(pk={}) from '{}'".format( + badgeclass.pk, remote_image_url + ) + ) continue # shortcircuit failure handling at end of loop else: - self.stdout.write("Http error fetching '{}': {}".format(remote_image_url, status_code)) + self.stdout.write( + "Http error fetching '{}': {}".format( + remote_image_url, status_code + ) + ) else: - report['no_image_url'].append(badgeclass.pk) - self.stdout.write("Unable to determine an image url for badgeclass(pk={})".format(badgeclass.pk)) + report["no_image_url"].append(badgeclass.pk) + self.stdout.write( + "Unable to determine an image url for badgeclass(pk={})".format( + badgeclass.pk + ) + ) # all errors should fall through to here if not badgeclass.image: - report['placeholders_saved'] += 1 + report["placeholders_saved"] += 1 badgeclass.image = placeholder_storage_name badgeclass.save() self.stdout.write(json.dumps(report, indent=2)) diff --git a/apps/issuer/management/commands/geocode_issuers.py b/apps/issuer/management/commands/geocode_issuers.py new file mode 100644 index 000000000..7bf84ec82 --- /dev/null +++ b/apps/issuer/management/commands/geocode_issuers.py @@ -0,0 +1,98 @@ +from django.core.management.base import BaseCommand +from django.db import models +from geopy.geocoders import Nominatim +import time +from issuer.models import Issuer + + +class Command(BaseCommand): + help = "Geocode all issuers that dont already have coordinates" + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Force geocoding for all issuers, even those that already have coordinates", + ) + parser.add_argument( + "--delay", + type=float, + default=1.0, + help="Delay in seconds between geocoding requests (default and minimum: 1.0)", + ) + + def handle(self, *args, **options): + force = options["force"] + delay = options["delay"] + + if force: + issuers = Issuer.objects.all() + self.stdout.write(f"Processing all {issuers.count()} issuers") + else: + issuers = Issuer.objects.filter( + models.Q(lat__isnull=True) | models.Q(lon__isnull=True) + ) + self.stdout.write( + f"Processing {issuers.count()} issuers without coordinates" + ) + + if not issuers.exists(): + self.stdout.write(self.style.SUCCESS("No issuers need geocoding!")) + return + + nom = Nominatim(user_agent="OpenEducationalBadges") + + successful = 0 + failed = 0 + + for issuer in issuers: + addr_string = ( + (issuer.street if issuer.street is not None else "") + + " " + + (str(issuer.streetnumber) if issuer.streetnumber is not None else "") + + " " + + (str(issuer.zip) if issuer.zip is not None else "") + + " " + + (str(issuer.city) if issuer.city is not None else "") + + " Deutschland" + ).strip() + + if not addr_string: + self.stdout.write( + self.style.WARNING(f"Issuer {issuer.pk}: No address information") + ) + failed += 1 + continue + + self.stdout.write(f"Geocoding issuer {issuer.pk}: {addr_string}") + + try: + # Add delay to respect rate limits + if not delay or delay < 1.0: + self.style.WARNING(f"A delay of {delay}, which is less than 1s violates rate limits; setting to 1s") + delay = 1.0 + time.sleep(delay) + + geoloc = nom.geocode(addr_string) + + if geoloc: + issuer.lat = geoloc.latitude + issuer.lon = geoloc.longitude + # Use update_fields to avoid triggering the save() method's geocoding logic + issuer.save(update_fields=["lat", "lon"]) + + self.stdout.write( + self.style.SUCCESS( + f"✓ Success: {geoloc.latitude}, {geoloc.longitude}" + ) + ) + successful += 1 + else: + self.stdout.write(self.style.WARNING("✗ No geocoding result found")) + failed += 1 + + except Exception as e: + self.stdout.write(self.style.ERROR(f"✗ Error: {e}")) + failed += 1 + + self.stdout.write(f"\nCompleted! Successful: {successful}, Failed: {failed}") diff --git a/apps/issuer/management/commands/migrate_learningpath_category.py b/apps/issuer/management/commands/migrate_learningpath_category.py new file mode 100644 index 000000000..c3928e595 --- /dev/null +++ b/apps/issuer/management/commands/migrate_learningpath_category.py @@ -0,0 +1,72 @@ +import json +from django.core.management import BaseCommand +from issuer.models import BadgeClass +from json import loads +from django.db import transaction + + +class Command(BaseCommand): + help = "Update the category extensions of existing learningpath participation badges to the new learningpath category" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", action="store_true", help="Simulate the changes" + ) + parser.add_argument( + "--output-file", + type=str, + default="lp_category.json", + help="File to write the changes to during dry run", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + output_file = options["output_file"] + changes_log = [] + + with transaction.atomic(): + badgeclasses = BadgeClass.objects.filter( + learningpath__isnull=False + ).distinct() + + for badgeclass in badgeclasses: + extensions = badgeclass.get_extensions_manager() + category_extension = extensions.filter( + name="extensions:CategoryExtension" + ).first() + + if category_extension is not None: + original_json = category_extension.original_json + category_dict = loads(original_json) + + category = category_dict["Category"] + if category == "participation": + category_dict["Category"] = "learningpath" + + updated_category_json = json.dumps(category_dict, indent=4) + + if dry_run: + change_entry = { + "badgeclass_name": badgeclass.name, + "before": json.loads(original_json), + "after": json.loads(updated_category_json), + } + changes_log.append(change_entry) + self.stdout.write( + f"DRY-RUN: Logged changes for badgeclass {badgeclass.name}" + ) + else: + category_extension.original_json = updated_category_json + category_extension.save() + + if dry_run and changes_log: + try: + with open(output_file, "w") as f: + json.dump(changes_log, f, indent=4) + self.stdout.write( + self.style.SUCCESS(f"Successfully wrote changes to {output_file}") + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"Failed to write to {output_file}: {str(e)}") + ) diff --git a/apps/issuer/management/commands/populate_badgeinstance_ob_json_2_0.py b/apps/issuer/management/commands/populate_badgeinstance_ob_json_2_0.py new file mode 100644 index 000000000..663c596d2 --- /dev/null +++ b/apps/issuer/management/commands/populate_badgeinstance_ob_json_2_0.py @@ -0,0 +1,15 @@ +# encoding: utf-8 +import json +from django.core.management import BaseCommand + +from issuer.models import BadgeInstance + + +class Command(BaseCommand): + def handle(self, *args, **options): + for bi in BadgeInstance.objects.all(): + if not bi.ob_json_2_0: + bi.ob_json_2_0 = json.dumps(bi.get_json_2_0()) + bi.save(update_fields=["ob_json_2_0"]) + + self.stdout.write("Finished populating BadgeInstance.ob_json_2_0") diff --git a/apps/issuer/management/commands/populate_image_hashes.py b/apps/issuer/management/commands/populate_image_hashes.py index 30b623ce1..8f75fc02f 100644 --- a/apps/issuer/management/commands/populate_image_hashes.py +++ b/apps/issuer/management/commands/populate_image_hashes.py @@ -7,17 +7,17 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - '--limit', + "--limit", type=int, - help='Number of model instances to process in a batch', - default=1000 + help="Number of model instances to process in a batch", + default=1000, ) def handle(self, *args, **options): model = BadgeClass processed_count = 0 - limit = options['limit'] - queryset = model.objects.filter(image_hash='').exclude(image='') + limit = options["limit"] + queryset = model.objects.filter(image_hash="").exclude(image="") processing = True while processing: @@ -26,14 +26,20 @@ def handle(self, *args, **options): if active_set.exists(): for instance in active_set: instance.save() - self.stdout.write("Calculated initial image_hash for {} #{}: {}".format( - instance.__class__.__name__, instance.pk, instance.image_hash) + self.stdout.write( + "Calculated initial image_hash for {} #{}: {}".format( + instance.__class__.__name__, + instance.pk, + instance.image_hash, + ) ) processed_count += 1 else: processing = False - self.stdout.write("Finished processing populate_image_hashes for model {}. {} records updated.".format( - model.__name__, processed_count) + self.stdout.write( + "Finished processing populate_image_hashes for model {}. {} records updated.".format( + model.__name__, processed_count + ) ) diff --git a/apps/issuer/management/commands/recalculate_private_keys.py b/apps/issuer/management/commands/recalculate_private_keys.py new file mode 100644 index 000000000..f46d4abce --- /dev/null +++ b/apps/issuer/management/commands/recalculate_private_keys.py @@ -0,0 +1,111 @@ +from django.core.management.base import BaseCommand +from django.db import transaction +from issuer.models import Issuer, BadgeInstance +from issuer.utils import generate_private_key_pem +from collections import Counter + + +class Command(BaseCommand): + help = "Regenerate private keys for issuers with duplicate keys and rebake affected assertions" + + def handle(self, *args, **options): + self.stdout.write("Analyzing private keys for duplicates...") + + all_keys = list(Issuer.objects.values_list("private_key", flat=True)) + key_counts = Counter(all_keys) + duplicate_keys = [key for key, count in key_counts.items() if count > 1] + + if not duplicate_keys: + self.stdout.write(self.style.SUCCESS("No duplicate private keys found.")) + return + + affected_issuers = Issuer.objects.filter(private_key__in=duplicate_keys) + affected_issuer_ids = list(affected_issuers.values_list("id", flat=True)) + + affected_assertions = BadgeInstance.objects.filter( + issuer_id__in=affected_issuer_ids, ob_json_3_0__isnull=False + ) + + self.stdout.write(f"Found {len(duplicate_keys)} duplicate private keys") + self.stdout.write(f"Affecting {affected_issuers.count()} issuers") + self.stdout.write(f"Need to rebake {affected_assertions.count()} assertions") + + self._regenerate_keys_and_rebake(affected_issuers, affected_assertions) + + def _regenerate_keys_and_rebake(self, affected_issuers, affected_assertions): + with transaction.atomic(): + self.stdout.write("Regenerating private keys...") + + for issuer in affected_issuers: + issuer.private_key = generate_private_key_pem() + issuer.save() + + self.stdout.write(f"\nRebaking {affected_assertions.count()} assertions...") + + proof_unchanged_count = 0 + proof_changed_count = 0 + failed_assertions = [] + no_proof_count = 0 + + for i, assertion in enumerate(affected_assertions, 1): + if i % 10 == 0: + self.stdout.write(f" Progress: {i}/{affected_assertions.count()}") + + try: + original_json = assertion.get_json(obi_version="3_0") + original_proof_value = None + + if "proof" in original_json and len(original_json["proof"]) > 0: + original_proof_value = original_json["proof"][0].get( + "proofValue" + ) + + assertion.rebake() + + new_json = assertion.get_json( + obi_version="3_0", force_recreate=True + ) + new_proof_value = None + + if "proof" in new_json and len(new_json["proof"]) > 0: + new_proof_value = new_json["proof"][0].get("proofValue") + + if original_proof_value and new_proof_value: + if original_proof_value == new_proof_value: + proof_unchanged_count += 1 + self.stdout.write( + self.style.WARNING( + f"ProofValue unchanged for assertion {assertion.id}" + ) + ) + else: + proof_changed_count += 1 + else: + no_proof_count += 1 + + except Exception as e: + failed_assertions.append(assertion.entity_id) + self.stdout.write( + self.style.ERROR( + f"Failed to rebake assertion {assertion.entity_id}: {str(e)}" + ) + ) + + self.stdout.write(f"\n{self.style.SUCCESS('OPERATION COMPLETE')}") + self.stdout.write(f"Assertions with changed proofs: {proof_changed_count}") + self.stdout.write( + f"Assertions with unchanged proofs: {proof_unchanged_count}" + ) + self.stdout.write(f"Assertions without proof comparison: {no_proof_count}") + + if failed_assertions: + self.stdout.write( + self.style.ERROR( + f"Failed assertions: {len(failed_assertions)} " + f"(IDs: {', '.join(map(str, failed_assertions))})" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS("All assertions rebaked successfully!") + ) diff --git a/apps/issuer/management/commands/update_badgeinstance_user.py b/apps/issuer/management/commands/update_badgeinstance_user.py index e2aeed24f..da01f565d 100644 --- a/apps/issuer/management/commands/update_badgeinstance_user.py +++ b/apps/issuer/management/commands/update_badgeinstance_user.py @@ -9,12 +9,11 @@ class Command(BaseCommand): def handle(self, *args, **options): - self.stdout.write("Updating BadgeInstaces...") self.stdout.write("1. Setting users from verified CachedEmailAddress") for verified_id in CachedEmailAddress.objects.filter(verified=True): self.update(verified_id.user, verified_id.email) - + self.stdout.write("2. Setting users from verified UserRecipientIdentifier") for verified_id in UserRecipientIdentifier.objects.filter(verified=True): self.update(verified_id.user, verified_id.identifier) @@ -25,8 +24,12 @@ def handle(self, *args, **options): self.stdout.write("3. Triggering cache updates") while True: - badges = BadgeInstance.objects.filter(user__isnull=False)[page:page+chunk_size] - self.stdout.write("Processing badges %d through %d" % (page+1, page+len(badges))) + badges = BadgeInstance.objects.filter(user__isnull=False)[ + page : page + chunk_size + ] + self.stdout.write( + "Processing badges %d through %d" % (page + 1, page + len(badges)) + ) for b in badges: b.publish() if len(badges) < chunk_size: @@ -36,4 +39,4 @@ def handle(self, *args, **options): self.stdout.write("All done.") def update(self, user, identifier): - BadgeInstance.objects.filter(recipient_identifier=identifier).update(user=user) \ No newline at end of file + BadgeInstance.objects.filter(recipient_identifier=identifier).update(user=user) diff --git a/apps/issuer/management/commands/update_extensions.py b/apps/issuer/management/commands/update_extensions.py new file mode 100644 index 000000000..6c095cae2 --- /dev/null +++ b/apps/issuer/management/commands/update_extensions.py @@ -0,0 +1,98 @@ +import json +from django.core.management import BaseCommand +from issuer.models import BadgeClass +from json import loads +from django.db import transaction +from urllib.parse import urlparse, parse_qs + + +class Command(BaseCommand): + help = "Update the competency extensions of a badgeclass to our new format" + escoBaseURl: str = "http://data.europa.eu" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", action="store_true", help="Simulate the changes" + ) + parser.add_argument( + "--output-file", + type=str, + default="competency_changes.json", + help="File to write the changes to during dry run", + ) + + def calculate_FrameworkIdentifier(self, escoId: str) -> str: + if escoId.startswith(self.escoBaseURl): + return escoId + elif escoId.startswith("https://esco.ec.europa.eu"): + parsed_url = urlparse(escoId) + query_params = parse_qs(parsed_url.query) + uri = query_params.get("uri", [None])[0] + return uri + elif escoId.startswith("esco/"): + return self.escoBaseURl + "/" + escoId + elif escoId.startswith("/skill/"): + return self.escoBaseURl + "/esco" + escoId + else: + return self.escoBaseURl + escoId + + def handle(self, *args, **options): + dry_run = options["dry_run"] + output_file = options["output_file"] + changes_log = [] + + with transaction.atomic(): + badgeclasses = BadgeClass.objects.all() + + for badgeclass in badgeclasses: + extensions = badgeclass.get_extensions_manager() + competency_extension = extensions.filter( + name="extensions:CompetencyExtension" + ).first() + + if competency_extension is not None: + original_json = competency_extension.original_json + competency_dict = loads(original_json) + + for item in competency_dict: + escoID = item.get("escoID") + if escoID is not None and escoID != "": + item["framework"] = "esco" + item["source"] = "ai" + item["framework_identifier"] = ( + self.calculate_FrameworkIdentifier(escoID) + ) + del item["escoID"] + elif escoID == "": + item["framework"] = "" + item["source"] = "manual" + item["framework_identifier"] = "" + del item["escoID"] + + updated_competency_json = json.dumps(competency_dict, indent=4) + + if dry_run: + change_entry = { + "badgeclass_name": badgeclass.name, + "before": json.loads(original_json), + "after": json.loads(updated_competency_json), + } + changes_log.append(change_entry) + self.stdout.write( + f"DRY-RUN: Logged changes for badgeclass {badgeclass.name}" + ) + else: + competency_extension.original_json = updated_competency_json + competency_extension.save() + + if dry_run and changes_log: + try: + with open(output_file, "w") as f: + json.dump(changes_log, f, indent=4) + self.stdout.write( + self.style.SUCCESS(f"Successfully wrote changes to {output_file}") + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"Failed to write to {output_file}: {str(e)}") + ) diff --git a/apps/issuer/management/commands/verify_get_json.py b/apps/issuer/management/commands/verify_get_json.py index fbad2eea5..48870e21b 100644 --- a/apps/issuer/management/commands/verify_get_json.py +++ b/apps/issuer/management/commands/verify_get_json.py @@ -9,13 +9,12 @@ def sorted_dict(d): - return OrderedDict((k,d[k]) for k in sorted(d.keys())) + return OrderedDict((k, d[k]) for k in sorted(d.keys())) class Command(BaseCommand): - def handle(self, *args, **options): - self.verbosity = int(options.get('verbosity', 1)) + self.verbosity = int(options.get("verbosity", 1)) self.check_jsons(Issuer) self.check_jsons(BadgeClass) self.check_jsons(BadgeInstance) @@ -26,14 +25,20 @@ def check_jsons(self, model_cls): for obj in model_cls.objects.all(): new_json = obj.get_json() orig_json = obj.old_json - if cmp(new_json, orig_json) != 0: + if new_json != orig_json: if self.verbosity > 1: - self.stdout.write(" Jsons don't match! pk={}\n old: {}\n new: {}\n\n".format(obj.pk, sorted_dict(orig_json), sorted_dict(new_json))) + self.stdout.write( + " Jsons don't match! pk={}\n old: {}\n new: {}\n\n".format( + obj.pk, sorted_dict(orig_json), sorted_dict(new_json) + ) + ) mismatch += 1 else: correct += 1 if self.verbosity > 0: - self.stdout.write("Found {} {}s. {} correct. {} mismatch".format(mismatch+correct, model_cls.__name__, correct, mismatch)) - - + self.stdout.write( + "Found {} {}s. {} correct. {} mismatch".format( + mismatch + correct, model_cls.__name__, correct, mismatch + ) + ) diff --git a/apps/issuer/management/migrate_criteria.py b/apps/issuer/management/migrate_criteria.py new file mode 100644 index 000000000..b8218079e --- /dev/null +++ b/apps/issuer/management/migrate_criteria.py @@ -0,0 +1,14 @@ +from issuer.models import BadgeClass +from django.core.management import BaseCommand + + +class Command(BaseCommand): + help = "Migrate the data from criteria_url and criteria_text to the new criteria json field" + + def handle(self, *args, **options): + for badge in BadgeClass.objects.all(): + if badge.criteria_url: + badge.criteria = {"id": badge.criteria_url, "criteria": []} + elif badge.criteria_text: + badge.criteria = {"narrative": badge.criteria_text, "criteria": []} + badge.save() diff --git a/apps/issuer/managers.py b/apps/issuer/managers.py index 938ef7377..2806cba26 100644 --- a/apps/issuer/managers.py +++ b/apps/issuer/managers.py @@ -1,6 +1,7 @@ # encoding: utf-8 +from datetime import timedelta import json import os import six @@ -11,6 +12,7 @@ from django.core.files.storage import DefaultStorage from django.db import models, transaction from django.urls import resolve, Resolver404 +from django.utils import timezone from issuer.utils import sanitize_id from mainsite.utils import fetch_remote_file_to_storage, list_of, OriginSetting @@ -21,7 +23,7 @@ def resolve_source_url_referencing_local_object(source_url): try: match = resolve(urllib.parse.urlparse(source_url).path) return match - except Resolver404 as e: + except Resolver404: pass @@ -30,44 +32,48 @@ def get_local_object(self, source_url): match = resolve_source_url_referencing_local_object(source_url) if match: try: - return self.get(entity_id=match.kwargs.get('entity_id')) + return self.get(entity_id=match.kwargs.get("entity_id")) except self.model.DoesNotExist: return None class IssuerManager(BaseOpenBadgeObjectManager): ALLOWED_MINE_TYPES = [ - 'image/png', - 'image/gif', - 'image/jpeg', - 'image/svg+xml', + "image/png", + "image/gif", + "image/jpeg", + "image/svg+xml", ] def update_from_ob2(self, issuer_obo, original_json=None): image = self.image_from_ob2(issuer_obo) return self.update_or_create( - source_url=issuer_obo.get('id'), + source_url=issuer_obo.get("id"), defaults=dict( - name=issuer_obo.get('name'), - description=issuer_obo.get('description', None), - url=issuer_obo.get('url', None), - email=issuer_obo.get('email', None), + name=issuer_obo.get("name"), + description=issuer_obo.get("description", None), + url=issuer_obo.get("url", None), + email=issuer_obo.get("email", None), image=image, - original_json=original_json - ) + original_json=original_json, + ), ) def image_from_ob2(self, issuer_obo): - image_url = issuer_obo.get('image', None) + image_url = issuer_obo.get("image", None) image = None if image_url: if isinstance(image_url, dict): - image_url = image_url.get('id') - image = _fetch_image_and_get_file(image_url, self.ALLOWED_MINE_TYPES, upload_to='remote/issuer') + image_url = image_url.get("id") + image = _fetch_image_and_get_file( + image_url, self.ALLOWED_MINE_TYPES, upload_to="remote/issuer" + ) return image - def get_or_create_from_ob2(self, issuer_obo, source=None, original_json=None, image=None): - source_url = issuer_obo.get('id') + def get_or_create_from_ob2( + self, issuer_obo, source=None, original_json=None, image=None + ): + source_url = issuer_obo.get("id") local_object = self.get_local_object(source_url) if local_object: return local_object, False @@ -75,95 +81,95 @@ def get_or_create_from_ob2(self, issuer_obo, source=None, original_json=None, im return self.get_or_create( source_url=source_url, defaults=dict( - source=source if source is not None else 'local', - name=issuer_obo.get('name'), - description=issuer_obo.get('description', None), - url=issuer_obo.get('url', None), - email=issuer_obo.get('email', None), + source=source if source is not None else "local", + name=issuer_obo.get("name"), + description=issuer_obo.get("description", None), + url=issuer_obo.get("url", None), + email=issuer_obo.get("email", None), image=image, - original_json=original_json - ) + original_json=original_json, + ), ) class BadgeClassManager(BaseOpenBadgeObjectManager): ALLOWED_MINE_TYPES = [ - 'image/png', - 'image/svg+xml', + "image/png", + "image/svg+xml", ] @transaction.atomic def create(self, **kwargs): obj = self.model(**kwargs) obj.save() - - if getattr(settings, 'BADGERANK_NOTIFY_ON_BADGECLASS_CREATE', True): - from issuer.tasks import notify_badgerank_of_badgeclass - notify_badgerank_of_badgeclass.delay(badgeclass_pk=obj.pk) - return obj def update_from_ob2(self, issuer, badgeclass_obo, original_json=None): criteria_url = None criteria_text = None - criteria = badgeclass_obo.get('criteria', None) + criteria = badgeclass_obo.get("criteria", None) if isinstance(criteria, str): criteria_url = criteria - elif criteria.get('type', 'Criteria') == 'Criteria': - criteria_url = criteria.get('id', None) - criteria_text = criteria.get('narrative', None) + elif criteria.get("type", "Criteria") == "Criteria": + criteria_url = criteria.get("id", None) + criteria_text = criteria.get("narrative", None) image = self.image_from_ob2(badgeclass_obo) return self.update_or_create( - source_url=badgeclass_obo.get('id'), + source_url=badgeclass_obo.get("id"), defaults=dict( issuer=issuer, - name=badgeclass_obo.get('name'), - description=badgeclass_obo.get('description', None), + name=badgeclass_obo.get("name"), + description=badgeclass_obo.get("description", None), image=image, criteria_url=criteria_url, criteria_text=criteria_text, - original_json=original_json - ) + original_json=original_json, + ), ) def image_from_ob2(self, badgeclass_obo): - image_url = badgeclass_obo.get('image') + image_url = badgeclass_obo.get("image") if isinstance(image_url, dict): - image_url = image_url.get('id') + image_url = image_url.get("id") - return _fetch_image_and_get_file(image_url, self.ALLOWED_MINE_TYPES, upload_to='remote/badgeclass') + return _fetch_image_and_get_file( + image_url, self.ALLOWED_MINE_TYPES, upload_to="remote/badgeclass" + ) - def get_or_create_from_ob2(self, issuer, badgeclass_obo, source=None, original_json=None, image=None): - source_url = badgeclass_obo.get('id') + def get_or_create_from_ob2( + self, issuer, badgeclass_obo, source=None, original_json=None, image=None + ): + source_url = badgeclass_obo.get("id") local_object = self.get_local_object(source_url) if local_object: return local_object, False criteria_url = None criteria_text = None - criteria = badgeclass_obo.get('criteria', None) + criteria = badgeclass_obo.get("criteria", None) if isinstance(criteria, str): criteria_url = criteria - elif criteria.get('type', 'Criteria') == 'Criteria': - criteria_url = criteria.get('id', None) - criteria_text = criteria.get('narrative', None) + elif criteria.get("type", "Criteria") == "Criteria": + criteria_url = criteria.get("id", None) + criteria_text = criteria.get("narrative", None) return self.get_or_create( source_url=source_url, defaults=dict( issuer=issuer, - source=source if source is not None else 'local', - name=badgeclass_obo.get('name'), - description=badgeclass_obo.get('description', None), + source=source if source is not None else "local", + name=badgeclass_obo.get("name"), + description=badgeclass_obo.get("description", None), image=image, criteria_url=criteria_url, criteria_text=criteria_text, - original_json=original_json - ) + original_json=original_json, + ), ) + class BadgeInstanceEvidenceManager(models.Manager): @transaction.atomic def create_from_ob2(self, badgeinstance, evidence_obo): @@ -172,16 +178,17 @@ def create_from_ob2(self, badgeinstance, evidence_obo): badgeinstance=badgeinstance, evidence_url=evidence_obo, narrative=None, - original_json='') + original_json="", + ) return self.create( badgeinstance=badgeinstance, - evidence_url=evidence_obo.get('id', None), - narrative=evidence_obo.get('narrative', None), - original_json=json.dumps(evidence_obo) + evidence_url=evidence_obo.get("id", None), + narrative=evidence_obo.get("narrative", None), + original_json=json.dumps(evidence_obo), ) -def _fetch_image_and_get_file(url, allowed_mime_types, upload_to=''): +def _fetch_image_and_get_file(url, allowed_mime_types, upload_to=""): status_code, storage_name = fetch_remote_file_to_storage( url, upload_to=upload_to, allowed_mime_types=allowed_mime_types ) @@ -193,111 +200,140 @@ def _fetch_image_and_get_file(url, allowed_mime_types, upload_to=''): class BadgeInstanceManager(BaseOpenBadgeObjectManager): ALLOWED_MINE_TYPES = [ - 'image/png', - 'image/svg+xml', + "image/png", + "image/svg+xml", ] - def update_from_ob2(self, badgeclass, assertion_obo, recipient_identifier, recipient_type='email', original_json=None): + def update_from_ob2( + self, + badgeclass, + assertion_obo, + recipient_identifier, + recipient_type="email", + original_json=None, + ): image = None - image_url = assertion_obo.get('image', None) + image_url = assertion_obo.get("image", None) if isinstance(image_url, dict): - image_url = image_url.get('id') + image_url = image_url.get("id") if image_url: - image = _fetch_image_and_get_file(image_url, self.ALLOWED_MINE_TYPES, upload_to='remote/assertion') + image = _fetch_image_and_get_file( + image_url, self.ALLOWED_MINE_TYPES, upload_to="remote/assertion" + ) issued_on = None - if 'issuedOn' in assertion_obo: - issued_on = dateutil.parser.parse(assertion_obo.get('issuedOn')) + if "issuedOn" in assertion_obo: + issued_on = dateutil.parser.parse(assertion_obo.get("issuedOn")) updated, created = self.update_or_create( - source_url=assertion_obo.get('id'), + source_url=assertion_obo.get("id"), defaults=dict( recipient_identifier=recipient_identifier, recipient_type=recipient_type, - hashed=assertion_obo.get('recipient', {}).get('hashed', True), + hashed=assertion_obo.get("recipient", {}).get("hashed", True), original_json=original_json, badgeclass=badgeclass, issuer=badgeclass.cached_issuer, image=image, acceptance=self.model.ACCEPTANCE_ACCEPTED, - narrative=assertion_obo.get('narrative', None), - issued_on=issued_on - ) + narrative=assertion_obo.get("narrative", None), + issued_on=issued_on, + ), ) - evidence = list_of(assertion_obo.get('evidence', None)) + evidence = list_of(assertion_obo.get("evidence", None)) evidence_items = [] for item in evidence: if isinstance(item, six.string_types): - evidence_items.append({'evidence_url': item}) # convert string/url type evidence to consistent format - elif hasattr(item, 'get'): - evidence_items.append({'evidence_url': item.get('id'), 'narrative': item.get('narrative')}) + evidence_items.append( + {"evidence_url": item} + ) # convert string/url type evidence to consistent format + elif hasattr(item, "get"): + evidence_items.append( + {"evidence_url": item.get("id"), "narrative": item.get("narrative")} + ) updated.evidence_items = evidence_items return updated, created def image_from_ob2(self, badgeclass_image, assertion_obo): - image_url = assertion_obo.get('image', None) + image_url = assertion_obo.get("image", None) image = None if image_url is None: image = badgeclass_image image.name = os.path.split(image.name)[1] else: if isinstance(image_url, dict): - image_url = image_url.get('id') - image = _fetch_image_and_get_file(image_url, self.ALLOWED_MINE_TYPES, upload_to='remote/assertion') + image_url = image_url.get("id") + image = _fetch_image_and_get_file( + image_url, self.ALLOWED_MINE_TYPES, upload_to="remote/assertion" + ) return image @transaction.atomic - def get_or_create_from_ob2(self, badgeclass, assertion_obo, recipient_identifier, recipient_type='email', source=None, original_json=None, image=None): - source_url = assertion_obo.get('id') + def get_or_create_from_ob2( + self, + badgeclass, + assertion_obo, + recipient_identifier, + recipient_type="email", + source=None, + original_json=None, + image=None, + ): + source_url = assertion_obo.get("id") local_object = self.get_local_object(source_url) if local_object: return local_object, False issued_on = None - if 'issuedOn' in assertion_obo: - issued_on = dateutil.parser.parse(assertion_obo.get('issuedOn')) + if "issuedOn" in assertion_obo: + issued_on = dateutil.parser.parse(assertion_obo.get("issuedOn")) badgeinstance, created = self.get_or_create( - source_url=assertion_obo.get('id'), + source_url=assertion_obo.get("id"), defaults=dict( recipient_identifier=recipient_identifier, recipient_type=recipient_type, - hashed=assertion_obo.get('recipient', {}).get('hashed', True), - source=source if source is not None else 'local', + hashed=assertion_obo.get("recipient", {}).get("hashed", True), + source=source if source is not None else "local", original_json=original_json, badgeclass=badgeclass, issuer=badgeclass.cached_issuer, image=image, acceptance=self.model.ACCEPTANCE_ACCEPTED, - narrative=assertion_obo.get('narrative', None), - issued_on=issued_on - ) + narrative=assertion_obo.get("narrative", None), + issued_on=issued_on, + ), ) if created: - evidence = list_of(assertion_obo.get('evidence', None)) + evidence = list_of(assertion_obo.get("evidence", None)) if evidence: from issuer.models import BadgeInstanceEvidence + for evidence_item in evidence: if isinstance(evidence_item, str): # we got an IRI as 'evidence' value BadgeInstanceEvidence.objects.create( - badgeinstance=badgeinstance, - evidence_url=evidence_item + badgeinstance=badgeinstance, evidence_url=evidence_item ) else: # we got a single evidence item dict - BadgeInstanceEvidence.objects.create_from_ob2(badgeinstance, evidence_item) + BadgeInstanceEvidence.objects.create_from_ob2( + badgeinstance, evidence_item + ) return badgeinstance, created - def create(self, + def create( + self, evidence=None, extensions=None, notify=False, allow_uppercase=False, badgr_app=None, - **kwargs + microdegree_id=None, + issuerSlug=None, + **kwargs, ): """ Convenience method to award a badge to a recipient_id @@ -307,29 +343,89 @@ def create(self, :type notify: bool :type evidence: list of dicts(url=string, narrative=string) """ - recipient_identifier = kwargs.pop('recipient_identifier') - recipient_identifier = sanitize_id(recipient_identifier, kwargs.get('recipient_type', 'email'), allow_uppercase=allow_uppercase) + recipient_identifier = kwargs.pop("recipient_identifier") + recipient_identifier = sanitize_id( + recipient_identifier, + kwargs.get("recipient_type", "email"), + allow_uppercase=allow_uppercase, + ) + + from issuer.models import Issuer - badgeclass = kwargs.pop('badgeclass', None) - issuer = kwargs.pop('issuer', badgeclass.issuer) + badgeclass = kwargs.pop("badgeclass", None) + if issuerSlug: + issuer = Issuer.objects.get(entity_id=issuerSlug) + else: + issuer = kwargs.pop("issuer", badgeclass.issuer) + + if badgeclass and badgeclass.expiration: + issued_on = kwargs.get("issued_on", timezone.now()) + kwargs["expires_at"] = issued_on + timedelta(days=badgeclass.expiration) # self.model would be a BadgeInstance new_instance = self.model( recipient_identifier=recipient_identifier, badgeclass=badgeclass, issuer=issuer, - **kwargs + **kwargs, ) with transaction.atomic(): new_instance.save() + if badgeclass and badgeclass.imageFrame: + badge_extensions = badgeclass.cached_extensions() + categoryExtension = badge_extensions.get( + name="extensions:CategoryExtension" + ) + category = json.loads(categoryExtension.original_json)["Category"] + + try: + issuer_image = None + network_image = None + + if badgeclass.cached_issuer.is_network: + issuer_image = ( + issuer.image + if issuer and category != "learningpath" + else None + ) + network_image = badgeclass.cached_issuer.image + + else: + share = ( + badgeclass.network_shares.filter(is_active=True) + .select_related("network") + .first() + ) + if share: + network_image = share.network.image + issuer_image = issuer.image + + if issuer_image and network_image and category != "learningpath": + new_instance.generate_assertion_image( + issuer_image, + network_image, + ) + new_instance.save(update_fields=["image"]) + except Exception as e: + import logging + + logger = logging.getLogger(__name__) + logger.error( + f"Failed to generate badge instance image for {new_instance.entity_id}: {str(e)}", + exc_info=True, + ) + if evidence is not None: from issuer.models import BadgeInstanceEvidence + for evidence_obj in evidence: - evidence_url = evidence_obj.get('evidence_url') - narrative = evidence_obj.get('narrative') - new_evidence = BadgeInstanceEvidence(badgeinstance=new_instance, evidence_url=evidence_url) + evidence_url = evidence_obj.get("evidence_url") + narrative = evidence_obj.get("narrative") + new_evidence = BadgeInstanceEvidence( + badgeinstance=new_instance, evidence_url=evidence_url + ) if narrative: new_evidence.narrative = narrative new_evidence.save() @@ -337,16 +433,39 @@ def create(self, if extensions is not None: for name, ext in list(extensions.items()): new_instance.badgeinstanceextension_set.create( - name=name, - original_json=json.dumps(ext) + name=name, original_json=json.dumps(ext) ) - if not notify and getattr(settings, 'GDPR_COMPLIANCE_NOTIFY_ON_FIRST_AWARD'): + # force recreation to include extensions and evidence in the json + new_instance.get_json(obi_version="3_0", force_recreate=True) + + if not notify and getattr(settings, "GDPR_COMPLIANCE_NOTIFY_ON_FIRST_AWARD"): # always notify if this is the first time issuing to a recipient if configured for GDPR compliance if self.filter(recipient_identifier=recipient_identifier).count() == 1: notify = True if notify: - new_instance.notify_earner(badgr_app=badgr_app) + new_instance.notify_earner( + badgr_app=badgr_app, microdegree_id=microdegree_id + ) + + # dynamic to prevent circular import + from issuer.models import LearningPath + + # check if badgeclass is used in learning paths + learningpathes = LearningPath.objects.filter( + learningpathbadge__badge=badgeclass + ) + for learningpath in learningpathes: + # all learningpath badges collected but participationBadge not yet issued + if learningpath.activated and learningpath.user_should_have_badge( + recipient_identifier + ): + # issue learningpath badge + learningpath.participationBadge.issue( + recipient_id=recipient_identifier, + notify=notify, + microdegree_id=learningpath.entity_id, + ) return new_instance diff --git a/apps/issuer/migrations/0001_initial.py b/apps/issuer/migrations/0001_initial.py index a077f5980..d3c505b11 100644 --- a/apps/issuer/migrations/0001_initial.py +++ b/apps/issuer/migrations/0001_initial.py @@ -9,93 +9,195 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='BadgeClass', + name="BadgeClass", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('json', jsonfield.fields.JSONField()), - ('name', models.CharField(max_length=255)), - ('slug', autoslug.fields.AutoSlugField(unique=True, max_length=255)), - ('criteria_text', models.TextField(null=True, blank=True)), - ('image', models.ImageField(upload_to=b'uploads/badges', blank=True)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("json", jsonfield.fields.JSONField()), + ("name", models.CharField(max_length=255)), + ("slug", autoslug.fields.AutoSlugField(unique=True, max_length=255)), + ("criteria_text", models.TextField(null=True, blank=True)), + ("image", models.ImageField(upload_to=b"uploads/badges", blank=True)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='BadgeInstance', + name="BadgeInstance", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('json', jsonfield.fields.JSONField()), - ('email', models.EmailField(max_length=255)), - ('slug', autoslug.fields.AutoSlugField(unique=True, max_length=255, editable=False)), - ('image', models.ImageField(upload_to=b'issued/badges', blank=True)), - ('revoked', models.BooleanField(default=False)), - ('revocation_reason', models.CharField(default=None, max_length=255, null=True, blank=True)), - ('badgeclass', models.ForeignKey(related_name='assertions', on_delete=django.db.models.deletion.PROTECT, to='issuer.BadgeClass')), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("json", jsonfield.fields.JSONField()), + ("email", models.EmailField(max_length=255)), + ( + "slug", + autoslug.fields.AutoSlugField( + unique=True, max_length=255, editable=False + ), + ), + ("image", models.ImageField(upload_to=b"issued/badges", blank=True)), + ("revoked", models.BooleanField(default=False)), + ( + "revocation_reason", + models.CharField( + default=None, max_length=255, null=True, blank=True + ), + ), + ( + "badgeclass", + models.ForeignKey( + related_name="assertions", + on_delete=django.db.models.deletion.PROTECT, + to="issuer.BadgeClass", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='Issuer', + name="Issuer", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('json', jsonfield.fields.JSONField()), - ('name', models.CharField(max_length=1024)), - ('slug', autoslug.fields.AutoSlugField(unique=True, max_length=255)), - ('image', models.ImageField(upload_to=b'uploads/issuers', blank=True)), - ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', blank=True, to=settings.AUTH_USER_MODEL, null=True)), - ('owner', models.ForeignKey(related_name='owner', on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("json", jsonfield.fields.JSONField()), + ("name", models.CharField(max_length=1024)), + ("slug", autoslug.fields.AutoSlugField(unique=True, max_length=255)), + ("image", models.ImageField(upload_to=b"uploads/issuers", blank=True)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), + ( + "owner", + models.ForeignKey( + related_name="owner", + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='IssuerStaff', + name="IssuerStaff", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('editor', models.BooleanField(default=False)), - ('badgeuser', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.Issuer')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("editor", models.BooleanField(default=False)), + ( + "badgeuser", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.Issuer" + ), + ), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.AddField( - model_name='issuer', - name='staff', - field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, through='issuer.IssuerStaff'), + model_name="issuer", + name="staff", + field=models.ManyToManyField( + to=settings.AUTH_USER_MODEL, through="issuer.IssuerStaff" + ), preserve_default=True, ), migrations.AddField( - model_name='badgeinstance', - name='issuer', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assertions', to='issuer.Issuer'), + model_name="badgeinstance", + name="issuer", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assertions", + to="issuer.Issuer", + ), preserve_default=True, ), migrations.AddField( - model_name='badgeclass', - name='issuer', - field=models.ForeignKey(related_name='badgeclasses', on_delete=django.db.models.deletion.PROTECT, to='issuer.Issuer'), + model_name="badgeclass", + name="issuer", + field=models.ForeignKey( + related_name="badgeclasses", + on_delete=django.db.models.deletion.PROTECT, + to="issuer.Issuer", + ), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0002_auto_20150409_1200.py b/apps/issuer/migrations/0002_auto_20150409_1200.py index 960459993..9857f5ebe 100644 --- a/apps/issuer/migrations/0002_auto_20150409_1200.py +++ b/apps/issuer/migrations/0002_auto_20150409_1200.py @@ -1,23 +1,22 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0001_initial'), + ("issuer", "0001_initial"), ] operations = [ migrations.RenameField( - model_name='issuerstaff', - old_name='badgeuser', - new_name='user', + model_name="issuerstaff", + old_name="badgeuser", + new_name="user", ), migrations.AlterUniqueTogether( - name='issuerstaff', - unique_together=set([('issuer', 'user')]), + name="issuerstaff", + unique_together=set([("issuer", "user")]), ), ] diff --git a/apps/issuer/migrations/0003_auto_20150512_0657.py b/apps/issuer/migrations/0003_auto_20150512_0657.py index b5c9c2d36..731bb1224 100644 --- a/apps/issuer/migrations/0003_auto_20150512_0657.py +++ b/apps/issuer/migrations/0003_auto_20150512_0657.py @@ -5,20 +5,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0002_auto_20150409_1200'), + ("issuer", "0002_auto_20150409_1200"), ] operations = [ migrations.AlterModelOptions( - name='badgeclass', - options={'verbose_name_plural': 'Badge classes'}, + name="badgeclass", + options={"verbose_name_plural": "Badge classes"}, ), migrations.AlterField( - model_name='badgeinstance', - name='image', - field=models.ImageField(upload_to=b'issued', blank=True), + model_name="badgeinstance", + name="image", + field=models.ImageField(upload_to=b"issued", blank=True), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0004_auto_20150915_1722.py b/apps/issuer/migrations/0004_auto_20150915_1722.py index 036feed38..5f2b7ca76 100644 --- a/apps/issuer/migrations/0004_auto_20150915_1722.py +++ b/apps/issuer/migrations/0004_auto_20150915_1722.py @@ -7,64 +7,75 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0003_auto_20150512_0657'), + ("issuer", "0003_auto_20150512_0657"), ] operations = [ migrations.AddField( - model_name='badgeclass', - name='identifier', - field=models.CharField(default=b'get_full_url', max_length=1024), + model_name="badgeclass", + name="identifier", + field=models.CharField(default=b"get_full_url", max_length=1024), preserve_default=True, ), migrations.AddField( - model_name='badgeinstance', - name='identifier', - field=models.CharField(default=b'get_full_url', max_length=1024), + model_name="badgeinstance", + name="identifier", + field=models.CharField(default=b"get_full_url", max_length=1024), preserve_default=True, ), migrations.AddField( - model_name='badgeinstance', - name='recipient_identifier', - field=models.EmailField(default='', max_length=1024), + model_name="badgeinstance", + name="recipient_identifier", + field=models.EmailField(default="", max_length=1024), preserve_default=False, ), migrations.AddField( - model_name='issuer', - name='identifier', - field=models.CharField(default=b'get_full_url', max_length=1024), + model_name="issuer", + name="identifier", + field=models.CharField(default=b"get_full_url", max_length=1024), preserve_default=True, ), migrations.AlterField( - model_name='badgeinstance', - name='badgeclass', - field=models.ForeignKey(related_name='badgeinstances', on_delete=django.db.models.deletion.PROTECT, to='issuer.BadgeClass'), + model_name="badgeinstance", + name="badgeclass", + field=models.ForeignKey( + related_name="badgeinstances", + on_delete=django.db.models.deletion.PROTECT, + to="issuer.BadgeClass", + ), preserve_default=True, ), migrations.AlterField( - model_name='badgeinstance', - name='image', - field=models.ImageField(upload_to=b'uploads/badges', blank=True), + model_name="badgeinstance", + name="image", + field=models.ImageField(upload_to=b"uploads/badges", blank=True), preserve_default=True, ), migrations.AlterField( - model_name='badgeinstance', - name='issuer', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.Issuer'), + model_name="badgeinstance", + name="issuer", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.Issuer" + ), preserve_default=True, ), migrations.AlterField( - model_name='issuer', - name='image', - field=models.ImageField(null=True, upload_to=b'uploads/issuers', blank=True), + model_name="issuer", + name="image", + field=models.ImageField( + null=True, upload_to=b"uploads/issuers", blank=True + ), preserve_default=True, ), migrations.AlterField( - model_name='issuer', - name='owner', - field=models.ForeignKey(related_name='issuers', on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + model_name="issuer", + name="owner", + field=models.ForeignKey( + related_name="issuers", + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0005_auto_20150915_1723.py b/apps/issuer/migrations/0005_auto_20150915_1723.py index ccd59e0be..db18d2b5a 100644 --- a/apps/issuer/migrations/0005_auto_20150915_1723.py +++ b/apps/issuer/migrations/0005_auto_20150915_1723.py @@ -1,22 +1,19 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations def copy_email_into_recipient_identifier(apps, schema_editor): - BadgeInstance = apps.get_model('issuer', 'BadgeInstance') + BadgeInstance = apps.get_model("issuer", "BadgeInstance") for badgeinstance in BadgeInstance.objects.all(): badgeinstance.recipient_identifier = badgeinstance.email badgeinstance.save() class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0004_auto_20150915_1722'), + ("issuer", "0004_auto_20150915_1722"), ] - operations = [ - migrations.RunPython(copy_email_into_recipient_identifier) - ] + operations = [migrations.RunPython(copy_email_into_recipient_identifier)] diff --git a/apps/issuer/migrations/0006_remove_badgeinstance_email.py b/apps/issuer/migrations/0006_remove_badgeinstance_email.py index 8685e2743..db9f2811a 100644 --- a/apps/issuer/migrations/0006_remove_badgeinstance_email.py +++ b/apps/issuer/migrations/0006_remove_badgeinstance_email.py @@ -1,18 +1,17 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0005_auto_20150915_1723'), + ("issuer", "0005_auto_20150915_1723"), ] operations = [ migrations.RemoveField( - model_name='badgeinstance', - name='email', + model_name="badgeinstance", + name="email", ), ] diff --git a/apps/issuer/migrations/0007_auto_20151117_1555.py b/apps/issuer/migrations/0007_auto_20151117_1555.py index aa872928c..6d1fc7709 100644 --- a/apps/issuer/migrations/0007_auto_20151117_1555.py +++ b/apps/issuer/migrations/0007_auto_20151117_1555.py @@ -5,28 +5,27 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0006_remove_badgeinstance_email'), + ("issuer", "0006_remove_badgeinstance_email"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='image', - field=models.FileField(upload_to=b'uploads/badges', blank=True), + model_name="badgeclass", + name="image", + field=models.FileField(upload_to=b"uploads/badges", blank=True), preserve_default=True, ), migrations.AlterField( - model_name='badgeinstance', - name='image', - field=models.FileField(upload_to=b'uploads/badges', blank=True), + model_name="badgeinstance", + name="image", + field=models.FileField(upload_to=b"uploads/badges", blank=True), preserve_default=True, ), migrations.AlterField( - model_name='issuer', - name='image', - field=models.FileField(null=True, upload_to=b'uploads/issuers', blank=True), + model_name="issuer", + name="image", + field=models.FileField(null=True, upload_to=b"uploads/issuers", blank=True), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0008_auto_20160322_1404.py b/apps/issuer/migrations/0008_auto_20160322_1404.py index 0e89cc656..68459d38a 100644 --- a/apps/issuer/migrations/0008_auto_20160322_1404.py +++ b/apps/issuer/migrations/0008_auto_20160322_1404.py @@ -6,22 +6,29 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0007_auto_20151117_1555'), + ("issuer", "0007_auto_20151117_1555"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='issuer', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='badgeclasses', to='issuer.Issuer'), + model_name="badgeclass", + name="issuer", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="badgeclasses", + to="issuer.Issuer", + ), preserve_default=True, ), migrations.AlterField( - model_name='badgeinstance', - name='badgeclass', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='badgeinstances', to='issuer.BadgeClass'), + model_name="badgeinstance", + name="badgeclass", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="badgeinstances", + to="issuer.BadgeClass", + ), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0009_badgeinstance_acceptance.py b/apps/issuer/migrations/0009_badgeinstance_acceptance.py index 0511d7557..2553a1e34 100644 --- a/apps/issuer/migrations/0009_badgeinstance_acceptance.py +++ b/apps/issuer/migrations/0009_badgeinstance_acceptance.py @@ -5,16 +5,23 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0008_auto_20160322_1404'), + ("issuer", "0008_auto_20160322_1404"), ] operations = [ migrations.AddField( - model_name='badgeinstance', - name='acceptance', - field=models.CharField(default=b'Accepted', max_length=254, choices=[(b'Unaccepted', b'Unaccepted'), (b'Accepted', b'Accepted'), (b'Rejected', b'Rejected')]), + model_name="badgeinstance", + name="acceptance", + field=models.CharField( + default=b"Accepted", + max_length=254, + choices=[ + (b"Unaccepted", b"Unaccepted"), + (b"Accepted", b"Accepted"), + (b"Rejected", b"Rejected"), + ], + ), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0010_auto_20170120_1724.py b/apps/issuer/migrations/0010_auto_20170120_1724.py index f3fcd1881..d4ee1e26c 100644 --- a/apps/issuer/migrations/0010_auto_20170120_1724.py +++ b/apps/issuer/migrations/0010_auto_20170120_1724.py @@ -5,16 +5,23 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0009_badgeinstance_acceptance'), + ("issuer", "0009_badgeinstance_acceptance"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='acceptance', - field=models.CharField(default=b'Unaccepted', max_length=254, choices=[(b'Unaccepted', b'Unaccepted'), (b'Accepted', b'Accepted'), (b'Rejected', b'Rejected')]), + model_name="badgeinstance", + name="acceptance", + field=models.CharField( + default=b"Unaccepted", + max_length=254, + choices=[ + (b"Unaccepted", b"Unaccepted"), + (b"Accepted", b"Accepted"), + (b"Rejected", b"Rejected"), + ], + ), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0011_auto_20170213_1531.py b/apps/issuer/migrations/0011_auto_20170213_1531.py index 011a0b704..9e6709026 100644 --- a/apps/issuer/migrations/0011_auto_20170213_1531.py +++ b/apps/issuer/migrations/0011_auto_20170213_1531.py @@ -5,78 +5,77 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0010_auto_20170120_1724'), + ("issuer", "0010_auto_20170120_1724"), ] operations = [ migrations.RenameField( - model_name='badgeclass', - old_name='json', - new_name='old_json', + model_name="badgeclass", + old_name="json", + new_name="old_json", ), migrations.RenameField( - model_name='badgeinstance', - old_name='json', - new_name='old_json', + model_name="badgeinstance", + old_name="json", + new_name="old_json", ), migrations.RenameField( - model_name='issuer', - old_name='json', - new_name='old_json', + model_name="issuer", + old_name="json", + new_name="old_json", ), migrations.RemoveField( - model_name='badgeclass', - name='identifier', + model_name="badgeclass", + name="identifier", ), migrations.RemoveField( - model_name='badgeinstance', - name='identifier', + model_name="badgeinstance", + name="identifier", ), migrations.RemoveField( - model_name='issuer', - name='identifier', + model_name="issuer", + name="identifier", ), migrations.AddField( - model_name='badgeclass', - name='criteria_url', + model_name="badgeclass", + name="criteria_url", field=models.CharField(default=None, max_length=254, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='badgeclass', - name='description', + model_name="badgeclass", + name="description", field=models.TextField(default=None, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='badgeinstance', - name='evidence_url', + model_name="badgeinstance", + name="evidence_url", field=models.CharField(default=None, max_length=254, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='badgeinstance', - name='salt', + model_name="badgeinstance", + name="salt", field=models.CharField(default=None, max_length=254, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='issuer', - name='description', + model_name="issuer", + name="description", field=models.TextField(default=None, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='issuer', - name='email', + model_name="issuer", + name="email", field=models.CharField(default=None, max_length=254, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='issuer', - name='url', + model_name="issuer", + name="url", field=models.CharField(default=None, max_length=254, null=True, blank=True), preserve_default=True, ), diff --git a/apps/issuer/migrations/0012_auto_20170213_1531.py b/apps/issuer/migrations/0012_auto_20170213_1531.py index e0e5f14bd..0beedcf74 100644 --- a/apps/issuer/migrations/0012_auto_20170213_1531.py +++ b/apps/issuer/migrations/0012_auto_20170213_1531.py @@ -12,22 +12,24 @@ def noop(apps, schema_editor): def deserialize_issuer_json(apps, schema_editor): - Issuer = apps.get_model('issuer', 'Issuer') + Issuer = apps.get_model("issuer", "Issuer") for issuer in Issuer.objects.all(): - issuer.description = issuer.old_json.get('description') - issuer.email = issuer.old_json.get('email') - issuer.url = issuer.old_json.get('url') + issuer.description = issuer.old_json.get("description") + issuer.email = issuer.old_json.get("email") + issuer.url = issuer.old_json.get("url") issuer.save() def deserialize_badgeclass_json(apps, schema_editor): - BadgeClass = apps.get_model('issuer', 'BadgeClass') + BadgeClass = apps.get_model("issuer", "BadgeClass") for badgeclass in BadgeClass.objects.all(): - badgeclass.description = badgeclass.old_json.get('description') + badgeclass.description = badgeclass.old_json.get("description") - criteria_url = badgeclass.old_json.get('criteria_url') + criteria_url = badgeclass.old_json.get("criteria_url") - local_criteria_url = OriginSetting.HTTP+reverse('badgeclass_criteria', kwargs={'entity_id': badgeclass.slug}) + local_criteria_url = OriginSetting.HTTP + reverse( + "badgeclass_criteria", kwargs={"entity_id": badgeclass.slug} + ) if criteria_url != local_criteria_url: badgeclass.criteria_url = criteria_url @@ -35,13 +37,13 @@ def deserialize_badgeclass_json(apps, schema_editor): def deserialize_badgeinstance_json(apps, schema_editor): - BadgeInstance = apps.get_model('issuer', 'BadgeInstance') + BadgeInstance = apps.get_model("issuer", "BadgeInstance") for instance in BadgeInstance.objects.all(): - recipient = instance.old_json.get('recipient') - if not isinstance(recipient, str) and 'salt' in recipient: - instance.salt = recipient.get('salt') + recipient = instance.old_json.get("recipient") + if not isinstance(recipient, str) and "salt" in recipient: + instance.salt = recipient.get("salt") - evidence = instance.old_json.get('evidence') + evidence = instance.old_json.get("evidence") if evidence: instance.evidence_url = evidence @@ -49,14 +51,17 @@ def deserialize_badgeinstance_json(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0011_auto_20170213_1531'), + ("issuer", "0011_auto_20170213_1531"), ] operations = [ - migrations.AlterField('BadgeInstance', 'evidence_url', models.CharField(max_length=2083, blank=True, null=True, default=None)), + migrations.AlterField( + "BadgeInstance", + "evidence_url", + models.CharField(max_length=2083, blank=True, null=True, default=None), + ), migrations.RunPython(deserialize_issuer_json, reverse_code=noop), migrations.RunPython(deserialize_badgeclass_json, reverse_code=noop), - migrations.RunPython(deserialize_badgeinstance_json, reverse_code=noop) + migrations.RunPython(deserialize_badgeinstance_json, reverse_code=noop), ] diff --git a/apps/issuer/migrations/0013_auto_20170214_0711.py b/apps/issuer/migrations/0013_auto_20170214_0711.py index cc191fb29..be4555b2e 100644 --- a/apps/issuer/migrations/0013_auto_20170214_0711.py +++ b/apps/issuer/migrations/0013_auto_20170214_0711.py @@ -5,33 +5,32 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0012_auto_20170213_1531'), + ("issuer", "0012_auto_20170213_1531"), ] operations = [ migrations.AddField( - model_name='badgeclass', - name='source', - field=models.CharField(default='local', max_length=254), + model_name="badgeclass", + name="source", + field=models.CharField(default="local", max_length=254), preserve_default=True, ), migrations.AddField( - model_name='badgeclass', - name='source_url', + model_name="badgeclass", + name="source_url", field=models.CharField(default=None, max_length=254, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='issuer', - name='source', - field=models.CharField(default='local', max_length=254), + model_name="issuer", + name="source", + field=models.CharField(default="local", max_length=254), preserve_default=True, ), migrations.AddField( - model_name='issuer', - name='source_url', + model_name="issuer", + name="source_url", field=models.CharField(default=None, max_length=254, null=True, blank=True), preserve_default=True, ), diff --git a/apps/issuer/migrations/0014_issuerstaff_role.py b/apps/issuer/migrations/0014_issuerstaff_role.py index d60c2c9e0..e52094d53 100644 --- a/apps/issuer/migrations/0014_issuerstaff_role.py +++ b/apps/issuer/migrations/0014_issuerstaff_role.py @@ -5,16 +5,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0013_auto_20170214_0711'), + ("issuer", "0013_auto_20170214_0711"), ] operations = [ migrations.AddField( - model_name='issuerstaff', - name='role', - field=models.CharField(default='staff', max_length=254, choices=[('owner', 'Owner'), ('editor', 'Editor'), ('staff', 'Staff')]), + model_name="issuerstaff", + name="role", + field=models.CharField( + default="staff", + max_length=254, + choices=[("owner", "Owner"), ("editor", "Editor"), ("staff", "Staff")], + ), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0015_auto_20170214_0738.py b/apps/issuer/migrations/0015_auto_20170214_0738.py index c3b6b125b..df299ad7d 100644 --- a/apps/issuer/migrations/0015_auto_20170214_0738.py +++ b/apps/issuer/migrations/0015_auto_20170214_0738.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations def noop(apps, schema): @@ -9,26 +9,23 @@ def noop(apps, schema): def update_staff_role(apps, schema): - IssuerStaff = apps.get_model('issuer', 'IssuerStaff') - Issuer = apps.get_model('issuer', 'Issuer') + IssuerStaff = apps.get_model("issuer", "IssuerStaff") + Issuer = apps.get_model("issuer", "Issuer") for staff in IssuerStaff.objects.all(): - if staff.editor and staff.role != 'editor': - staff.role = 'editor' + if staff.editor and staff.role != "editor": + staff.role = "editor" staff.save() for issuer in Issuer.objects.all(): - new_staff, created = IssuerStaff.objects.get_or_create(issuer=issuer, user=issuer.owner, defaults={ - 'role': 'owner' - }) + new_staff, created = IssuerStaff.objects.get_or_create( + issuer=issuer, user=issuer.owner, defaults={"role": "owner"} + ) new_staff.save() class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0014_issuerstaff_role'), + ("issuer", "0014_issuerstaff_role"), ] - operations = [ - migrations.RunPython(update_staff_role, reverse_code=noop) - ] + operations = [migrations.RunPython(update_staff_role, reverse_code=noop)] diff --git a/apps/issuer/migrations/0016_auto_20170214_0749.py b/apps/issuer/migrations/0016_auto_20170214_0749.py index 1bddd6a8d..ce9b62e0f 100644 --- a/apps/issuer/migrations/0016_auto_20170214_0749.py +++ b/apps/issuer/migrations/0016_auto_20170214_0749.py @@ -1,22 +1,21 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0015_auto_20170214_0738'), + ("issuer", "0015_auto_20170214_0738"), ] operations = [ migrations.RemoveField( - model_name='issuer', - name='owner', + model_name="issuer", + name="owner", ), migrations.RemoveField( - model_name='issuerstaff', - name='editor', + model_name="issuerstaff", + name="editor", ), ] diff --git a/apps/issuer/migrations/0017_auto_20170227_1334.py b/apps/issuer/migrations/0017_auto_20170227_1334.py index aaaca79d1..6bdd1f4c0 100644 --- a/apps/issuer/migrations/0017_auto_20170227_1334.py +++ b/apps/issuer/migrations/0017_auto_20170227_1334.py @@ -6,38 +6,47 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0016_auto_20170214_0749'), + ("issuer", "0016_auto_20170214_0749"), ] operations = [ migrations.AddField( - model_name='badgeclass', - name='original_json', + model_name="badgeclass", + name="original_json", field=models.TextField(default=None, null=True, blank=True), preserve_default=True, ), migrations.AddField( - model_name='issuer', - name='original_json', + model_name="issuer", + name="original_json", field=models.TextField(default=None, null=True, blank=True), preserve_default=True, ), - # Reset state of populate_from kwarg for AutoSlugField after bumping django-autoslugfield to 1.9.3. See: # https://github.com/neithere/django-autoslug/commit/7b6288a300fba3afa634dc2ba836398e86468d8e migrations.AlterField( - model_name='badgeclass', - name='slug', - field=AutoSlugField(max_length=255, populate_from='name', unique=True, blank=False, editable=True), + model_name="badgeclass", + name="slug", + field=AutoSlugField( + max_length=255, + populate_from="name", + unique=True, + blank=False, + editable=True, + ), preserve_default=True, ), migrations.AlterField( - model_name='issuer', - name='slug', - field=AutoSlugField(max_length=255, populate_from='name', unique=True, blank=False, editable=True), + model_name="issuer", + name="slug", + field=AutoSlugField( + max_length=255, + populate_from="name", + unique=True, + blank=False, + editable=True, + ), preserve_default=True, ), - ] diff --git a/apps/issuer/migrations/0018_auto_20170413_1054.py b/apps/issuer/migrations/0018_auto_20170413_1054.py index fde00ec16..315ebaa9d 100644 --- a/apps/issuer/migrations/0018_auto_20170413_1054.py +++ b/apps/issuer/migrations/0018_auto_20170413_1054.py @@ -1,21 +1,25 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations import autoslug.fields class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0017_auto_20170227_1334'), + ("issuer", "0017_auto_20170227_1334"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='slug', - field=autoslug.fields.AutoSlugField(populate_from='get_new_slug', unique=True, max_length=255, editable=False), + model_name="badgeinstance", + name="slug", + field=autoslug.fields.AutoSlugField( + populate_from="get_new_slug", + unique=True, + max_length=255, + editable=False, + ), preserve_default=True, ), ] diff --git a/apps/issuer/migrations/0019_auto_20170413_1136.py b/apps/issuer/migrations/0019_auto_20170413_1136.py index db6376f14..59f2748c9 100644 --- a/apps/issuer/migrations/0019_auto_20170413_1136.py +++ b/apps/issuer/migrations/0019_auto_20170413_1136.py @@ -5,63 +5,62 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0018_auto_20170413_1054'), + ("issuer", "0018_auto_20170413_1054"), ] operations = [ migrations.AddField( - model_name='badgeclass', - name='entity_id', + model_name="badgeclass", + name="entity_id", field=models.CharField(default=None, max_length=254, null=True), preserve_default=True, ), migrations.AddField( - model_name='badgeclass', - name='entity_version', + model_name="badgeclass", + name="entity_version", field=models.PositiveIntegerField(default=1), preserve_default=True, ), migrations.AddField( - model_name='badgeinstance', - name='entity_id', + model_name="badgeinstance", + name="entity_id", field=models.CharField(default=None, max_length=254, null=True), preserve_default=True, ), migrations.AddField( - model_name='badgeinstance', - name='entity_version', + model_name="badgeinstance", + name="entity_version", field=models.PositiveIntegerField(default=1), preserve_default=True, ), migrations.AddField( - model_name='issuer', - name='entity_id', + model_name="issuer", + name="entity_id", field=models.CharField(default=None, max_length=254, null=True), preserve_default=True, ), migrations.AddField( - model_name='issuer', - name='entity_version', + model_name="issuer", + name="entity_version", field=models.PositiveIntegerField(default=1), preserve_default=True, ), migrations.AlterField( - model_name='badgeclass', - name='slug', + model_name="badgeclass", + name="slug", field=models.CharField(default=None, max_length=255, null=True, blank=True), preserve_default=True, ), migrations.AlterField( - model_name='badgeinstance', - name='slug', + model_name="badgeinstance", + name="slug", field=models.CharField(default=None, max_length=255, null=True, blank=True), preserve_default=True, ), migrations.AlterField( - model_name='issuer', - name='slug', + model_name="issuer", + name="slug", field=models.CharField(default=None, max_length=255, null=True, blank=True), preserve_default=True, ), diff --git a/apps/issuer/migrations/0019_auto_20170420_0810.py b/apps/issuer/migrations/0019_auto_20170420_0810.py index 94429c9ef..5ce9b07be 100644 --- a/apps/issuer/migrations/0019_auto_20170420_0810.py +++ b/apps/issuer/migrations/0019_auto_20170420_0810.py @@ -6,28 +6,41 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0018_auto_20170413_1054'), + ("issuer", "0018_auto_20170413_1054"), ] operations = [ migrations.CreateModel( - name='BadgeInstanceEvidence', + name="BadgeInstanceEvidence", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('evidence_url', models.CharField(max_length=2083)), - ('narrative', models.TextField(default=None, null=True, blank=True)), - ('badgeinstance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeInstance')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("evidence_url", models.CharField(max_length=2083)), + ("narrative", models.TextField(default=None, null=True, blank=True)), + ( + "badgeinstance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeInstance", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.AddField( - model_name='badgeinstance', - name='narrative', + model_name="badgeinstance", + name="narrative", field=models.TextField(default=None, null=True, blank=True), preserve_default=True, ), diff --git a/apps/issuer/migrations/0020_auto_20170413_1139.py b/apps/issuer/migrations/0020_auto_20170413_1139.py index fd4922ca1..f9fbbb8cf 100644 --- a/apps/issuer/migrations/0020_auto_20170413_1139.py +++ b/apps/issuer/migrations/0020_auto_20170413_1139.py @@ -7,17 +7,15 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0019_auto_20170413_1136'), - ('composition', '0010_auto_20170214_0712'), + ("issuer", "0019_auto_20170413_1136"), + ("composition", "0010_auto_20170214_0712"), ] operations = [ - PopulateEntityIdsMigration('issuer', 'Issuer'), - PopulateEntityIdsMigration('issuer', 'BadgeClass'), - PopulateEntityIdsMigration('issuer', 'BadgeInstance', entity_class_name='Assertion'), + PopulateEntityIdsMigration("issuer", "Issuer"), + PopulateEntityIdsMigration("issuer", "BadgeClass"), + PopulateEntityIdsMigration( + "issuer", "BadgeInstance", entity_class_name="Assertion" + ), ] - - - diff --git a/apps/issuer/migrations/0020_auto_20170420_0811.py b/apps/issuer/migrations/0020_auto_20170420_0811.py index 15f47b69f..933341602 100644 --- a/apps/issuer/migrations/0020_auto_20170420_0811.py +++ b/apps/issuer/migrations/0020_auto_20170420_0811.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations from django.db.migrations import RunPython @@ -10,19 +10,18 @@ def noop(apps, schema): def migrate_evidence_url_to_badgeinstanceevidence(apps, schema): - BadgeInstance = apps.get_model('issuer', 'BadgeInstance') - BadgeInstanceEvidence = apps.get_model('issuer', 'BadgeInstanceEvidence') + BadgeInstance = apps.get_model("issuer", "BadgeInstance") + BadgeInstanceEvidence = apps.get_model("issuer", "BadgeInstanceEvidence") for assertion in BadgeInstance.objects.all(): if assertion.evidence_url: evidence, created = BadgeInstanceEvidence.objects.get_or_create( - badgeinstance=assertion, - evidence_url=assertion.evidence_url) + badgeinstance=assertion, evidence_url=assertion.evidence_url + ) class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0019_auto_20170420_0810'), + ("issuer", "0019_auto_20170420_0810"), ] operations = [ diff --git a/apps/issuer/migrations/0021_auto_20170424_1427.py b/apps/issuer/migrations/0021_auto_20170424_1427.py index 4a658c425..10cb0fb17 100644 --- a/apps/issuer/migrations/0021_auto_20170424_1427.py +++ b/apps/issuer/migrations/0021_auto_20170424_1427.py @@ -5,27 +5,26 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0020_auto_20170413_1139'), + ("issuer", "0020_auto_20170413_1139"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='entity_id', + model_name="badgeclass", + name="entity_id", field=models.CharField(default=None, unique=True, max_length=254), preserve_default=True, ), migrations.AlterField( - model_name='badgeinstance', - name='entity_id', + model_name="badgeinstance", + name="entity_id", field=models.CharField(default=None, unique=True, max_length=254), preserve_default=True, ), migrations.AlterField( - model_name='issuer', - name='entity_id', + model_name="issuer", + name="entity_id", field=models.CharField(default=None, unique=True, max_length=254), preserve_default=True, ), diff --git a/apps/issuer/migrations/0021_remove_badgeinstance_evidence_url.py b/apps/issuer/migrations/0021_remove_badgeinstance_evidence_url.py index f214d7d85..1c828aa25 100644 --- a/apps/issuer/migrations/0021_remove_badgeinstance_evidence_url.py +++ b/apps/issuer/migrations/0021_remove_badgeinstance_evidence_url.py @@ -1,18 +1,17 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0020_auto_20170420_0811'), + ("issuer", "0020_auto_20170420_0811"), ] operations = [ migrations.RemoveField( - model_name='badgeinstance', - name='evidence_url', + model_name="badgeinstance", + name="evidence_url", ), ] diff --git a/apps/issuer/migrations/0022_merge.py b/apps/issuer/migrations/0022_merge.py index bef8c8034..40cc69521 100644 --- a/apps/issuer/migrations/0022_merge.py +++ b/apps/issuer/migrations/0022_merge.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- -from django.db import migrations, models +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0021_remove_badgeinstance_evidence_url'), - ('issuer', '0021_auto_20170424_1427'), + ("issuer", "0021_remove_badgeinstance_evidence_url"), + ("issuer", "0021_auto_20170424_1427"), ] - operations = [ - ] + operations = [] diff --git a/apps/issuer/migrations/0023_auto_20170531_1044.py b/apps/issuer/migrations/0023_auto_20170531_1044.py index b48151199..664bf2569 100644 --- a/apps/issuer/migrations/0023_auto_20170531_1044.py +++ b/apps/issuer/migrations/0023_auto_20170531_1044.py @@ -5,20 +5,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0022_merge'), + ("issuer", "0022_merge"), ] operations = [ migrations.AddField( - model_name='badgeinstance', - name='source', - field=models.CharField(default='local', max_length=254), + model_name="badgeinstance", + name="source", + field=models.CharField(default="local", max_length=254), ), migrations.AddField( - model_name='badgeinstance', - name='source_url', + model_name="badgeinstance", + name="source_url", field=models.CharField(default=None, max_length=254, null=True, blank=True), ), ] diff --git a/apps/issuer/migrations/0024_auto_20170609_0845.py b/apps/issuer/migrations/0024_auto_20170609_0845.py index 630065031..95a25f8d4 100644 --- a/apps/issuer/migrations/0024_auto_20170609_0845.py +++ b/apps/issuer/migrations/0024_auto_20170609_0845.py @@ -5,20 +5,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0023_auto_20170531_1044'), + ("issuer", "0023_auto_20170531_1044"), ] operations = [ migrations.AddField( - model_name='badgeinstance', - name='original_json', + model_name="badgeinstance", + name="original_json", field=models.TextField(default=None, null=True, blank=True), ), migrations.AddField( - model_name='badgeinstanceevidence', - name='original_json', + model_name="badgeinstanceevidence", + name="original_json", field=models.TextField(default=None, null=True, blank=True), ), ] diff --git a/apps/issuer/migrations/0025_badgeinstance_issued_on.py b/apps/issuer/migrations/0025_badgeinstance_issued_on.py index c0baca998..bf4a3e951 100644 --- a/apps/issuer/migrations/0025_badgeinstance_issued_on.py +++ b/apps/issuer/migrations/0025_badgeinstance_issued_on.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0024_auto_20170609_0845'), + ("issuer", "0024_auto_20170609_0845"), ] operations = [ migrations.AddField( - model_name='badgeinstance', - name='issued_on', + model_name="badgeinstance", + name="issued_on", field=models.DateTimeField(null=True), ), ] diff --git a/apps/issuer/migrations/0026_auto_20170726_1409.py b/apps/issuer/migrations/0026_auto_20170726_1409.py index 891960c31..1c5f6160e 100644 --- a/apps/issuer/migrations/0026_auto_20170726_1409.py +++ b/apps/issuer/migrations/0026_auto_20170726_1409.py @@ -6,23 +6,22 @@ def created_at_to_issued_on(apps, schema_editor): - BadgeInstance = apps.get_model('issuer', 'BadgeInstance') + BadgeInstance = apps.get_model("issuer", "BadgeInstance") for badge in BadgeInstance.objects.all(): badge.issued_on = badge.created_at badge.save() class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0025_badgeinstance_issued_on'), + ("issuer", "0025_badgeinstance_issued_on"), ] operations = [ migrations.RunPython(created_at_to_issued_on), migrations.AlterField( - model_name='badgeinstance', - name='issued_on', + model_name="badgeinstance", + name="issued_on", field=models.DateTimeField(), ), ] diff --git a/apps/issuer/migrations/0027_auto_20170801_1636.py b/apps/issuer/migrations/0027_auto_20170801_1636.py index bdfe570b2..ddfca2009 100644 --- a/apps/issuer/migrations/0027_auto_20170801_1636.py +++ b/apps/issuer/migrations/0027_auto_20170801_1636.py @@ -7,15 +7,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0026_auto_20170726_1409'), + ("issuer", "0026_auto_20170726_1409"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='issued_on', + model_name="badgeinstance", + name="issued_on", field=models.DateTimeField(default=datetime.datetime.now), ), ] diff --git a/apps/issuer/migrations/0028_auto_20170802_1035.py b/apps/issuer/migrations/0028_auto_20170802_1035.py index 2f546b859..1ea4fa216 100644 --- a/apps/issuer/migrations/0028_auto_20170802_1035.py +++ b/apps/issuer/migrations/0028_auto_20170802_1035.py @@ -7,15 +7,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0027_auto_20170801_1636'), + ("issuer", "0027_auto_20170801_1636"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='issued_on', + model_name="badgeinstance", + name="issued_on", field=models.DateTimeField(default=django.utils.timezone.now), ), ] diff --git a/apps/issuer/migrations/0029_badgeinstance_recipient_type.py b/apps/issuer/migrations/0029_badgeinstance_recipient_type.py index ae2b7a236..023e0378c 100644 --- a/apps/issuer/migrations/0029_badgeinstance_recipient_type.py +++ b/apps/issuer/migrations/0029_badgeinstance_recipient_type.py @@ -6,15 +6,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0028_auto_20170802_1035'), + ("issuer", "0028_auto_20170802_1035"), ] operations = [ migrations.AddField( - model_name='badgeinstance', - name='recipient_type', - field=models.CharField(choices=[('email', 'email')], default='email', max_length=255), + model_name="badgeinstance", + name="recipient_type", + field=models.CharField( + choices=[("email", "email")], default="email", max_length=255 + ), ), ] diff --git a/apps/issuer/migrations/0030_badgeclassalignment.py b/apps/issuer/migrations/0030_badgeclassalignment.py index cf8c973da..2347d0e53 100644 --- a/apps/issuer/migrations/0030_badgeclassalignment.py +++ b/apps/issuer/migrations/0030_badgeclassalignment.py @@ -7,26 +7,48 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0029_badgeinstance_recipient_type'), + ("issuer", "0029_badgeinstance_recipient_type"), ] operations = [ migrations.CreateModel( - name='BadgeClassAlignment', + name="BadgeClassAlignment", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('original_json', models.TextField(blank=True, default=None, null=True)), - ('target_name', models.TextField()), - ('target_url', models.CharField(max_length=2083)), - ('target_description', models.TextField(blank=True, default=None, null=True)), - ('target_framework', models.TextField(blank=True, default=None, null=True)), - ('target_code', models.TextField(blank=True, default=None, null=True)), - ('badgeclass', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeClass')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ("target_name", models.TextField()), + ("target_url", models.CharField(max_length=2083)), + ( + "target_description", + models.TextField(blank=True, default=None, null=True), + ), + ( + "target_framework", + models.TextField(blank=True, default=None, null=True), + ), + ("target_code", models.TextField(blank=True, default=None, null=True)), + ( + "badgeclass", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeClass", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/apps/issuer/migrations/0031_auto_20170918_0735.py b/apps/issuer/migrations/0031_auto_20170918_0735.py index 9387a450b..d64ab00c2 100644 --- a/apps/issuer/migrations/0031_auto_20170918_0735.py +++ b/apps/issuer/migrations/0031_auto_20170918_0735.py @@ -3,19 +3,26 @@ from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0030_badgeclassalignment'), + ("issuer", "0030_badgeclassalignment"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='recipient_type', - field=models.CharField(choices=[('email', 'email'), ('id', 'id'), ('telephone', 'telephone'), ('url', 'url')], default='email', max_length=255), + model_name="badgeinstance", + name="recipient_type", + field=models.CharField( + choices=[ + ("email", "email"), + ("id", "id"), + ("telephone", "telephone"), + ("url", "url"), + ], + default="email", + max_length=255, + ), ), ] diff --git a/apps/issuer/migrations/0032_badgeclasstag.py b/apps/issuer/migrations/0032_badgeclasstag.py index fd365e732..a9f16aac6 100644 --- a/apps/issuer/migrations/0032_badgeclasstag.py +++ b/apps/issuer/migrations/0032_badgeclasstag.py @@ -7,21 +7,34 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0031_auto_20170918_0735'), + ("issuer", "0031_auto_20170918_0735"), ] operations = [ migrations.CreateModel( - name='BadgeClassTag', + name="BadgeClassTag", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(db_index=True, max_length=254)), - ('badgeclass', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeClass')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(db_index=True, max_length=254)), + ( + "badgeclass", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeClass", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/apps/issuer/migrations/0033_auto_20171002_1413.py b/apps/issuer/migrations/0033_auto_20171002_1413.py index 6535bc616..ad245d330 100644 --- a/apps/issuer/migrations/0033_auto_20171002_1413.py +++ b/apps/issuer/migrations/0033_auto_20171002_1413.py @@ -6,15 +6,23 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0032_badgeclasstag'), + ("issuer", "0032_badgeclasstag"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='recipient_type', - field=models.CharField(choices=[('email', 'email'), ('openBadgeId', 'openBadgeId'), ('telephone', 'telephone'), ('url', 'url')], default='email', max_length=255), + model_name="badgeinstance", + name="recipient_type", + field=models.CharField( + choices=[ + ("email", "email"), + ("openBadgeId", "openBadgeId"), + ("telephone", "telephone"), + ("url", "url"), + ], + default="email", + max_length=255, + ), ), ] diff --git a/apps/issuer/migrations/0034_auto_20171025_1020.py b/apps/issuer/migrations/0034_auto_20171025_1020.py index 2f3e8e335..d86840e57 100644 --- a/apps/issuer/migrations/0034_auto_20171025_1020.py +++ b/apps/issuer/migrations/0034_auto_20171025_1020.py @@ -8,41 +8,58 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('issuer', '0033_auto_20171002_1413'), + ("issuer", "0033_auto_20171002_1413"), ] operations = [ migrations.AddField( - model_name='badgeclass', - name='updated_at', + model_name="badgeclass", + name="updated_at", field=models.DateTimeField(auto_now=True), ), migrations.AddField( - model_name='badgeclass', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="badgeclass", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='badgeinstance', - name='updated_at', + model_name="badgeinstance", + name="updated_at", field=models.DateTimeField(auto_now=True), ), migrations.AddField( - model_name='badgeinstance', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="badgeinstance", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='issuer', - name='updated_at', + model_name="issuer", + name="updated_at", field=models.DateTimeField(auto_now=True), ), migrations.AddField( - model_name='issuer', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="issuer", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/apps/issuer/migrations/0034_badgeinstance_hashed.py b/apps/issuer/migrations/0034_badgeinstance_hashed.py index 0bf4ae897..15a9ed16f 100644 --- a/apps/issuer/migrations/0034_badgeinstance_hashed.py +++ b/apps/issuer/migrations/0034_badgeinstance_hashed.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0033_auto_20171002_1413'), + ("issuer", "0033_auto_20171002_1413"), ] operations = [ migrations.AddField( - model_name='badgeinstance', - name='hashed', + model_name="badgeinstance", + name="hashed", field=models.BooleanField(default=True), ), ] diff --git a/apps/issuer/migrations/0035_issuer_badgrapp.py b/apps/issuer/migrations/0035_issuer_badgrapp.py index 3c093d34a..218c3c564 100644 --- a/apps/issuer/migrations/0035_issuer_badgrapp.py +++ b/apps/issuer/migrations/0035_issuer_badgrapp.py @@ -7,16 +7,21 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0012_badgrapp_public_pages_redirect'), - ('issuer', '0034_auto_20171025_1020'), + ("mainsite", "0012_badgrapp_public_pages_redirect"), + ("issuer", "0034_auto_20171025_1020"), ] operations = [ migrations.AddField( - model_name='issuer', - name='badgrapp', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='mainsite.BadgrApp'), + model_name="issuer", + name="badgrapp", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="mainsite.BadgrApp", + ), ), ] diff --git a/apps/issuer/migrations/0036_merge_20171101_0815.py b/apps/issuer/migrations/0036_merge_20171101_0815.py index 8bed54dfe..b8e106185 100644 --- a/apps/issuer/migrations/0036_merge_20171101_0815.py +++ b/apps/issuer/migrations/0036_merge_20171101_0815.py @@ -6,11 +6,9 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0034_badgeinstance_hashed'), - ('issuer', '0035_issuer_badgrapp'), + ("issuer", "0034_badgeinstance_hashed"), + ("issuer", "0035_issuer_badgrapp"), ] - operations = [ - ] + operations = [] diff --git a/apps/issuer/migrations/0037_auto_20171113_1015.py b/apps/issuer/migrations/0037_auto_20171113_1015.py index 1056851a9..cbe570fdc 100644 --- a/apps/issuer/migrations/0037_auto_20171113_1015.py +++ b/apps/issuer/migrations/0037_auto_20171113_1015.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0036_merge_20171101_0815'), + ("issuer", "0036_merge_20171101_0815"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='recipient_identifier', + model_name="badgeinstance", + name="recipient_identifier", field=models.EmailField(db_index=True, max_length=1024), ), ] diff --git a/apps/issuer/migrations/0037_auto_20171113_1015_squashed_0081_auto_20250903_2334.py b/apps/issuer/migrations/0037_auto_20171113_1015_squashed_0081_auto_20250903_2334.py new file mode 100644 index 000000000..a95f50c57 --- /dev/null +++ b/apps/issuer/migrations/0037_auto_20171113_1015_squashed_0081_auto_20250903_2334.py @@ -0,0 +1,1357 @@ +# Generated by Django 3.2 on 2025-09-04 06:57 + +from django.conf import settings +from django.db import migrations, models +from django.core import management +import django.db.migrations.operations.special +import django.db.models.deletion +import django.utils.timezone +import issuer.models +import issuer.utils +import jsonfield.fields +import mainsite.mixins + + +# Functions from the following migrations need manual copying. +# Move them and any dependencies into this file, then update the +# RunPython operations to refer to the local versions: +# issuer.migrations.0048_auto_20190812_1229 +# issuer.migrations.0074_badgeinstance_ob_json_2_0 +# issuer.migrations.0078_learningpath_required_badges_count_squashed_0079_learningpath_activated + + +def nullify_blanks(apps, schema_editor): + Issuer = apps.get_model("issuer", "Issuer") + Issuer.objects.filter(source_url="").update(source_url=None) + + +def badgeinstance_generate_ob2_json(apps, schema): + # try to populate during migration + try: + print("\n running command issuer:populate_badgeinstance_ob_json_2_0...") + management.call_command("populate_badgeinstance_ob_json_2_0") + except Exception: + pass + + +def set_required_badges_count(apps, schema_editor): + for lp in issuer.models.LearningPath.objects.all(): + badge_count = issuer.models.LearningPathBadge.objects.filter( + learning_path=lp + ).count() + lp.required_badges_count = badge_count + lp.save() + + +class Migration(migrations.Migration): + replaces = [ + ("issuer", "0037_auto_20171113_1015"), + ("issuer", "0038_badgeinstance_expires_at"), + ("issuer", "0039_badgeclassextension"), + ("issuer", "0040_badgeinstanceextension_issuerextension"), + ("issuer", "0041_badgeinstancebakedimage"), + ("issuer", "0042_auto_20180220_1150"), + ("issuer", "0043_auto_20180614_0949"), + ("issuer", "0044_auto_20180713_0658"), + ("issuer", "0045_auto_20181104_1847"), + ("issuer", "0046_auto_20181114_1517"), + ("issuer", "0047_badgeinstance_user"), + ("issuer", "0048_auto_20190812_1229"), + ("issuer", "0049_auto_20190812_1232"), + ("issuer", "0050_auto_20190813_1945"), + ("issuer", "0051_auto_20190826_1604"), + ("issuer", "0052_auto_20200106_1621"), + ("issuer", "0053_auto_20200608_0452"), + ("issuer", "0054_auto_20200724_1317"), + ("issuer", "0055_auto_20200817_1538"), + ("issuer", "0056_auto_20200817_1352"), + ("issuer", "0057_add_image_preview"), + ("issuer", "0058_auto_20210302_1103"), + ("issuer", "0059_remove_issuer_image_preview"), + ("issuer", "0060_issuer_verified"), + ("issuer", "0061_auto_20211118_0923"), + ("issuer", "0062_issuer_category"), + ("issuer", "0063_auto_20211122_0622"), + ("issuer", "0064_auto_20211122_0929"), + ("issuer", "0065_badgeclass_imageframe"), + ("issuer", "0066_qrcode_requestedbadge"), + ( + "issuer", + "0067_learningpath_learningpathbadge_learningpathparticipant_learningpathtag_requestedlearningpath", + ), + ("issuer", "0068_issuer_intendeduseverified"), + ("issuer", "0069_delete_learningpathparticipant"), + ("issuer", "0070_badgeclass_copy_permissions"), + ("issuer", "0071_qrcode_notifications"), + ("issuer", "0071_issuerstaffrequest"), + ("issuer", "0072_merge_20250407_1526"), + ("issuer", "0073_auto_20250408_1502"), + ("issuer", "0074_badgeinstance_ob_json_2_0"), + ("issuer", "0075_importedbadgeassertion_importedbadgeassertionextension"), + ("issuer", "0076_badgeclass_criteria"), + ("issuer", "0077_auto_20250513_1031"), + ( + "issuer", + "0078_learningpath_required_badges_count_squashed_0079_learningpath_activated", + ), + ("issuer", "0079_alter_learningpath_activated"), + ("issuer", "0080_auto_20250824_2314"), + ("issuer", "0081_auto_20250903_2334"), + ] + + dependencies = [ + ("mainsite", "0024_auto_20200608_0452"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("mainsite", "0026_iframeurl"), + ("issuer", "0036_merge_20171101_0815"), + ] + + operations = [ + migrations.AlterField( + model_name="badgeinstance", + name="recipient_identifier", + field=models.EmailField(db_index=True, max_length=320), + ), + migrations.AddField( + model_name="badgeinstance", + name="expires_at", + field=models.DateTimeField(blank=True, default=None, null=True), + ), + migrations.CreateModel( + name="BadgeClassExtension", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "badgeclass", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.badgeclass", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="BadgeInstanceExtension", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "badgeinstance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.badgeinstance", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="IssuerExtension", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.issuer" + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="BadgeInstanceBakedImage", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("obi_version", models.CharField(max_length=254)), + ( + "image", + models.FileField( + blank=True, + upload_to=issuer.models._baked_badge_instance_filename_generator, + ), + ), + ( + "badgeinstance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.badgeinstance", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AlterField( + model_name="badgeinstanceevidence", + name="evidence_url", + field=models.CharField( + blank=True, default=None, max_length=2083, null=True + ), + ), + migrations.AlterField( + model_name="badgeinstance", + name="recipient_identifier", + field=models.EmailField(db_index=True, max_length=320), + ), + migrations.AlterIndexTogether( + name="badgeinstance", + index_together={("recipient_identifier", "badgeclass", "revoked")}, + ), + migrations.AlterField( + model_name="badgeinstance", + name="recipient_identifier", + field=models.EmailField(db_index=True, max_length=320), + ), + migrations.AddField( + model_name="badgeclass", + name="expires_amount", + field=models.IntegerField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name="badgeclass", + name="expires_duration", + field=models.CharField( + blank=True, + choices=[ + ("days", "Days"), + ("weeks", "Weeks"), + ("months", "Months"), + ("years", "Years"), + ], + default=None, + max_length=254, + null=True, + ), + ), + migrations.AlterField( + model_name="badgeclass", + name="created_at", + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + migrations.AlterField( + model_name="badgeinstance", + name="created_at", + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + migrations.AlterField( + model_name="issuer", + name="created_at", + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + migrations.AddField( + model_name="badgeinstance", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.RunPython( + code=nullify_blanks, + ), + migrations.AlterField( + model_name="issuer", + name="source_url", + field=models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), + ), + migrations.AlterField( + model_name="badgeclass", + name="source_url", + field=models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), + ), + migrations.AlterField( + model_name="badgeinstance", + name="source_url", + field=models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), + ), + migrations.AlterField( + model_name="badgeinstance", + name="recipient_identifier", + field=models.CharField(db_index=True, max_length=320), + ), + migrations.AlterField( + model_name="badgeclass", + name="updated_at", + field=models.DateTimeField(auto_now=True, db_index=True), + ), + migrations.AlterField( + model_name="badgeinstance", + name="updated_at", + field=models.DateTimeField(auto_now=True, db_index=True), + ), + migrations.AlterField( + model_name="issuer", + name="updated_at", + field=models.DateTimeField(auto_now=True, db_index=True), + ), + migrations.AlterField( + model_name="badgeclass", + name="image", + field=models.FileField(blank=True, upload_to="uploads/badges"), + ), + migrations.AlterField( + model_name="badgeinstance", + name="acceptance", + field=models.CharField( + choices=[ + ("Unaccepted", "Unaccepted"), + ("Accepted", "Accepted"), + ("Rejected", "Rejected"), + ], + default="Unaccepted", + max_length=254, + ), + ), + migrations.AlterField( + model_name="badgeinstance", + name="image", + field=models.FileField(blank=True, upload_to="uploads/badges"), + ), + migrations.AlterField( + model_name="issuer", + name="badgrapp", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="mainsite.badgrapp", + ), + ), + migrations.AlterField( + model_name="issuer", + name="image", + field=models.FileField(blank=True, null=True, upload_to="uploads/issuers"), + ), + migrations.AlterField( + model_name="badgeinstance", + name="revoked", + field=models.BooleanField(db_index=True, default=False), + ), + migrations.AlterField( + model_name="badgeclass", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="badgeclass", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="badgeinstance", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="badgeinstance", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="issuer", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="issuer", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="badgeclass", + name="image_hash", + field=models.CharField(blank=True, default="", max_length=72), + ), + migrations.AddField( + model_name="badgeclass", + name="image_preview", + field=models.FileField(blank=True, null=True, upload_to="uploads/badges"), + ), + migrations.AlterField( + model_name="badgeclass", + name="slug", + field=models.CharField( + blank=True, db_index=True, default=None, max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="badgeinstance", + name="slug", + field=models.CharField( + blank=True, db_index=True, default=None, max_length=255, null=True + ), + ), + migrations.AlterField( + model_name="issuer", + name="slug", + field=models.CharField( + blank=True, db_index=True, default=None, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="issuer", + name="verified", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="issuer", + name="category", + field=models.CharField(default="n/a", max_length=255), + ), + migrations.AddField( + model_name="issuer", + name="city", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuer", + name="country", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuer", + name="street", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuer", + name="streetnumber", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuer", + name="zip", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="issuer", + name="lat", + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name="issuer", + name="lon", + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name="badgeclass", + name="imageFrame", + field=models.BooleanField(default=True), + ), + migrations.CreateModel( + name="QrCode", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("title", models.CharField(max_length=254)), + ("createdBy", models.CharField(max_length=254)), + ( + "valid_from", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "expires_at", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "badgeclass", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="qrcodes", + to="issuer.badgeclass", + ), + ), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.issuer" + ), + ), + ("notifications", models.BooleanField(default=False)), + ( + "created_by_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RequestedBadge", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("firstName", models.CharField(max_length=254)), + ("lastName", models.CharField(max_length=254)), + ("email", models.CharField(blank=True, max_length=254, null=True)), + ( + "requestedOn", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("status", models.CharField(default="Pending", max_length=254)), + ( + "badgeclass", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="requestedbadges", + to="issuer.badgeclass", + ), + ), + ( + "qrcode", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="requestedbadges", + to="issuer.qrcode", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="LearningPath", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True, db_index=True)), + ("name", models.CharField(max_length=254)), + ("description", models.TextField(blank=True, default=None, null=True)), + ( + "slug", + models.CharField( + blank=True, + db_index=True, + default=None, + max_length=255, + null=True, + ), + ), + ( + "badgrapp", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="mainsite.badgrapp", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="learningpaths", + to="issuer.issuer", + ), + ), + ( + "participationBadge", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.badgeclass", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RequestedLearningPath", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ( + "requestedOn", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("status", models.CharField(default="Pending", max_length=254)), + ( + "learningpath", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="requested_learningpath", + to="issuer.learningpath", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="LearningPathTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(db_index=True, max_length=254)), + ( + "learningPath", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.learningpath", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="LearningPathBadge", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("order", models.PositiveIntegerField()), + ( + "badge", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.badgeclass", + ), + ), + ( + "learning_path", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.learningpath", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="issuer", + name="intendedUseVerified", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="badgeclass", + name="copy_permissions", + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.CreateModel( + name="IssuerStaffRequest", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ( + "requestedOn", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "status", + models.CharField( + choices=[ + ("Pending", "Pending"), + ("Approved", "Approved"), + ("Rejected", "Rejected"), + ("Revoked", "Revoked"), + ], + default="Pending", + max_length=254, + ), + ), + ("revoked", models.BooleanField(db_index=True, default=False)), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="staffrequests", + to="issuer.issuer", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="badgeinstance", + name="ob_json_2_0", + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.RunPython( + code=badgeinstance_generate_ob2_json, + reverse_code=django.db.migrations.operations.special.RunPython.noop, + ), + migrations.CreateModel( + name="ImportedBadgeAssertion", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True, db_index=True)), + ("source", models.CharField(default="local", max_length=254)), + ( + "source_url", + models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), + ), + ("badge_name", models.CharField(max_length=255)), + ("badge_description", models.TextField(blank=True, null=True)), + ("badge_criteria_url", models.URLField(blank=True, null=True)), + ("badge_image_url", models.URLField(blank=True, null=True)), + ("image", models.FileField(blank=True, upload_to="uploads/badges")), + ("issuer_name", models.CharField(max_length=255)), + ("issuer_url", models.URLField()), + ( + "issuer_email", + models.EmailField(blank=True, max_length=254, null=True), + ), + ("issuer_image_url", models.URLField(blank=True, null=True)), + ("issued_on", models.DateTimeField()), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ( + "recipient_identifier", + models.CharField(db_index=True, max_length=320), + ), + ( + "recipient_type", + models.CharField( + choices=[ + ("email", "email"), + ("openBadgeId", "openBadgeId"), + ("telephone", "telephone"), + ("url", "url"), + ], + default="email", + max_length=255, + ), + ), + ( + "acceptance", + models.CharField( + choices=[ + ("Unaccepted", "Unaccepted"), + ("Accepted", "Accepted"), + ("Rejected", "Rejected"), + ], + default="Accepted", + max_length=254, + ), + ), + ("revoked", models.BooleanField(default=False)), + ( + "revocation_reason", + models.CharField(blank=True, max_length=255, null=True), + ), + ("original_json", jsonfield.fields.JSONField()), + ("hashed", models.BooleanField(default=True)), + ( + "salt", + models.CharField( + blank=True, default=None, max_length=254, null=True + ), + ), + ("narrative", models.TextField(blank=True, null=True)), + ("verification_url", models.URLField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Imported Badge Assertion", + }, + ), + migrations.CreateModel( + name="ImportedBadgeAssertionExtension", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "importedBadge", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.importedbadgeassertion", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="badgeclass", + name="criteria", + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name="badgeinstance", + name="ob_json_3_0", + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name="issuer", + name="private_key", + field=models.CharField( + blank=True, + default=issuer.utils.generate_private_key_pem, + max_length=512, + null=True, + ), + ), + migrations.AddField( + model_name="learningpath", + name="required_badges_count", + field=models.PositiveIntegerField(default=3), + preserve_default=False, + ), + migrations.AddField( + model_name="learningpath", + name="activated", + field=models.BooleanField(default=True), + ), + migrations.RunPython( + code=set_required_badges_count, + ), + migrations.AlterField( + model_name="learningpath", + name="activated", + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name="Network", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True, db_index=True)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ("source", models.CharField(default="local", max_length=254)), + ( + "source_url", + models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), + ), + ( + "slug", + models.CharField( + blank=True, + db_index=True, + default=None, + max_length=255, + null=True, + ), + ), + ("name", models.CharField(max_length=1024)), + ( + "image", + models.FileField( + blank=True, null=True, upload_to="uploads/issuers" + ), + ), + ("description", models.TextField(blank=True, default=None, null=True)), + ( + "url", + models.CharField( + blank=True, default=None, max_length=254, null=True + ), + ), + ("country", models.CharField(blank=True, max_length=254, null=True)), + ( + "private_key", + models.CharField( + blank=True, + default=issuer.utils.generate_private_key_pem, + max_length=512, + null=True, + ), + ), + ("state", models.CharField(blank=True, max_length=254, null=True)), + ( + "badgrapp", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="mainsite.badgrapp", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + mainsite.mixins.ResizeUploadedImage, + mainsite.mixins.ScrubUploadedSvgImage, + mainsite.mixins.PngImagePreview, + models.Model, + ), + ), + migrations.AlterField( + model_name="issuer", + name="country", + field=models.CharField(blank=True, max_length=254, null=True), + ), + migrations.CreateModel( + name="NetworkStaff", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("owner", "Owner"), + ("editor", "Editor"), + ("staff", "Staff"), + ], + default="staff", + max_length=254, + ), + ), + ( + "network", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.network" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("network", "user")}, + }, + ), + migrations.CreateModel( + name="NetworkInvite", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("invitedOn", models.DateTimeField(default=django.utils.timezone.now)), + ( + "acceptedOn", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "status", + models.CharField( + choices=[ + ("Pending", "Pending"), + ("Approved", "Approved"), + ("Rejected", "Rejected"), + ("Revoked", "Revoked"), + ], + default="Pending", + max_length=254, + ), + ), + ("revoked", models.BooleanField(db_index=True, default=False)), + ( + "issuer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="issuer.issuer", + ), + ), + ( + "network", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invites", + to="issuer.network", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="NetworkExtension", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "network", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.network" + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="network", + name="partner_issuers", + field=models.ManyToManyField( + blank=True, related_name="networks", to="issuer.Issuer" + ), + ), + migrations.AddField( + model_name="network", + name="staff", + field=models.ManyToManyField( + through="issuer.NetworkStaff", to=settings.AUTH_USER_MODEL + ), + ), + migrations.AddField( + model_name="network", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="badgeinstance", + name="recipient_identifier", + field=models.CharField(db_index=True, max_length=320), + ), + migrations.AlterField( + model_name="importedbadgeassertion", + name="recipient_identifier", + field=models.CharField(db_index=True, max_length=320), + ), + ] diff --git a/apps/issuer/migrations/0038_badgeinstance_expires_at.py b/apps/issuer/migrations/0038_badgeinstance_expires_at.py index f90984f7b..48b4f4173 100644 --- a/apps/issuer/migrations/0038_badgeinstance_expires_at.py +++ b/apps/issuer/migrations/0038_badgeinstance_expires_at.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0037_auto_20171113_1015'), + ("issuer", "0037_auto_20171113_1015"), ] operations = [ migrations.AddField( - model_name='badgeinstance', - name='expires_at', + model_name="badgeinstance", + name="expires_at", field=models.DateTimeField(blank=True, default=None, null=True), ), ] diff --git a/apps/issuer/migrations/0038_issuer_linkedinid.py b/apps/issuer/migrations/0038_issuer_linkedinid.py new file mode 100644 index 000000000..e2498b10b --- /dev/null +++ b/apps/issuer/migrations/0038_issuer_linkedinid.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2025-09-15 12:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0037_auto_20171113_1015_squashed_0081_auto_20250903_2334'), + ] + + operations = [ + migrations.AddField( + model_name='issuer', + name='linkedinId', + field=models.CharField(default=None, max_length=255), + ), + ] diff --git a/apps/issuer/migrations/0039_auto_20250923_2250.py b/apps/issuer/migrations/0039_auto_20250923_2250.py new file mode 100644 index 000000000..7682390b3 --- /dev/null +++ b/apps/issuer/migrations/0039_auto_20250923_2250.py @@ -0,0 +1,84 @@ +# Generated by Django 3.2 on 2025-09-24 05:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0038_issuer_linkedinid'), + ] + + operations = [ + migrations.CreateModel( + name='NetworkMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.RemoveField( + model_name='networkextension', + name='network', + ), + migrations.AlterUniqueTogether( + name='networkstaff', + unique_together=None, + ), + migrations.RemoveField( + model_name='networkstaff', + name='network', + ), + migrations.RemoveField( + model_name='networkstaff', + name='user', + ), + migrations.AddField( + model_name='issuer', + name='is_network', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='issuer', + name='state', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='issuer', + name='country', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='networkinvite', + name='network', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='issuer.issuer'), + ), + migrations.DeleteModel( + name='Network', + ), + migrations.DeleteModel( + name='NetworkExtension', + ), + migrations.DeleteModel( + name='NetworkStaff', + ), + migrations.AddField( + model_name='networkmembership', + name='issuer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='network_memberships', to='issuer.issuer'), + ), + migrations.AddField( + model_name='networkmembership', + name='network', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='issuer.issuer'), + ), + migrations.AddField( + model_name='issuer', + name='networks', + field=models.ManyToManyField(related_name='_issuer_issuer_networks_+', through='issuer.NetworkMembership', to='issuer.Issuer'), + ), + migrations.AlterUniqueTogether( + name='networkmembership', + unique_together={('network', 'issuer')}, + ), + ] diff --git a/apps/issuer/migrations/0039_badgeclassextension.py b/apps/issuer/migrations/0039_badgeclassextension.py index b60a42330..8c2aa6a0e 100644 --- a/apps/issuer/migrations/0039_badgeclassextension.py +++ b/apps/issuer/migrations/0039_badgeclassextension.py @@ -7,22 +7,38 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0038_badgeinstance_expires_at'), + ("issuer", "0038_badgeinstance_expires_at"), ] operations = [ migrations.CreateModel( - name='BadgeClassExtension', + name="BadgeClassExtension", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=254)), - ('original_json', models.TextField(blank=True, default=None, null=True)), - ('badgeclass', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeClass')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "badgeclass", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeClass", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/apps/issuer/migrations/0040_badgeclassnetworkshare.py b/apps/issuer/migrations/0040_badgeclassnetworkshare.py new file mode 100644 index 000000000..e2e92e483 --- /dev/null +++ b/apps/issuer/migrations/0040_badgeclassnetworkshare.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2 on 2025-09-25 14:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('issuer', '0039_auto_20250923_2250'), + ] + + operations = [ + migrations.CreateModel( + name='BadgeClassNetworkShare', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('shared_at', models.DateTimeField(auto_now_add=True)), + ('is_active', models.BooleanField(default=True)), + ('badgeclass', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='network_shares', to='issuer.badgeclass')), + ('network', models.ForeignKey(limit_choices_to={'is_network': True}, on_delete=django.db.models.deletion.CASCADE, related_name='shared_badges', to='issuer.issuer')), + ('shared_by_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='badge_shares', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Badge Class Network Share', + 'verbose_name_plural': 'Badge Class Network Shares', + 'unique_together': {('badgeclass', 'network')}, + }, + ), + ] diff --git a/apps/issuer/migrations/0040_badgeinstanceextension_issuerextension.py b/apps/issuer/migrations/0040_badgeinstanceextension_issuerextension.py index f858ff134..6d0240f17 100644 --- a/apps/issuer/migrations/0040_badgeinstanceextension_issuerextension.py +++ b/apps/issuer/migrations/0040_badgeinstanceextension_issuerextension.py @@ -7,34 +7,66 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0039_badgeclassextension'), + ("issuer", "0039_badgeclassextension"), ] operations = [ migrations.CreateModel( - name='BadgeInstanceExtension', + name="BadgeInstanceExtension", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=254)), - ('original_json', models.TextField(blank=True, default=None, null=True)), - ('badgeinstance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeInstance')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "badgeinstance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeInstance", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), migrations.CreateModel( - name='IssuerExtension', + name="IssuerExtension", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=254)), - ('original_json', models.TextField(blank=True, default=None, null=True)), - ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.Issuer')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.Issuer" + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/apps/issuer/migrations/0041_badgeclassnetworkshare_shared_by_issuer.py b/apps/issuer/migrations/0041_badgeclassnetworkshare_shared_by_issuer.py new file mode 100644 index 000000000..e2ccc9d3d --- /dev/null +++ b/apps/issuer/migrations/0041_badgeclassnetworkshare_shared_by_issuer.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2 on 2025-10-06 08:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0040_badgeclassnetworkshare'), + ] + + operations = [ + migrations.AddField( + model_name='badgeclassnetworkshare', + name='shared_by_issuer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='network_shares_created', to='issuer.issuer'), + ), + ] diff --git a/apps/issuer/migrations/0041_badgeinstancebakedimage.py b/apps/issuer/migrations/0041_badgeinstancebakedimage.py index 17db0e737..cf02953dc 100644 --- a/apps/issuer/migrations/0041_badgeinstancebakedimage.py +++ b/apps/issuer/migrations/0041_badgeinstancebakedimage.py @@ -8,22 +8,41 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0040_badgeinstanceextension_issuerextension'), + ("issuer", "0040_badgeinstanceextension_issuerextension"), ] operations = [ migrations.CreateModel( - name='BadgeInstanceBakedImage', + name="BadgeInstanceBakedImage", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('obi_version', models.CharField(max_length=254)), - ('image', models.FileField(blank=True, upload_to=issuer.models._baked_badge_instance_filename_generator)), - ('badgeinstance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='issuer.BadgeInstance')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("obi_version", models.CharField(max_length=254)), + ( + "image", + models.FileField( + blank=True, + upload_to=issuer.models._baked_badge_instance_filename_generator, + ), + ), + ( + "badgeinstance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.BadgeInstance", + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/apps/issuer/migrations/0042_auto_20180220_1150.py b/apps/issuer/migrations/0042_auto_20180220_1150.py index cb45bc5b9..ac8170254 100644 --- a/apps/issuer/migrations/0042_auto_20180220_1150.py +++ b/apps/issuer/migrations/0042_auto_20180220_1150.py @@ -6,15 +6,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0041_badgeinstancebakedimage'), + ("issuer", "0041_badgeinstancebakedimage"), ] operations = [ migrations.AlterField( - model_name='badgeinstanceevidence', - name='evidence_url', - field=models.CharField(blank=True, default=None, max_length=2083, null=True), + model_name="badgeinstanceevidence", + name="evidence_url", + field=models.CharField( + blank=True, default=None, max_length=2083, null=True + ), ), ] diff --git a/apps/issuer/migrations/0042_auto_20251020_0806.py b/apps/issuer/migrations/0042_auto_20251020_0806.py new file mode 100644 index 000000000..cbab645a9 --- /dev/null +++ b/apps/issuer/migrations/0042_auto_20251020_0806.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2025-10-20 15:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0041_badgeclassnetworkshare_shared_by_issuer'), + ] + + operations = [ + migrations.AddField( + model_name='badgeinstance', + name='activity_end_date', + field=models.DateTimeField(blank=True, default=None, help_text='The datetime the activity/course ended', null=True), + ), + migrations.AddField( + model_name='badgeinstance', + name='activity_start_date', + field=models.DateTimeField(blank=True, default=None, help_text='The datetime the activity/course started', null=True), + ), + ] diff --git a/apps/issuer/migrations/0043_auto_20180614_0949.py b/apps/issuer/migrations/0043_auto_20180614_0949.py index 08f44b7ca..0337cf57f 100644 --- a/apps/issuer/migrations/0043_auto_20180614_0949.py +++ b/apps/issuer/migrations/0043_auto_20180614_0949.py @@ -6,19 +6,18 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0042_auto_20180220_1150'), + ("issuer", "0042_auto_20180220_1150"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='recipient_identifier', + model_name="badgeinstance", + name="recipient_identifier", field=models.EmailField(db_index=True, max_length=768), ), migrations.AlterIndexTogether( - name='badgeinstance', - index_together=set([('recipient_identifier', 'badgeclass', 'revoked')]), + name="badgeinstance", + index_together=set([("recipient_identifier", "badgeclass", "revoked")]), ), ] diff --git a/apps/issuer/migrations/0043_auto_20251021_0230.py b/apps/issuer/migrations/0043_auto_20251021_0230.py new file mode 100644 index 000000000..382186e95 --- /dev/null +++ b/apps/issuer/migrations/0043_auto_20251021_0230.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2025-10-21 09:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0042_auto_20251020_0806'), + ] + + operations = [ + migrations.AddField( + model_name='qrcode', + name='activity_end_date', + field=models.DateTimeField(blank=True, default=None, help_text='The datetime the activity/course ended', null=True), + ), + migrations.AddField( + model_name='qrcode', + name='activity_start_date', + field=models.DateTimeField(blank=True, default=None, help_text='The datetime the activity/course started', null=True), + ), + ] diff --git a/apps/issuer/migrations/0044_auto_20180713_0658.py b/apps/issuer/migrations/0044_auto_20180713_0658.py index 9040b4a85..18cfdd217 100644 --- a/apps/issuer/migrations/0044_auto_20180713_0658.py +++ b/apps/issuer/migrations/0044_auto_20180713_0658.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0043_auto_20180614_0949'), + ("issuer", "0043_auto_20180614_0949"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='recipient_identifier', + model_name="badgeinstance", + name="recipient_identifier", field=models.EmailField(db_index=True, max_length=768), ), ] diff --git a/apps/issuer/migrations/0044_auto_20251107_0310.py b/apps/issuer/migrations/0044_auto_20251107_0310.py new file mode 100644 index 000000000..05d79ac6a --- /dev/null +++ b/apps/issuer/migrations/0044_auto_20251107_0310.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2 on 2025-11-07 11:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0043_auto_20251021_0230'), + ] + + operations = [ + migrations.AddField( + model_name='badgeinstance', + name='activity_city', + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + migrations.AddField( + model_name='badgeinstance', + name='activity_online', + field=models.BooleanField(blank=True, default=False), + ), + migrations.AddField( + model_name='badgeinstance', + name='activity_zip', + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + migrations.AddField( + model_name='qrcode', + name='activity_city', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='qrcode', + name='activity_online', + field=models.BooleanField(blank=True, default=False), + ), + migrations.AddField( + model_name='qrcode', + name='activity_zip', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/issuer/migrations/0045_auto_20181104_1847.py b/apps/issuer/migrations/0045_auto_20181104_1847.py index 81fe09f8b..999768fea 100644 --- a/apps/issuer/migrations/0045_auto_20181104_1847.py +++ b/apps/issuer/migrations/0045_auto_20181104_1847.py @@ -6,20 +6,30 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0044_auto_20180713_0658'), + ("issuer", "0044_auto_20180713_0658"), ] operations = [ migrations.AddField( - model_name='badgeclass', - name='expires_amount', + model_name="badgeclass", + name="expires_amount", field=models.IntegerField(blank=True, default=None, null=True), ), migrations.AddField( - model_name='badgeclass', - name='expires_duration', - field=models.CharField(blank=True, choices=[('days', 'Days'), ('weeks', 'Weeks'), ('months', 'Months'), ('years', 'Years')], default=None, max_length=254, null=True), + model_name="badgeclass", + name="expires_duration", + field=models.CharField( + blank=True, + choices=[ + ("days", "Days"), + ("weeks", "Weeks"), + ("months", "Months"), + ("years", "Years"), + ], + default=None, + max_length=254, + null=True, + ), ), ] diff --git a/apps/issuer/migrations/0045_auto_20251112_0903.py b/apps/issuer/migrations/0045_auto_20251112_0903.py new file mode 100644 index 000000000..5b53e46bc --- /dev/null +++ b/apps/issuer/migrations/0045_auto_20251112_0903.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2 on 2025-11-12 17:03 + +from django.db import migrations, models + + +def migrate_expiration(apps, schema_editor): + BadgeClass = apps.get_model("issuer", "BadgeClass") + duration_map = { + "days": 1, + "weeks": 7, + "months": 30, + "years": 365, + } + + for badge in BadgeClass.objects.all(): + if badge.expires_amount and badge.expires_duration: + multiplier = duration_map.get(badge.expires_duration, 0) + badge.expiration = badge.expires_amount * multiplier + badge.save() + + +def reverse_migrate_expiration(apps, schema_editor): + BadgeClass = apps.get_model("issuer", "BadgeClass") + + for badge in BadgeClass.objects.exclude(expiration__isnull=True): + badge.expires_amount = badge.expiration + badge.expires_duration = "days" + badge.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0044_auto_20251107_0310"), + ] + + operations = [ + migrations.AddField( + model_name="badgeclass", + name="expiration", + field=models.IntegerField( + blank=True, + default=None, + help_text="Number of days the badge is valid after being issued.", + null=True, + ), + ), + migrations.RunPython(migrate_expiration), + migrations.RemoveField( + model_name="badgeclass", + name="expires_amount", + ), + migrations.RemoveField( + model_name="badgeclass", + name="expires_duration", + ), + ] diff --git a/apps/issuer/migrations/0046_alter_badgeclass_expiration.py b/apps/issuer/migrations/0046_alter_badgeclass_expiration.py new file mode 100644 index 000000000..d4289187b --- /dev/null +++ b/apps/issuer/migrations/0046_alter_badgeclass_expiration.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2 on 2025-11-14 07:33 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0045_auto_20251112_0903'), + ] + + operations = [ + migrations.AlterField( + model_name='badgeclass', + name='expiration', + field=models.IntegerField(blank=True, default=None, help_text='Number of days the badge is valid after being issued.', null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(36500)]), + ), + ] diff --git a/apps/issuer/migrations/0046_auto_20181114_1517.py b/apps/issuer/migrations/0046_auto_20181114_1517.py index df01a6a55..ea4ab7224 100644 --- a/apps/issuer/migrations/0046_auto_20181114_1517.py +++ b/apps/issuer/migrations/0046_auto_20181114_1517.py @@ -6,25 +6,24 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0045_auto_20181104_1847'), + ("issuer", "0045_auto_20181104_1847"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='created_at', + model_name="badgeclass", + name="created_at", field=models.DateTimeField(auto_now_add=True, db_index=True), ), migrations.AlterField( - model_name='badgeinstance', - name='created_at', + model_name="badgeinstance", + name="created_at", field=models.DateTimeField(auto_now_add=True, db_index=True), ), migrations.AlterField( - model_name='issuer', - name='created_at', + model_name="issuer", + name="created_at", field=models.DateTimeField(auto_now_add=True, db_index=True), ), ] diff --git a/apps/issuer/migrations/0047_badgeinstance_user.py b/apps/issuer/migrations/0047_badgeinstance_user.py index 9a42c8f61..d4f92bbf5 100644 --- a/apps/issuer/migrations/0047_badgeinstance_user.py +++ b/apps/issuer/migrations/0047_badgeinstance_user.py @@ -4,20 +4,23 @@ from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('issuer', '0046_auto_20181114_1517'), + ("issuer", "0046_auto_20181114_1517"), ] operations = [ migrations.AddField( - model_name='badgeinstance', - name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=models.SET_NULL, to=settings.AUTH_USER_MODEL), + model_name="badgeinstance", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/apps/issuer/migrations/0047_rename_badgeinstance_recipient_identifier_badgeclass_revoked_issuer_badg_recipie_6a2cd8_idx_and_more.py b/apps/issuer/migrations/0047_rename_badgeinstance_recipient_identifier_badgeclass_revoked_issuer_badg_recipie_6a2cd8_idx_and_more.py new file mode 100644 index 000000000..2f7731a95 --- /dev/null +++ b/apps/issuer/migrations/0047_rename_badgeinstance_recipient_identifier_badgeclass_revoked_issuer_badg_recipie_6a2cd8_idx_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.8 on 2025-11-20 12:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0046_alter_badgeclass_expiration'), + ] + + operations = [ + migrations.RenameIndex( + model_name='badgeinstance', + new_name='issuer_badg_recipie_6a2cd8_idx', + old_fields=('recipient_identifier', 'badgeclass', 'revoked'), + ), + migrations.AlterField( + model_name='issuer', + name='networks', + field=models.ManyToManyField(related_name='partner_issuers', through='issuer.NetworkMembership', to='issuer.issuer'), + ), + ] diff --git a/apps/issuer/migrations/0048_auto_20190812_1229.py b/apps/issuer/migrations/0048_auto_20190812_1229.py index 8a11cd423..50c24a837 100644 --- a/apps/issuer/migrations/0048_auto_20190812_1229.py +++ b/apps/issuer/migrations/0048_auto_20190812_1229.py @@ -4,14 +4,15 @@ from django.db import migrations + def nullify_blanks(apps, schema_editor): - Issuer = apps.get_model('issuer', 'Issuer') - Issuer.objects.filter(source_url='').update(source_url=None) + Issuer = apps.get_model("issuer", "Issuer") + Issuer.objects.filter(source_url="").update(source_url=None) -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('issuer', '0047_badgeinstance_user'), + ("issuer", "0047_badgeinstance_user"), ] operations = [ diff --git a/apps/issuer/migrations/0048_badgeclass_course_url.py b/apps/issuer/migrations/0048_badgeclass_course_url.py new file mode 100644 index 000000000..23b1beecc --- /dev/null +++ b/apps/issuer/migrations/0048_badgeclass_course_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-09 14:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0047_rename_badgeinstance_recipient_identifier_badgeclass_revoked_issuer_badg_recipie_6a2cd8_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='badgeclass', + name='course_url', + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + ] diff --git a/apps/issuer/migrations/0049_auto_20190812_1232.py b/apps/issuer/migrations/0049_auto_20190812_1232.py index 6120dee46..a0a2c9b18 100644 --- a/apps/issuer/migrations/0049_auto_20190812_1232.py +++ b/apps/issuer/migrations/0049_auto_20190812_1232.py @@ -6,15 +6,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0048_auto_20190812_1229'), + ("issuer", "0048_auto_20190812_1229"), ] operations = [ migrations.AlterField( - model_name='issuer', - name='source_url', - field=models.CharField(blank=True, default=None, max_length=254, null=True, unique=True), + model_name="issuer", + name="source_url", + field=models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), ), - ] \ No newline at end of file + ] diff --git a/apps/issuer/migrations/0049_badgeinstance_course_url.py b/apps/issuer/migrations/0049_badgeinstance_course_url.py new file mode 100644 index 000000000..af5a995b6 --- /dev/null +++ b/apps/issuer/migrations/0049_badgeinstance_course_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-15 14:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0048_badgeclass_course_url'), + ] + + operations = [ + migrations.AddField( + model_name='badgeinstance', + name='course_url', + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + ] diff --git a/apps/issuer/migrations/0050_auto_20190813_1945.py b/apps/issuer/migrations/0050_auto_20190813_1945.py index 6fa23512d..67036b572 100644 --- a/apps/issuer/migrations/0050_auto_20190813_1945.py +++ b/apps/issuer/migrations/0050_auto_20190813_1945.py @@ -6,20 +6,23 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0049_auto_20190812_1232'), + ("issuer", "0049_auto_20190812_1232"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='source_url', - field=models.CharField(blank=True, default=None, max_length=254, null=True, unique=True), + model_name="badgeclass", + name="source_url", + field=models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), ), migrations.AlterField( - model_name='badgeinstance', - name='source_url', - field=models.CharField(blank=True, default=None, max_length=254, null=True, unique=True), + model_name="badgeinstance", + name="source_url", + field=models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), ), ] diff --git a/apps/issuer/migrations/0050_qrcode_course_url.py b/apps/issuer/migrations/0050_qrcode_course_url.py new file mode 100644 index 000000000..c190b1da0 --- /dev/null +++ b/apps/issuer/migrations/0050_qrcode_course_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-16 09:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0049_badgeinstance_course_url'), + ] + + operations = [ + migrations.AddField( + model_name='qrcode', + name='course_url', + field=models.CharField(blank=True, default=None, max_length=255, null=True), + ), + ] diff --git a/apps/issuer/migrations/0051_auto_20190826_1604.py b/apps/issuer/migrations/0051_auto_20190826_1604.py index 2ee31fe8f..d5e35bb50 100644 --- a/apps/issuer/migrations/0051_auto_20190826_1604.py +++ b/apps/issuer/migrations/0051_auto_20190826_1604.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0050_auto_20190813_1945'), + ("issuer", "0050_auto_20190813_1945"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='recipient_identifier', + model_name="badgeinstance", + name="recipient_identifier", field=models.CharField(db_index=True, max_length=768), ), ] diff --git a/apps/issuer/migrations/0051_qrcode_evidence_items.py b/apps/issuer/migrations/0051_qrcode_evidence_items.py new file mode 100644 index 000000000..e38c939b7 --- /dev/null +++ b/apps/issuer/migrations/0051_qrcode_evidence_items.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2025-12-09 13:53 + +import jsonfield.fields +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0050_qrcode_course_url"), + ] + + operations = [ + migrations.AddField( + model_name="qrcode", + name="evidence_items", + field=jsonfield.fields.JSONField(blank=True, default=list), + ), + ] diff --git a/apps/issuer/migrations/0052_area_badgeclass_areas.py b/apps/issuer/migrations/0052_area_badgeclass_areas.py new file mode 100644 index 000000000..3a247a65c --- /dev/null +++ b/apps/issuer/migrations/0052_area_badgeclass_areas.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.8 on 2026-01-11 17:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0051_qrcode_evidence_items'), + ] + + operations = [ + migrations.CreateModel( + name='Area', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ], + options={ + 'verbose_name': 'Area', + 'verbose_name_plural': 'Areas', + }, + ), + migrations.AddField( + model_name='badgeclass', + name='areas', + field=models.ManyToManyField(blank=True, related_name='badgeclasses', to='issuer.area'), + ), + ] diff --git a/apps/issuer/migrations/0052_auto_20200106_1621.py b/apps/issuer/migrations/0052_auto_20200106_1621.py index 36cdb6165..540d074a4 100644 --- a/apps/issuer/migrations/0052_auto_20200106_1621.py +++ b/apps/issuer/migrations/0052_auto_20200106_1621.py @@ -6,25 +6,24 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0051_auto_20190826_1604'), + ("issuer", "0051_auto_20190826_1604"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='updated_at', + model_name="badgeclass", + name="updated_at", field=models.DateTimeField(auto_now=True, db_index=True), ), migrations.AlterField( - model_name='badgeinstance', - name='updated_at', + model_name="badgeinstance", + name="updated_at", field=models.DateTimeField(auto_now=True, db_index=True), ), migrations.AlterField( - model_name='issuer', - name='updated_at', + model_name="issuer", + name="updated_at", field=models.DateTimeField(auto_now=True, db_index=True), ), ] diff --git a/apps/issuer/migrations/0053_alter_badgeclass_areas_alter_badgeclass_issuer.py b/apps/issuer/migrations/0053_alter_badgeclass_areas_alter_badgeclass_issuer.py new file mode 100644 index 000000000..f18387abf --- /dev/null +++ b/apps/issuer/migrations/0053_alter_badgeclass_areas_alter_badgeclass_issuer.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.8 on 2026-01-11 20:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0052_area_badgeclass_areas'), + ] + + operations = [ + migrations.AlterField( + model_name='badgeclass', + name='areas', + field=models.ManyToManyField(blank=True, related_name='badgeclasses', to='issuer.area', verbose_name='Bereich'), + ), + migrations.AlterField( + model_name='badgeclass', + name='issuer', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='badgeclasses', to='issuer.issuer', verbose_name='Bereich'), + ), + ] diff --git a/apps/issuer/migrations/0053_auto_20200608_0452.py b/apps/issuer/migrations/0053_auto_20200608_0452.py index 68649a9b1..4c353f956 100644 --- a/apps/issuer/migrations/0053_auto_20200608_0452.py +++ b/apps/issuer/migrations/0053_auto_20200608_0452.py @@ -5,35 +5,48 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0052_auto_20200106_1621'), + ("issuer", "0052_auto_20200106_1621"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='image', - field=models.FileField(blank=True, upload_to='uploads/badges'), + model_name="badgeclass", + name="image", + field=models.FileField(blank=True, upload_to="uploads/badges"), ), migrations.AlterField( - model_name='badgeinstance', - name='acceptance', - field=models.CharField(choices=[('Unaccepted', 'Unaccepted'), ('Accepted', 'Accepted'), ('Rejected', 'Rejected')], default='Unaccepted', max_length=254), + model_name="badgeinstance", + name="acceptance", + field=models.CharField( + choices=[ + ("Unaccepted", "Unaccepted"), + ("Accepted", "Accepted"), + ("Rejected", "Rejected"), + ], + default="Unaccepted", + max_length=254, + ), ), migrations.AlterField( - model_name='badgeinstance', - name='image', - field=models.FileField(blank=True, upload_to='uploads/badges'), + model_name="badgeinstance", + name="image", + field=models.FileField(blank=True, upload_to="uploads/badges"), ), migrations.AlterField( - model_name='issuer', - name='badgrapp', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='mainsite.BadgrApp'), + model_name="issuer", + name="badgrapp", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="mainsite.BadgrApp", + ), ), migrations.AlterField( - model_name='issuer', - name='image', - field=models.FileField(blank=True, null=True, upload_to='uploads/issuers'), + model_name="issuer", + name="image", + field=models.FileField(blank=True, null=True, upload_to="uploads/issuers"), ), ] diff --git a/apps/issuer/migrations/0054_auto_20200724_1317.py b/apps/issuer/migrations/0054_auto_20200724_1317.py index 2c3b386ee..4ca02b500 100644 --- a/apps/issuer/migrations/0054_auto_20200724_1317.py +++ b/apps/issuer/migrations/0054_auto_20200724_1317.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0053_auto_20200608_0452'), + ("issuer", "0053_auto_20200608_0452"), ] operations = [ migrations.AlterField( - model_name='badgeinstance', - name='revoked', + model_name="badgeinstance", + name="revoked", field=models.BooleanField(db_index=True, default=False), ), ] diff --git a/apps/issuer/migrations/0055_auto_20200817_1538.py b/apps/issuer/migrations/0055_auto_20200817_1538.py index d3bb5be72..af4a7aeb1 100644 --- a/apps/issuer/migrations/0055_auto_20200817_1538.py +++ b/apps/issuer/migrations/0055_auto_20200817_1538.py @@ -6,40 +6,75 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0054_auto_20200724_1317'), + ("issuer", "0054_auto_20200724_1317"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="badgeclass", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterField( - model_name='badgeclass', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="badgeclass", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterField( - model_name='badgeinstance', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="badgeinstance", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterField( - model_name='badgeinstance', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="badgeinstance", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterField( - model_name='issuer', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="issuer", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterField( - model_name='issuer', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="issuer", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/apps/issuer/migrations/0056_auto_20200817_1352.py b/apps/issuer/migrations/0056_auto_20200817_1352.py index 3ef49e46d..d5c009cbd 100644 --- a/apps/issuer/migrations/0056_auto_20200817_1352.py +++ b/apps/issuer/migrations/0056_auto_20200817_1352.py @@ -1,20 +1,17 @@ # Generated by Django 2.2.14 on 2020-08-17 20:52 -from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0055_auto_20200817_1538'), + ("issuer", "0055_auto_20200817_1538"), ] operations = [ migrations.AddField( - model_name='badgeclass', - name='image_hash', - field=models.CharField(blank=True, default='', max_length=72), + model_name="badgeclass", + name="image_hash", + field=models.CharField(blank=True, default="", max_length=72), ), ] diff --git a/apps/issuer/migrations/0057_add_image_preview.py b/apps/issuer/migrations/0057_add_image_preview.py index fc1cd1467..f47a3032a 100644 --- a/apps/issuer/migrations/0057_add_image_preview.py +++ b/apps/issuer/migrations/0057_add_image_preview.py @@ -1,24 +1,22 @@ from __future__ import unicode_literals from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0056_auto_20200817_1352'), + ("issuer", "0056_auto_20200817_1352"), ] operations = [ migrations.AddField( - model_name='badgeclass', - name='image_preview', - field=models.FileField(blank=True, null=True, upload_to='uploads/badges'), + model_name="badgeclass", + name="image_preview", + field=models.FileField(blank=True, null=True, upload_to="uploads/badges"), ), migrations.AddField( - model_name='issuer', - name='image_preview', - field=models.FileField(blank=True, null=True, upload_to='uploads/issuer'), + model_name="issuer", + name="image_preview", + field=models.FileField(blank=True, null=True, upload_to="uploads/issuer"), ), ] diff --git a/apps/issuer/migrations/0058_auto_20210302_1103.py b/apps/issuer/migrations/0058_auto_20210302_1103.py index 91dcd9849..afd57a6e4 100644 --- a/apps/issuer/migrations/0058_auto_20210302_1103.py +++ b/apps/issuer/migrations/0058_auto_20210302_1103.py @@ -4,30 +4,35 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0057_add_image_preview'), + ("issuer", "0057_add_image_preview"), ] operations = [ migrations.AlterField( - model_name='badgeclass', - name='slug', - field=models.CharField(blank=True, db_index=True, default=None, max_length=255, null=True), + model_name="badgeclass", + name="slug", + field=models.CharField( + blank=True, db_index=True, default=None, max_length=255, null=True + ), ), migrations.AlterField( - model_name='badgeinstance', - name='slug', - field=models.CharField(blank=True, db_index=True, default=None, max_length=255, null=True), + model_name="badgeinstance", + name="slug", + field=models.CharField( + blank=True, db_index=True, default=None, max_length=255, null=True + ), ), migrations.AlterField( - model_name='issuer', - name='image_preview', - field=models.FileField(blank=True, null=True, upload_to='uploads/issuers'), + model_name="issuer", + name="image_preview", + field=models.FileField(blank=True, null=True, upload_to="uploads/issuers"), ), migrations.AlterField( - model_name='issuer', - name='slug', - field=models.CharField(blank=True, db_index=True, default=None, max_length=255, null=True), + model_name="issuer", + name="slug", + field=models.CharField( + blank=True, db_index=True, default=None, max_length=255, null=True + ), ), ] diff --git a/apps/issuer/migrations/0059_remove_issuer_image_preview.py b/apps/issuer/migrations/0059_remove_issuer_image_preview.py index fa63fdb20..f41f97d57 100644 --- a/apps/issuer/migrations/0059_remove_issuer_image_preview.py +++ b/apps/issuer/migrations/0059_remove_issuer_image_preview.py @@ -4,14 +4,13 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0058_auto_20210302_1103'), + ("issuer", "0058_auto_20210302_1103"), ] operations = [ migrations.RemoveField( - model_name='issuer', - name='image_preview', + model_name="issuer", + name="image_preview", ), ] diff --git a/apps/issuer/migrations/0060_issuer_verified.py b/apps/issuer/migrations/0060_issuer_verified.py index bdcbd7d9f..dd6940ee7 100644 --- a/apps/issuer/migrations/0060_issuer_verified.py +++ b/apps/issuer/migrations/0060_issuer_verified.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0059_remove_issuer_image_preview'), + ("issuer", "0059_remove_issuer_image_preview"), ] operations = [ migrations.AddField( - model_name='issuer', - name='verified', + model_name="issuer", + name="verified", field=models.BooleanField(default=False), ), ] diff --git a/apps/issuer/migrations/0061_auto_20211118_0923.py b/apps/issuer/migrations/0061_auto_20211118_0923.py index fcd4200f3..af247d3f8 100644 --- a/apps/issuer/migrations/0061_auto_20211118_0923.py +++ b/apps/issuer/migrations/0061_auto_20211118_0923.py @@ -4,35 +4,34 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0060_issuer_verified'), + ("issuer", "0060_issuer_verified"), ] operations = [ migrations.AddField( - model_name='issuer', - name='city', + model_name="issuer", + name="city", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='issuer', - name='country', + model_name="issuer", + name="country", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='issuer', - name='street', + model_name="issuer", + name="street", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='issuer', - name='streetnumber', + model_name="issuer", + name="streetnumber", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='issuer', - name='zip', + model_name="issuer", + name="zip", field=models.CharField(max_length=255, null=True), ), ] diff --git a/apps/issuer/migrations/0062_issuer_category.py b/apps/issuer/migrations/0062_issuer_category.py index d043e9059..e6a9ddc50 100644 --- a/apps/issuer/migrations/0062_issuer_category.py +++ b/apps/issuer/migrations/0062_issuer_category.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0061_auto_20211118_0923'), + ("issuer", "0061_auto_20211118_0923"), ] operations = [ migrations.AddField( - model_name='issuer', - name='category', - field=models.CharField(default='n/a', max_length=255), + model_name="issuer", + name="category", + field=models.CharField(default="n/a", max_length=255), ), ] diff --git a/apps/issuer/migrations/0063_auto_20211122_0622.py b/apps/issuer/migrations/0063_auto_20211122_0622.py index 67743d82c..4ab4d1829 100644 --- a/apps/issuer/migrations/0063_auto_20211122_0622.py +++ b/apps/issuer/migrations/0063_auto_20211122_0622.py @@ -4,35 +4,34 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0062_issuer_category'), + ("issuer", "0062_issuer_category"), ] operations = [ migrations.AlterField( - model_name='issuer', - name='city', + model_name="issuer", + name="city", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AlterField( - model_name='issuer', - name='country', + model_name="issuer", + name="country", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AlterField( - model_name='issuer', - name='street', + model_name="issuer", + name="street", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AlterField( - model_name='issuer', - name='streetnumber', + model_name="issuer", + name="streetnumber", field=models.CharField(blank=True, max_length=255, null=True), ), migrations.AlterField( - model_name='issuer', - name='zip', + model_name="issuer", + name="zip", field=models.CharField(blank=True, max_length=255, null=True), ), ] diff --git a/apps/issuer/migrations/0064_auto_20211122_0929.py b/apps/issuer/migrations/0064_auto_20211122_0929.py index e897e50c2..eb0bdb494 100644 --- a/apps/issuer/migrations/0064_auto_20211122_0929.py +++ b/apps/issuer/migrations/0064_auto_20211122_0929.py @@ -4,20 +4,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('issuer', '0063_auto_20211122_0622'), + ("issuer", "0063_auto_20211122_0622"), ] operations = [ migrations.AddField( - model_name='issuer', - name='lat', + model_name="issuer", + name="lat", field=models.FloatField(blank=True, null=True), ), migrations.AddField( - model_name='issuer', - name='lon', + model_name="issuer", + name="lon", field=models.FloatField(blank=True, null=True), ), ] diff --git a/apps/issuer/migrations/0065_badgeclass_imageframe.py b/apps/issuer/migrations/0065_badgeclass_imageframe.py new file mode 100644 index 000000000..164180d35 --- /dev/null +++ b/apps/issuer/migrations/0065_badgeclass_imageframe.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2024-05-29 13:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0064_auto_20211122_0929"), + ] + + operations = [ + migrations.AddField( + model_name="badgeclass", + name="imageFrame", + field=models.BooleanField(default=True), + ), + ] diff --git a/apps/issuer/migrations/0066_qrcode_requestedbadge.py b/apps/issuer/migrations/0066_qrcode_requestedbadge.py new file mode 100644 index 000000000..f3d5969a9 --- /dev/null +++ b/apps/issuer/migrations/0066_qrcode_requestedbadge.py @@ -0,0 +1,117 @@ +# Generated by Django 3.2 on 2024-08-07 22:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("issuer", "0065_badgeclass_imageframe"), + ] + + operations = [ + migrations.CreateModel( + name="QrCode", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("title", models.CharField(max_length=254)), + ("createdBy", models.CharField(max_length=254)), + ( + "valid_from", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "expires_at", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "badgeclass", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="qrcodes", + to="issuer.badgeclass", + ), + ), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.issuer" + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RequestedBadge", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("firstName", models.CharField(max_length=254)), + ("lastName", models.CharField(max_length=254)), + ("email", models.CharField(blank=True, max_length=254, null=True)), + ( + "requestedOn", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("status", models.CharField(default="Pending", max_length=254)), + ( + "badgeclass", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="requestedbadges", + to="issuer.badgeclass", + ), + ), + ( + "qrcode", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="requestedbadges", + to="issuer.qrcode", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/apps/issuer/migrations/0067_learningpath_learningpathbadge_learningpathparticipant_learningpathtag_requestedlearningpath.py b/apps/issuer/migrations/0067_learningpath_learningpathbadge_learningpathparticipant_learningpathtag_requestedlearningpath.py new file mode 100644 index 000000000..d57273e0a --- /dev/null +++ b/apps/issuer/migrations/0067_learningpath_learningpathbadge_learningpathparticipant_learningpathtag_requestedlearningpath.py @@ -0,0 +1,257 @@ +# Generated by Django 3.2 on 2024-10-08 12:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("mainsite", "0024_auto_20200608_0452"), + ("issuer", "0066_qrcode_requestedbadge"), + ] + + operations = [ + migrations.CreateModel( + name="LearningPath", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True, db_index=True)), + ("name", models.CharField(max_length=254)), + ("description", models.TextField(blank=True, default=None, null=True)), + ( + "slug", + models.CharField( + blank=True, + db_index=True, + default=None, + max_length=255, + null=True, + ), + ), + ( + "badgrapp", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="mainsite.badgrapp", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="learningpaths", + to="issuer.issuer", + ), + ), + ( + "participationBadge", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.badgeclass", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="RequestedLearningPath", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ( + "requestedOn", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("status", models.CharField(default="Pending", max_length=254)), + ( + "learningpath", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="requested_learningpath", + to="issuer.learningpath", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="LearningPathTag", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(db_index=True, max_length=254)), + ( + "learningPath", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.learningpath", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="LearningPathBadge", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("order", models.PositiveIntegerField()), + ( + "badge", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.badgeclass", + ), + ), + ( + "learning_path", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.learningpath", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="LearningPathParticipant", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True, db_index=True)), + ("started_at", models.DateTimeField(auto_now_add=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "learning_path", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.learningpath", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "learning_path")}, + }, + ), + ] diff --git a/apps/issuer/migrations/0068_issuer_intendeduseverified.py b/apps/issuer/migrations/0068_issuer_intendeduseverified.py new file mode 100644 index 000000000..03c48cd1b --- /dev/null +++ b/apps/issuer/migrations/0068_issuer_intendeduseverified.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2 on 2024-10-28 11:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "issuer", + "0067_learningpath_learningpathbadge_learningpathparticipant_learningpathtag_requestedlearningpath", + ), + ] + + operations = [ + migrations.AddField( + model_name="issuer", + name="intendedUseVerified", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/issuer/migrations/0069_delete_learningpathparticipant.py b/apps/issuer/migrations/0069_delete_learningpathparticipant.py new file mode 100644 index 000000000..79c094a06 --- /dev/null +++ b/apps/issuer/migrations/0069_delete_learningpathparticipant.py @@ -0,0 +1,15 @@ +# Generated by Django 3.2 on 2025-01-16 11:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0068_issuer_intendeduseverified"), + ] + + operations = [ + migrations.DeleteModel( + name="LearningPathParticipant", + ), + ] diff --git a/apps/issuer/migrations/0070_badgeclass_copy_permissions.py b/apps/issuer/migrations/0070_badgeclass_copy_permissions.py new file mode 100644 index 000000000..94ba691be --- /dev/null +++ b/apps/issuer/migrations/0070_badgeclass_copy_permissions.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2025-03-10 09:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0069_delete_learningpathparticipant"), + ] + + operations = [ + migrations.AddField( + model_name="badgeclass", + name="copy_permissions", + field=models.PositiveSmallIntegerField(default=1), + ), + ] diff --git a/apps/issuer/migrations/0071_issuerstaffrequest.py b/apps/issuer/migrations/0071_issuerstaffrequest.py new file mode 100644 index 000000000..12af600cb --- /dev/null +++ b/apps/issuer/migrations/0071_issuerstaffrequest.py @@ -0,0 +1,72 @@ +# Generated by Django 3.2 on 2025-04-01 10:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0070_badgeclass_copy_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="IssuerStaffRequest", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ( + "requestedOn", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "status", + models.CharField( + choices=[ + ("Pending", "Pending"), + ("Approved", "Approved"), + ("Rejected", "Rejected"), + ("Revoked", "Revoked"), + ], + default="Pending", + max_length=254, + ), + ), + ("revoked", models.BooleanField(db_index=True, default=False)), + ( + "issuer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="staffrequests", + to="issuer.issuer", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/apps/issuer/migrations/0071_qrcode_notifications.py b/apps/issuer/migrations/0071_qrcode_notifications.py new file mode 100644 index 000000000..d41dd3ee6 --- /dev/null +++ b/apps/issuer/migrations/0071_qrcode_notifications.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2025-04-01 14:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0070_badgeclass_copy_permissions"), + ] + + operations = [ + migrations.AddField( + model_name="qrcode", + name="notifications", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/issuer/migrations/0072_merge_20250407_1526.py b/apps/issuer/migrations/0072_merge_20250407_1526.py new file mode 100644 index 000000000..b7db73b25 --- /dev/null +++ b/apps/issuer/migrations/0072_merge_20250407_1526.py @@ -0,0 +1,12 @@ +# Generated by Django 3.2 on 2025-04-07 13:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0071_issuerstaffrequest"), + ("issuer", "0071_qrcode_notifications"), + ] + + operations = [] diff --git a/apps/issuer/migrations/0073_auto_20250408_1502.py b/apps/issuer/migrations/0073_auto_20250408_1502.py new file mode 100644 index 000000000..b0551d3be --- /dev/null +++ b/apps/issuer/migrations/0073_auto_20250408_1502.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2 on 2025-04-08 13:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("issuer", "0072_merge_20250407_1526"), + ] + + operations = [ + migrations.AddField( + model_name="qrcode", + name="created_by_user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="issuerstaffrequest", + name="user", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/apps/issuer/migrations/0074_badgeinstance_ob_json_2_0.py b/apps/issuer/migrations/0074_badgeinstance_ob_json_2_0.py new file mode 100644 index 000000000..7ddc59782 --- /dev/null +++ b/apps/issuer/migrations/0074_badgeinstance_ob_json_2_0.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2 on 2025-04-08 11:47 + + +from django.conf import settings +from django.core import management +from django.db import migrations, models + + +def badgeinstance_generate_ob2_json(apps, schema): + # try to populate during migration + try: + print("\n running command issuer:populate_badgeinstance_ob_json_2_0...") + management.call_command("populate_badgeinstance_ob_json_2_0") + except Exception: + pass + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("issuer", "0073_auto_20250408_1502"), + ] + + operations = [ + migrations.AddField( + model_name="badgeinstance", + name="ob_json_2_0", + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.RunPython( + code=badgeinstance_generate_ob2_json, reverse_code=migrations.RunPython.noop + ), + ] diff --git a/apps/issuer/migrations/0075_importedbadgeassertion_importedbadgeassertionextension.py b/apps/issuer/migrations/0075_importedbadgeassertion_importedbadgeassertionextension.py new file mode 100644 index 000000000..fa2160a1f --- /dev/null +++ b/apps/issuer/migrations/0075_importedbadgeassertion_importedbadgeassertionextension.py @@ -0,0 +1,163 @@ +# Generated by Django 3.2 on 2025-04-28 09:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import jsonfield.fields + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("issuer", "0074_badgeinstance_ob_json_2_0"), + ] + + operations = [ + migrations.CreateModel( + name="ImportedBadgeAssertion", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True, db_index=True)), + ("source", models.CharField(default="local", max_length=254)), + ( + "source_url", + models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), + ), + ("badge_name", models.CharField(max_length=255)), + ("badge_description", models.TextField(blank=True, null=True)), + ("badge_criteria_url", models.URLField(blank=True, null=True)), + ("badge_image_url", models.URLField(blank=True, null=True)), + ("image", models.FileField(blank=True, upload_to="uploads/badges")), + ("issuer_name", models.CharField(max_length=255)), + ("issuer_url", models.URLField()), + ( + "issuer_email", + models.EmailField(blank=True, max_length=254, null=True), + ), + ("issuer_image_url", models.URLField(blank=True, null=True)), + ("issued_on", models.DateTimeField()), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ( + "recipient_identifier", + models.CharField(db_index=True, max_length=768), + ), + ( + "recipient_type", + models.CharField( + choices=[ + ("email", "email"), + ("openBadgeId", "openBadgeId"), + ("telephone", "telephone"), + ("url", "url"), + ], + default="email", + max_length=255, + ), + ), + ( + "acceptance", + models.CharField( + choices=[ + ("Unaccepted", "Unaccepted"), + ("Accepted", "Accepted"), + ("Rejected", "Rejected"), + ], + default="Accepted", + max_length=254, + ), + ), + ("revoked", models.BooleanField(default=False)), + ( + "revocation_reason", + models.CharField(blank=True, max_length=255, null=True), + ), + ("original_json", jsonfield.fields.JSONField()), + ("hashed", models.BooleanField(default=True)), + ( + "salt", + models.CharField( + blank=True, default=None, max_length=254, null=True + ), + ), + ("narrative", models.TextField(blank=True, null=True)), + ("verification_url", models.URLField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Imported Badge Assertion", + }, + ), + migrations.CreateModel( + name="ImportedBadgeAssertionExtension", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "importedBadge", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="issuer.importedbadgeassertion", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/apps/issuer/migrations/0076_badgeclass_criteria.py b/apps/issuer/migrations/0076_badgeclass_criteria.py new file mode 100644 index 000000000..efdc59fd1 --- /dev/null +++ b/apps/issuer/migrations/0076_badgeclass_criteria.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2025-05-12 19:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0075_importedbadgeassertion_importedbadgeassertionextension"), + ] + + operations = [ + migrations.AddField( + model_name="badgeclass", + name="criteria", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/apps/issuer/migrations/0077_auto_20250513_1031.py b/apps/issuer/migrations/0077_auto_20250513_1031.py new file mode 100644 index 000000000..c3e73eb7c --- /dev/null +++ b/apps/issuer/migrations/0077_auto_20250513_1031.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2 on 2025-05-13 08:31 + +from django.db import migrations, models +import issuer.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0076_badgeclass_criteria'), + ] + + operations = [ + migrations.AddField( + model_name='badgeinstance', + name='ob_json_3_0', + field=models.TextField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name='issuer', + name='private_key', + field=models.CharField(blank=True, default=issuer.utils.generate_private_key_pem, max_length=512, null=True), + ), + ] diff --git a/apps/issuer/migrations/0078_learningpath_required_badges_count_squashed_0079_learningpath_activated.py b/apps/issuer/migrations/0078_learningpath_required_badges_count_squashed_0079_learningpath_activated.py new file mode 100644 index 000000000..4b34c3113 --- /dev/null +++ b/apps/issuer/migrations/0078_learningpath_required_badges_count_squashed_0079_learningpath_activated.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2 on 2025-07-10 07:46 + +from django.db import migrations, models + +from issuer.models import LearningPath, LearningPathBadge + + +def set_required_badges_count(apps, schema_editor): + for lp in LearningPath.objects.all(): + badge_count = LearningPathBadge.objects.filter(learning_path=lp).count() + lp.required_badges_count = badge_count + lp.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("issuer", "0077_auto_20250513_1031"), + ] + + operations = [ + migrations.AddField( + model_name="learningpath", + name="required_badges_count", + field=models.PositiveIntegerField(default=3), + preserve_default=False, + ), + migrations.AddField( + model_name="learningpath", + name="activated", + field=models.BooleanField(default=True), + ), + migrations.RunPython(set_required_badges_count), + ] diff --git a/apps/issuer/migrations/0079_alter_learningpath_activated.py b/apps/issuer/migrations/0079_alter_learningpath_activated.py new file mode 100644 index 000000000..c05c1aa47 --- /dev/null +++ b/apps/issuer/migrations/0079_alter_learningpath_activated.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2025-08-05 06:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0078_learningpath_required_badges_count_squashed_0079_learningpath_activated'), + ] + + operations = [ + migrations.AlterField( + model_name='learningpath', + name='activated', + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/issuer/migrations/0080_auto_20250824_2314.py b/apps/issuer/migrations/0080_auto_20250824_2314.py new file mode 100644 index 000000000..b40106135 --- /dev/null +++ b/apps/issuer/migrations/0080_auto_20250824_2314.py @@ -0,0 +1,273 @@ +# Generated by Django 3.2 on 2025-08-25 06:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import issuer.utils +import mainsite.mixins + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("mainsite", "0026_iframeurl"), + ("issuer", "0079_alter_learningpath_activated"), + ] + + operations = [ + migrations.CreateModel( + name="Network", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ("updated_at", models.DateTimeField(auto_now=True, db_index=True)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ("source", models.CharField(default="local", max_length=254)), + ( + "source_url", + models.CharField( + blank=True, default=None, max_length=254, null=True, unique=True + ), + ), + ( + "slug", + models.CharField( + blank=True, + db_index=True, + default=None, + max_length=255, + null=True, + ), + ), + ("name", models.CharField(max_length=1024)), + ( + "image", + models.FileField( + blank=True, null=True, upload_to="uploads/issuers" + ), + ), + ("description", models.TextField(blank=True, default=None, null=True)), + ( + "url", + models.CharField( + blank=True, default=None, max_length=254, null=True + ), + ), + ("country", models.CharField(blank=True, max_length=254, null=True)), + ( + "private_key", + models.CharField( + blank=True, + default=issuer.utils.generate_private_key_pem, + max_length=512, + null=True, + ), + ), + ("state", models.CharField(blank=True, max_length=254, null=True)), + ( + "badgrapp", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="mainsite.badgrapp", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + bases=( + mainsite.mixins.ResizeUploadedImage, + mainsite.mixins.ScrubUploadedSvgImage, + mainsite.mixins.PngImagePreview, + models.Model, + ), + ), + migrations.AlterField( + model_name="issuer", + name="country", + field=models.CharField(blank=True, max_length=254, null=True), + ), + migrations.CreateModel( + name="NetworkStaff", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "role", + models.CharField( + choices=[ + ("owner", "Owner"), + ("editor", "Editor"), + ("staff", "Staff"), + ], + default="staff", + max_length=254, + ), + ), + ( + "network", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.network" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("network", "user")}, + }, + ), + migrations.CreateModel( + name="NetworkInvite", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("entity_version", models.PositiveIntegerField(default=1)), + ( + "entity_id", + models.CharField(default=None, max_length=254, unique=True), + ), + ("invitedOn", models.DateTimeField(default=django.utils.timezone.now)), + ( + "acceptedOn", + models.DateTimeField(blank=True, default=None, null=True), + ), + ( + "status", + models.CharField( + choices=[ + ("Pending", "Pending"), + ("Approved", "Approved"), + ("Rejected", "Rejected"), + ("Revoked", "Revoked"), + ], + default="Pending", + max_length=254, + ), + ), + ("revoked", models.BooleanField(db_index=True, default=False)), + ( + "issuer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="issuer.issuer", + ), + ), + ( + "network", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invites", + to="issuer.network", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="NetworkExtension", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=254)), + ( + "original_json", + models.TextField(blank=True, default=None, null=True), + ), + ( + "network", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="issuer.network" + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="network", + name="partner_issuers", + field=models.ManyToManyField( + blank=True, related_name="networks", to="issuer.Issuer" + ), + ), + migrations.AddField( + model_name="network", + name="staff", + field=models.ManyToManyField( + through="issuer.NetworkStaff", to=settings.AUTH_USER_MODEL + ), + ), + migrations.AddField( + model_name="network", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/apps/issuer/migrations/0081_auto_20250903_2334.py b/apps/issuer/migrations/0081_auto_20250903_2334.py new file mode 100644 index 000000000..873790735 --- /dev/null +++ b/apps/issuer/migrations/0081_auto_20250903_2334.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2025-09-04 06:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('issuer', '0080_auto_20250824_2314'), + ] + + operations = [ + migrations.AlterField( + model_name='badgeinstance', + name='recipient_identifier', + field=models.CharField(db_index=True, max_length=320), + ), + migrations.AlterField( + model_name='importedbadgeassertion', + name='recipient_identifier', + field=models.CharField(db_index=True, max_length=320), + ), + ] diff --git a/apps/issuer/models.py b/apps/issuer/models.py index 58287f930..e6438d0ae 100644 --- a/apps/issuer/models.py +++ b/apps/issuer/models.py @@ -1,65 +1,92 @@ -import io import datetime -import urllib.request, urllib.parse, urllib.error - -import dateutil -import re +import io +import math +import os +import urllib.parse import uuid +import base64 +import base58 +from hashlib import sha256 from collections import OrderedDict -from itertools import chain +from json import dumps as json_dumps, loads as json_loads + +from cryptography.hazmat.primitives import serialization +from pyld import jsonld import cachemodel -import os from allauth.account.adapter import get_adapter -from cachemodel import CACHE_FOREVER_TIMEOUT from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.files.storage import default_storage -from django.urls import reverse +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models, transaction from django.db.models import ProtectedError -from json import loads as json_loads -from json import dumps as json_dumps - -from jsonfield import JSONField -from openbadges_bakery import bake +from django.urls import reverse from django.utils import timezone -from django.core.cache import cache - -import badgrlog +from apps.issuer.services.image_composer import ImageComposer from entity.models import BaseVersionedEntity -from issuer.managers import BadgeInstanceManager, IssuerManager, BadgeClassManager, BadgeInstanceEvidenceManager +from issuer.managers import ( + BadgeClassManager, + BadgeInstanceEvidenceManager, + BadgeInstanceManager, + IssuerManager, +) +from jsonfield import JSONField +from mainsite import blacklist from mainsite.managers import SlugOrJsonIdCacheModelManager -from mainsite.mixins import HashUploadedImage, ResizeUploadedImage, ScrubUploadedSvgImage, PngImagePreview +from mainsite.mixins import ( + HashUploadedImage, + PngImagePreview, + ResizeUploadedImage, + ScrubUploadedSvgImage, +) from mainsite.models import BadgrApp, EmailBlacklist -from mainsite import blacklist -from mainsite.utils import OriginSetting, generate_entity_uri - -from .utils import (add_obi_version_ifneeded, CURRENT_OBI_VERSION, generate_rebaked_filename, - generate_sha256_hashstring, get_obi_context, parse_original_datetime, UNVERSIONED_BAKED_VERSION) +from mainsite.utils import OriginSetting, generate_entity_uri, get_name +from openbadges_bakery import bake -from geopy.geocoders import Nominatim +from .utils import ( + CURRENT_OBI_VERSION, + UNVERSIONED_BAKED_VERSION, + add_obi_version_ifneeded, + generate_rebaked_filename, + generate_sha256_hashstring, + get_obi_context, + parse_original_datetime, + generate_private_key_pem, + geocode, +) +import logging -AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') +logger = logging.getLogger("Badgr.Events") -RECIPIENT_TYPE_EMAIL = 'email' -RECIPIENT_TYPE_ID = 'openBadgeId' -RECIPIENT_TYPE_TELEPHONE = 'telephone' -RECIPIENT_TYPE_URL = 'url' +AUTH_USER_MODEL = getattr(settings, "AUTH_USER_MODEL", "auth.User") -logger = badgrlog.BadgrLogger() +RECIPIENT_TYPE_EMAIL = "email" +RECIPIENT_TYPE_ID = "openBadgeId" +RECIPIENT_TYPE_TELEPHONE = "telephone" +RECIPIENT_TYPE_URL = "url" class BaseAuditedModel(cachemodel.CacheModel): created_at = models.DateTimeField(auto_now_add=True, db_index=True) - created_by = models.ForeignKey('badgeuser.BadgeUser', blank=True, null=True, related_name="+", - on_delete=models.SET_NULL) + created_by = models.ForeignKey( + "badgeuser.BadgeUser", + blank=True, + null=True, + related_name="+", + on_delete=models.SET_NULL, + ) updated_at = models.DateTimeField(auto_now=True, db_index=True) - updated_by = models.ForeignKey('badgeuser.BadgeUser', blank=True, null=True, related_name="+", - on_delete=models.SET_NULL) + updated_by = models.ForeignKey( + "badgeuser.BadgeUser", + blank=True, + null=True, + related_name="+", + on_delete=models.SET_NULL, + ) class Meta: abstract = True @@ -67,16 +94,27 @@ class Meta: @property def cached_creator(self): from badgeuser.models import BadgeUser + return BadgeUser.cached.get(id=self.created_by_id) class BaseAuditedModelDeletedWithUser(cachemodel.CacheModel): created_at = models.DateTimeField(auto_now_add=True, db_index=True) - created_by = models.ForeignKey('badgeuser.BadgeUser', blank=True, null=True, related_name="+", - on_delete=models.CASCADE) + created_by = models.ForeignKey( + "badgeuser.BadgeUser", + blank=True, + null=True, + related_name="+", + on_delete=models.CASCADE, + ) updated_at = models.DateTimeField(auto_now=True, db_index=True) - updated_by = models.ForeignKey('badgeuser.BadgeUser', blank=True, null=True, related_name="+", - on_delete=models.CASCADE) + updated_by = models.ForeignKey( + "badgeuser.BadgeUser", + blank=True, + null=True, + related_name="+", + on_delete=models.CASCADE, + ) class Meta: abstract = True @@ -84,6 +122,7 @@ class Meta: @property def cached_creator(self): from badgeuser.models import BadgeUser + return BadgeUser.cached.get(id=self.created_by_id) @@ -97,18 +136,25 @@ def get_original_json(self): if self.original_json: try: return json_loads(self.original_json) - except (TypeError, ValueError) as e: + except (TypeError, ValueError): pass def get_filtered_json(self, excluded_fields=()): original = self.get_original_json() if original is not None: - return {key: original[key] for key in [k for k in list(original.keys()) if k not in excluded_fields]} + return { + key: original[key] + for key in [ + k for k in list(original.keys()) if k not in excluded_fields + ] + } class BaseOpenBadgeObjectModel(OriginalJsonMixin, cachemodel.CacheModel): - source = models.CharField(max_length=254, default='local') - source_url = models.CharField(max_length=254, blank=True, null=True, default=None, unique=True) + source = models.CharField(max_length=254, default="local") + source_url = models.CharField( + max_length=254, blank=True, null=True, default=None, unique=True + ) class Meta: abstract = True @@ -122,7 +168,7 @@ def __hash__(self): def __eq__(self, other): UNUSABLE_DEFAULT = uuid.uuid4() - comparable_properties = getattr(self, 'COMPARABLE_PROPERTIES', None) + comparable_properties = getattr(self, "COMPARABLE_PROPERTIES", None) if comparable_properties is None: return super(BaseOpenBadgeObjectModel, self).__eq__(other) @@ -133,6 +179,8 @@ def __eq__(self, other): @cachemodel.cached_method(auto_publish=True) def cached_extensions(self): + if not self.pk: + return [] return self.get_extensions_manager().all() @property @@ -152,9 +200,9 @@ def extension_items(self, value): # add new for ext_name, ext in list(value.items()): ext_json = json_dumps(ext) - ext, ext_created = self.get_extensions_manager().get_or_create(name=ext_name, defaults=dict( - original_json=ext_json - )) + ext, ext_created = self.get_extensions_manager().get_or_create( + name=ext_name, defaults=dict(original_json=ext_json) + ) if not ext_created: ext.original_json = ext_json ext.save() @@ -176,28 +224,62 @@ def __str__(self): class Meta: abstract = True +class Area(models.Model): + name = models.CharField(max_length=255, unique=True) + + class Meta: + verbose_name = "Area" + verbose_name_plural = "Areas" -class Issuer(ResizeUploadedImage, - ScrubUploadedSvgImage, - PngImagePreview, - BaseAuditedModel, - BaseVersionedEntity, - BaseOpenBadgeObjectModel): - entity_class_name = 'Issuer' - COMPARABLE_PROPERTIES = ('badgrapp_id', 'description', 'email', 'entity_id', 'entity_version', 'name', 'pk', - 'updated_at', 'url') + def __str__(self): + return self.name +class Issuer( + ResizeUploadedImage, + ScrubUploadedSvgImage, + PngImagePreview, + BaseAuditedModel, + BaseVersionedEntity, + BaseOpenBadgeObjectModel, +): + entity_class_name = "Issuer" + COMPARABLE_PROPERTIES = ( + "badgrapp_id", + "description", + "email", + "entity_id", + "entity_version", + "name", + "pk", + "updated_at", + "url", + ) - staff = models.ManyToManyField(AUTH_USER_MODEL, through='IssuerStaff') + staff = models.ManyToManyField(AUTH_USER_MODEL, through="IssuerStaff") # slug has been deprecated for now, but preserve existing values - slug = models.CharField(max_length=255, db_index=True, blank=True, null=True, default=None) - #slug = AutoSlugField(max_length=255, populate_from='name', unique=True, blank=False, editable=True) + slug = models.CharField( + max_length=255, db_index=True, blank=True, null=True, default=None + ) + # slug = AutoSlugField(max_length=255, populate_from='name', unique=True, blank=False, editable=True) + + badgrapp = models.ForeignKey( + "mainsite.BadgrApp", + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + ) + + networks = models.ManyToManyField( + "self", + through="NetworkMembership", + related_name="partner_issuers", + ) - badgrapp = models.ForeignKey('mainsite.BadgrApp', blank=True, null=True, default=None, on_delete=models.SET_NULL) + is_network = models.BooleanField(default=False) name = models.CharField(max_length=1024) - image = models.FileField(upload_to='uploads/issuers', blank=True, null=True) - image_preview = models.FileField(upload_to='uploads/issuers', blank=True, null=True) + image = models.FileField(upload_to="uploads/issuers", blank=True, null=True) description = models.TextField(blank=True, null=True, default=None) url = models.CharField(max_length=254, blank=True, null=True, default=None) email = models.CharField(max_length=254, blank=True, null=True, default=None) @@ -206,30 +288,33 @@ class Issuer(ResizeUploadedImage, verified = models.BooleanField(null=False, default=False) objects = IssuerManager() - cached = SlugOrJsonIdCacheModelManager(slug_kwarg_name='entity_id', slug_field_name='entity_id') - - category = models.CharField(max_length=255, null=False, default='n/a') + cached = SlugOrJsonIdCacheModelManager( + slug_kwarg_name="entity_id", slug_field_name="entity_id" + ) - #address fields + category = models.CharField(max_length=255, null=False, default="n/a") + + # address fields street = models.CharField(max_length=255, null=True, blank=True) streetnumber = models.CharField(max_length=255, null=True, blank=True) zip = models.CharField(max_length=255, null=True, blank=True) city = models.CharField(max_length=255, null=True, blank=True) country = models.CharField(max_length=255, null=True, blank=True) - + state = models.CharField(max_length=255, null=True, blank=True) + + intendedUseVerified = models.BooleanField(null=False, default=False) + linkedinId = models.CharField(max_length=255, default=None) lat = models.FloatField(null=True, blank=True) lon = models.FloatField(null=True, blank=True) - __original_address = { - 'street': None, - 'streetnumber': None, - 'zip': None, - 'city': None, - 'country': None - } + private_key = models.CharField( + max_length=512, blank=True, null=True, default=generate_private_key_pem + ) def publish(self, publish_staff=True, *args, **kwargs): - fields_cache = self._state.fields_cache # stash the fields cache to avoid publishing related objects here + fields_cache = ( + self._state.fields_cache + ) # stash the fields cache to avoid publishing related objects here self._state.fields_cache = dict() super(Issuer, self).publish(*args, **kwargs) @@ -244,23 +329,26 @@ def has_nonrevoked_assertions(self): def delete(self, *args, **kwargs): if self.has_nonrevoked_assertions(): - raise ProtectedError("Issuer can not be deleted because it has previously issued badges.", self) + raise ProtectedError( + "Issuer can not be deleted because it has previously issued badges.", + self, + ) # remove any unused badgeclasses owned by issuer for bc in self.cached_badgeclasses(): bc.delete() staff = self.cached_issuerstaff() - ret = super(Issuer, self).delete(*args, **kwargs) - # remove membership records for membership in staff: membership.delete(publish_issuer=False) + ret = super(Issuer, self).delete(*args, **kwargs) - if apps.is_installed('badgebook'): + if apps.is_installed("badgebook"): # badgebook shim try: from badgebook.models import LmsCourseInfo + # update LmsCourseInfo's that were using this issuer as the default_issuer for course_info in LmsCourseInfo.objects.filter(default_issuer=self): course_info.default_issuer = None @@ -270,48 +358,205 @@ def delete(self, *args, **kwargs): return ret - # override init method to save original address - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.__original_address = { 'street': self.street, 'streetnumber': self.streetnumber, 'city': self.city, 'zip': self.zip, 'country': self.country } - def save(self, *args, **kwargs): + original_verified = None + should_geocode = False + # original_image = None + if not self.pk: self.notify_admins(self) - - #geocoding if address in model changed - if self.__original_address: - if (self.street != self.__original_address['street'] or self.streetnumber != self.__original_address['streetnumber'] or self.city != self.__original_address['city'] or self.zip != self.__original_address['zip'] or self.country != self.__original_address['country']): - addr_string = (self.street if self.street != None else '') +" "+ (str(self.streetnumber) if self.streetnumber != None else '') +" "+ (str(self.zip) if self.zip != None else '')+" "+ (str(self.city) if self.city != None else '') + " Deutschland" - nom = Nominatim(user_agent="myBadges") - geoloc = nom.geocode(addr_string) - if geoloc: - self.lon = geoloc.longitude - self.lat = geoloc.latitude - + should_geocode = True + if not self.verified: + badgr_app = BadgrApp.objects.get_current(None) + try: + email_context = { + # removes all special characters from the issuer name + # (keeps whitespces, digits and alphabetical characters ) + "issuer_name": self.name, + "issuer_url": self.url, + "issuer_email": self.email, + "badgr_app": badgr_app, + } + except KeyError as e: + # A property isn't stored right in json + raise e + template_name = "issuer/email/notify_issuer_unverified" + adapter = get_adapter() + adapter.send_mail( + template_name, + self.email, + context=email_context, + from_email="support@openbadges.education", + ) + else: + original_object = Issuer.objects.get(pk=self.pk) + original_verified = original_object.verified + # original_image = original_object.image + + if ( + self.street != original_object.street + or self.streetnumber != original_object.streetnumber + or self.city != original_object.city + or self.zip != original_object.zip + or self.country != original_object.country + ): + should_geocode = True + + # geocoding if issuer is newly created or address in model changed + if should_geocode: + addr_string = ( + (self.street if self.street is not None else "") + + " " + + (str(self.streetnumber) if self.streetnumber is not None else "") + + " " + + (str(self.zip) if self.zip is not None else "") + + " " + + (str(self.city) if self.city is not None else "") + + " Deutschland" + ) + geoloc = geocode(addr_string) + if geoloc: + self.lon = geoloc.longitude + self.lat = geoloc.latitude + + ensureOwner = kwargs.pop("ensureOwner", True) ret = super(Issuer, self).save(*args, **kwargs) - # if no owner staff records exist, create one for created_by - if len(self.owners) < 1 and self.created_by_id: - IssuerStaff.objects.create(issuer=self, user=self.created_by, role=IssuerStaff.ROLE_OWNER) + # The user who created the issuer should always be an owner + if ensureOwner: + self.ensure_owner() + + # if original_image.name != self.image.name: + # for bc in self.cached_badgeclasses(): + # bc.generate_badge_image(self.image) + + if self.verified and not original_verified: + badgr_app = BadgrApp.objects.get_current(None) + try: + email_context = { + "issuer_name": self.name, + "issuer_url": self.url, + "issuer_email": self.email, + "badgr_app": badgr_app, + } + except KeyError as e: + # A property isn't stored right in json + raise e + + template_name = "issuer/email/notify_issuer_verified" + adapter = get_adapter() + adapter.send_mail(template_name, self.email, context=email_context) return ret + def ensure_owner(self): + """Makes sure the issuer has a staff with role owner + + An issuer staff relation is either created with role owner + (if none existed), or updated to contain the role + ROLE_OWNER. + Earlier this also made sure that the creator was the owner; + since this doesn't seem to be required anymore though, + this now merely makes sure that both a creator and an + owner exist (if possible) + """ + + # If there exists both a creator and an owner, there's nothing to do + # (I think; it's not clearly specified) + if ( + self.staff.filter(issuerstaff__role=IssuerStaff.ROLE_OWNER) + and self.created_by + ): + return + + # If there already is an IssuerStaff entry I have to edit it + if ( + self.created_by + and IssuerStaff.objects.filter(user=self.created_by, issuer=self).exists() + ): + issuerStaff = IssuerStaff.objects.get(user=self.created_by, issuer=self) + issuerStaff.role = IssuerStaff.ROLE_OWNER + issuerStaff.save() + return + + # If I don't have a creator, this means they were deleted. + # If there are other users associated, I can chose the one with the highest privileges + if not self.created_by: + owners = self.staff.filter(issuerstaff__role=IssuerStaff.ROLE_OWNER) + editors = self.staff.filter(issuerstaff__role=IssuerStaff.ROLE_EDITOR) + staff = self.staff.filter(issuerstaff__role=IssuerStaff.ROLE_STAFF) + if owners.exists(): + self.created_by = owners.first() + self.save(ensureOwner=False) + # Is already owner + return + elif editors.exists(): + self.created_by = editors.first() + self.save(ensureOwner=False) + elif staff.exists(): + self.created_by = staff.first() + self.save(ensureOwner=False) + else: + # With no other staff, there's nothing we can do. So we unverify the issuer + self.verified = False + self.save(ensureOwner=False) + return + # The new "creator" should also be the owner + issuerStaff = IssuerStaff.objects.get(user=self.created_by, issuer=self) + issuerStaff.role = IssuerStaff.ROLE_OWNER + issuerStaff.save() + return + + # The last remaining case is that the created_by user still exists, but got removed as owner + # In this case there must be no owner assigned currently, so we chose a new owner + editors = self.staff.filter(issuerstaff__role=IssuerStaff.ROLE_EDITOR) + staff = self.staff.filter(issuerstaff__role=IssuerStaff.ROLE_STAFF) + if editors.exists(): + new_owner = editors.first() + elif staff.exists(): + new_owner = staff.first() + else: + # If there is no other user, we (re-)assign the creator as owner. + # This is also the case for the initial creation + new_owner = IssuerStaff.objects.create( + issuer=self, user=self.created_by, role=IssuerStaff.ROLE_OWNER + ) + return + new_owner.role = IssuerStaff.ROLE_OWNER + new_owner.save() + + def new_contact_email(self): + # If this method is called, this may mean that the owner got deleted. + # This implicates that we have to take measures to ensure a new owner is applied. + self.ensure_owner() + # We set the contact email to the first email of the first owner we find + owners = self.staff.filter(issuerstaff__role=IssuerStaff.ROLE_OWNER) + if not owners.exists(): + # Without an owner, there's nothing we can do + return + owner = owners.first() + self.email = owner.primary_email + self.save() + def get_absolute_url(self): - return reverse('issuer_json', kwargs={'entity_id': self.entity_id}) + return reverse("issuer_json", kwargs={"entity_id": self.entity_id}) @property def public_url(self): - return OriginSetting.HTTP+self.get_absolute_url() + return OriginSetting.HTTP + self.get_absolute_url() def image_url(self, public=False): if bool(self.image): if public: - return OriginSetting.HTTP + reverse('issuer_image', kwargs={'entity_id': self.entity_id}) - if getattr(settings, 'MEDIA_URL').startswith('http'): + return OriginSetting.HTTP + reverse( + "issuer_image", kwargs={"entity_id": self.entity_id} + ) + if getattr(settings, "MEDIA_URL").startswith("http"): return default_storage.url(self.image.name) else: - return getattr(settings, 'HTTP_ORIGIN') + default_storage.url(self.image.name) + return getattr(settings, "HTTP_ORIGIN") + default_storage.url( + self.image.name + ) else: return None @@ -323,7 +568,9 @@ def jsonld_id(self): @property def editors(self): - return self.staff.filter(issuerstaff__role__in=(IssuerStaff.ROLE_EDITOR, IssuerStaff.ROLE_OWNER)) + return self.staff.filter( + issuerstaff__role__in=(IssuerStaff.ROLE_EDITOR, IssuerStaff.ROLE_OWNER) + ) @property def owners(self): @@ -343,26 +590,28 @@ def staff_items(self, value): Update this issuers IssuerStaff from a list of IssuerStaffSerializerV2 data """ existing_staff_idx = {s.cached_user: s for s in self.staff_items} - new_staff_idx = {s['cached_user']: s for s in value} + new_staff_idx = {s["cached_user"]: s for s in value} with transaction.atomic(): # add missing staff records for staff_data in value: - if staff_data['cached_user'] not in existing_staff_idx: + if staff_data["cached_user"] not in existing_staff_idx: staff_record, created = IssuerStaff.cached.get_or_create( issuer=self, - user=staff_data['cached_user'], - defaults={ - 'role': staff_data['role'] - }) + user=staff_data["cached_user"], + defaults={"role": staff_data["role"]}, + ) if not created: - staff_record.role = staff_data['role'] + staff_record.role = staff_data["role"] staff_record.save() # remove old staff records -- but never remove the only OWNER role for staff_record in self.staff_items: if staff_record.cached_user not in new_staff_idx: - if staff_record.role != IssuerStaff.ROLE_OWNER or len(self.owners) > 1: + if ( + staff_record.role != IssuerStaff.ROLE_OWNER + or len(self.owners) > 1 + ): staff_record.delete() def get_extensions_manager(self): @@ -371,44 +620,68 @@ def get_extensions_manager(self): @cachemodel.cached_method(auto_publish=True) def cached_editors(self): UserModel = get_user_model() - return UserModel.objects.filter(issuerstaff__issuer=self, issuerstaff__role=IssuerStaff.ROLE_EDITOR) + return UserModel.objects.filter( + issuerstaff__issuer=self, issuerstaff__role=IssuerStaff.ROLE_EDITOR + ) @cachemodel.cached_method(auto_publish=True) def cached_badgeclasses(self): return self.badgeclasses.all().order_by("created_at") + @cachemodel.cached_method(auto_publish=True) + def cached_learningpaths(self): + return self.learningpaths.all().order_by("created_at") + @property def image_preview(self): return self.image - def get_json(self, obi_version=CURRENT_OBI_VERSION, include_extra=True, use_canonical_id=False): + def get_json( + self, + obi_version=CURRENT_OBI_VERSION, + include_extra=True, + use_canonical_id=False, + ): obi_version, context_iri = get_obi_context(obi_version) - json = OrderedDict({'@context': context_iri}) - json.update(OrderedDict( - type='Issuer', - id=self.jsonld_id if use_canonical_id else add_obi_version_ifneeded(self.jsonld_id, obi_version), - name=self.name, - url=self.url, - email=self.email, - description=self.description, - category=self.category, - slug=self.entity_id)) + id = ( + self.jsonld_id + if use_canonical_id + else add_obi_version_ifneeded(self.jsonld_id, obi_version) + ) + + # spread 3_0 context_iri to create a copy because we might modify it later on + json = OrderedDict( + {"@context": [*context_iri] if obi_version == "3_0" else context_iri} + ) + + json.update( + OrderedDict( + type="Issuer", + id=id, + name=self.name, + url=self.url, + email=self.email, + description=self.description, + category=self.category, + slug=self.entity_id, + ) + ) image_url = self.image_url(public=True) - json['image'] = image_url + json["image"] = image_url if self.original_json: - image_info = self.get_original_json().get('image', None) + image_info = self.get_original_json().get("image", None) if isinstance(image_info, dict): - json['image'] = image_info - json['image']['id'] = image_url + json["image"] = image_info + json["image"]["id"] = image_url # source url if self.source_url: - if obi_version == '1_1': + if obi_version == "1_1": json["source_url"] = self.source_url json["hosted_url"] = OriginSetting.HTTP + self.get_absolute_url() - elif obi_version == '2_0': + elif obi_version == "2_0": json["sourceUrl"] = self.source_url json["hostedUrl"] = OriginSetting.HTTP + self.get_absolute_url() @@ -421,17 +694,83 @@ def get_json(self, obi_version=CURRENT_OBI_VERSION, include_extra=True, use_cano if include_extra: extra = self.get_filtered_json() if extra is not None: - for k,v in list(extra.items()): + for k, v in list(extra.items()): if k not in json: json[k] = v + if obi_version == "2_0": + # link to v3 version of profile + json["related"] = [ + { + "type": [ + "https://purl.imsglobal.org/spec/vc/ob/vocab.html#Profile" + ], + "id": add_obi_version_ifneeded(self.jsonld_id, "3_0"), + "version": "Open Badges v3p0", + } + ] + + # add verificationMethod + if obi_version == "3_0": + json["@context"].append("https://www.w3.org/ns/did/v1") + + # link to v2 version of profile + # https://www.imsglobal.org/spec/ob/v3p0/impl#example-issuer-profile-relation-between-open-badges-3-0-and-open-badges-2-0 + json["alsoKnownAs"] = [add_obi_version_ifneeded(self.jsonld_id, "2_0")] + + private_key = serialization.load_pem_private_key( + self.private_key.encode(), settings.SECRET_KEY.encode() + ) + public_key = private_key.public_key() + + # for multicodec + ed01_prefix = b"\xed\x01" + + public_key_bytes = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + public_key_base58 = base58.b58encode( + ed01_prefix + public_key_bytes + ).decode() + + # z prefix for multibase 58 + public_key_multibase = f"z{public_key_base58}" + + # FIXME: needed for current version of https://github.com/1EdTech/digital-credentials-public-validator/ to work.. + json["controller"] = "" + + # FIXME: this should be a list of dicts according to the spec, but the verificator only supports it this way for now + json["verificationMethod"] = OrderedDict( + { + "id": f"{id}#key-0", + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-rdf-2022", + "controller": id, + "publicKeyMultibase": public_key_multibase, + } + ) + return json @property def json(self): return self.get_json() - def get_filtered_json(self, excluded_fields=('@context', 'id', 'type', 'name', 'url', 'description', 'image', 'email')): + def get_filtered_json( + self, + excluded_fields=( + "@context", + "id", + "type", + "name", + "url", + "description", + "image", + "email", + ), + ): return super(Issuer, self).get_filtered_json(excluded_fields=excluded_fields) @property @@ -439,8 +778,12 @@ def cached_badgrapp(self): id = self.badgrapp_id if self.badgrapp_id else None return BadgrApp.objects.get_by_id_or_default(badgrapp_id=id) - def has_nonrevoked_assertions(self): - return self.badgeinstance_set.filter(revoked=False).exists() + @property + def partner_issuers(self): + """Get all issuers that are partners of this network""" + if not self.is_network: + return Issuer.objects.none() + return Issuer.objects.filter(network_memberships__network=self) def notify_admins(self, badgr_app=None, renotify=False): """ @@ -452,7 +795,6 @@ def notify_admins(self, badgr_app=None, renotify=False): if badgr_app is None: badgr_app = BadgrApp.objects.get_current(None) - print(badgr_app) UserModel = get_user_model() users = UserModel.objects.filter(is_staff=True) @@ -461,44 +803,91 @@ def notify_admins(self, badgr_app=None, renotify=False): # 'badge_id': self.entity_id, # 'badge_description': self.badgeclass.description, # 'help_email': getattr(settings, 'HELP_EMAIL', 'help@badgr.io'), - 'issuer_name': re.sub(r'[^\w\s]+', '', self.name, 0, re.I), - 'users': users, + "issuer_name": self.name, + "users": users, # 'issuer_email': self.issuer.email, # 'issuer_detail': self.issuer.public_url, # 'issuer_image_url': issuer_image_url, # 'badge_instance_url': self.public_url, # 'image_url': self.public_url + '/image?type=png', # 'download_url': self.public_url + "?action=download", - 'site_name': "mybadges.org" + "site_name": "Open Educational Badges", # 'badgr_app': badgr_app } - - template_name = 'issuer/email/notify_admins' + # Notify admin whether issuer was automatically verified or needs to be verified manually + if self.verified: + template_name = "issuer/email/notify_admins_issuer_verified" + else: + template_name = "issuer/email/notify_admins" adapter = get_adapter() for user in users: adapter.send_mail(template_name, user.email, context=email_context) - + + +class NetworkMembership(models.Model): + network = models.ForeignKey( + Issuer, + on_delete=models.CASCADE, + related_name="memberships", + ) + issuer = models.ForeignKey( + Issuer, + on_delete=models.CASCADE, + related_name="network_memberships", + ) + + class Meta: + unique_together = ("network", "issuer") + + +class NetworkInvite(BaseVersionedEntity): + class Status(models.TextChoices): + PENDING = "Pending", "Pending" + APPROVED = "Approved", "Approved" + REJECTED = "Rejected", "Rejected" + REVOKED = "Revoked", "Revoked" + + network = models.ForeignKey( + Issuer, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="invites", + ) + issuer = models.ForeignKey(Issuer, blank=True, null=True, on_delete=models.CASCADE) + invitedOn = models.DateTimeField(blank=False, null=False, default=timezone.now) + acceptedOn = models.DateTimeField(blank=True, null=True, default=None) + status = models.CharField( + max_length=254, choices=Status.choices, default=Status.PENDING + ) + revoked = models.BooleanField(default=False, db_index=True) + + def revoke(self): + if self.revoked: + raise ValidationError("Invitation is already revoked") + + self.revoked = True + self.status = self.Status.REVOKED + self.save() class IssuerStaff(cachemodel.CacheModel): - ROLE_OWNER = 'owner' - ROLE_EDITOR = 'editor' - ROLE_STAFF = 'staff' + ROLE_OWNER = "owner" + ROLE_EDITOR = "editor" + ROLE_STAFF = "staff" ROLE_CHOICES = ( - (ROLE_OWNER, 'Owner'), - (ROLE_EDITOR, 'Editor'), - (ROLE_STAFF, 'Staff'), - ) - issuer = models.ForeignKey(Issuer, - on_delete=models.CASCADE) - user = models.ForeignKey(AUTH_USER_MODEL, - on_delete=models.CASCADE) + (ROLE_OWNER, "Owner"), + (ROLE_EDITOR, "Editor"), + (ROLE_STAFF, "Staff"), + ) + issuer = models.ForeignKey(Issuer, on_delete=models.CASCADE) + user = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE) role = models.CharField(max_length=254, choices=ROLE_CHOICES, default=ROLE_STAFF) class Meta: - unique_together = ('issuer', 'user') + unique_together = ("issuer", "user") def publish(self): super(IssuerStaff, self).publish() @@ -506,15 +895,32 @@ def publish(self): self.user.publish() def delete(self, *args, **kwargs): - publish_issuer = kwargs.pop('publish_issuer', True) + publish_issuer = kwargs.pop("publish_issuer", True) + new_contact = self.is_staff_contact() super(IssuerStaff, self).delete() if publish_issuer: self.issuer.publish(publish_staff=False) self.user.publish() + # Note that this delete method is not called if the user is deleted, + # since the cascade is done on the database level. That means that this logic + # *also* has to be contained in the delete method of the user + if new_contact: + self.issuer.new_contact_email() + + def is_staff_contact(self) -> bool: + # Get verified emails of associated user + user_emails = self.user.verified_emails + # Get email of issuer + issuer_email = self.issuer.email + # Check if overlap exists + if issuer_email is None: + return False + return any(user_email.email == issuer_email for user_email in user_emails) @property def cached_user(self): from badgeuser.models import BadgeUser + return BadgeUser.cached.get(pk=self.user_id) @property @@ -523,70 +929,187 @@ def cached_issuer(self): def get_user_or_none(recipient_id, recipient_type): - from badgeuser.models import UserRecipientIdentifier, CachedEmailAddress + from badgeuser.models import CachedEmailAddress, UserRecipientIdentifier + user = None - if recipient_type == 'email': - verified_email = CachedEmailAddress.objects.filter(verified=True, email=recipient_id).first() + if recipient_type == "email": + verified_email = CachedEmailAddress.objects.filter( + verified=True, email=recipient_id + ).first() if verified_email: user = verified_email.user else: - verified_recipient_id = UserRecipientIdentifier.objects.filter(verified=True, - identifier=recipient_id).first() + verified_recipient_id = UserRecipientIdentifier.objects.filter( + verified=True, identifier=recipient_id + ).first() if verified_recipient_id: user = verified_recipient_id.user return user -class BadgeClass(ResizeUploadedImage, - ScrubUploadedSvgImage, - HashUploadedImage, - PngImagePreview, - BaseAuditedModel, - BaseVersionedEntity, - BaseOpenBadgeObjectModel): - entity_class_name = 'BadgeClass' - COMPARABLE_PROPERTIES = ('criteria_text', 'criteria_url', 'description', 'entity_id', 'entity_version', - 'expires_amount', 'expires_duration', 'name', 'pk', 'slug', 'updated_at',) +class IssuerStaffRequest(BaseVersionedEntity): + class Status(models.TextChoices): + PENDING = "Pending", "Pending" + APPROVED = "Approved", "Approved" + REJECTED = "Rejected", "Rejected" + REVOKED = "Revoked", "Revoked" - EXPIRES_DURATION_DAYS = 'days' - EXPIRES_DURATION_WEEKS = 'weeks' - EXPIRES_DURATION_MONTHS = 'months' - EXPIRES_DURATION_YEARS = 'years' - EXPIRES_DURATION_CHOICES = ( - (EXPIRES_DURATION_DAYS, 'Days'), - (EXPIRES_DURATION_WEEKS, 'Weeks'), - (EXPIRES_DURATION_MONTHS, 'Months'), - (EXPIRES_DURATION_YEARS, 'Years'), + issuer = models.ForeignKey( + Issuer, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="staffrequests", ) + user = models.ForeignKey( + "badgeuser.BadgeUser", blank=True, null=True, on_delete=models.CASCADE + ) + requestedOn = models.DateTimeField(blank=False, null=False, default=timezone.now) + status = models.CharField( + max_length=254, choices=Status.choices, default=Status.PENDING + ) + revoked = models.BooleanField(default=False, db_index=True) + + def revoke(self): + if self.revoked: + raise ValidationError("Membership request is already revoked") + + self.revoked = True + self.status = self.Status.REVOKED + self.save() - issuer = models.ForeignKey(Issuer, blank=False, null=False, on_delete=models.CASCADE, related_name="badgeclasses") +class BadgeClass( + ResizeUploadedImage, + ScrubUploadedSvgImage, + HashUploadedImage, + PngImagePreview, + BaseAuditedModel, + BaseVersionedEntity, + BaseOpenBadgeObjectModel, +): + entity_class_name = "BadgeClass" + COMPARABLE_PROPERTIES = ( + "criteria_text", + "criteria_url", + "description", + "entity_id", + "entity_version", + "expiration", + "name", + "pk", + "slug", + "updated_at", + ) + + issuer = models.ForeignKey( + Issuer, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="badgeclasses", + verbose_name="Bereich" + ) # slug has been deprecated for now, but preserve existing values - slug = models.CharField(max_length=255, db_index=True, blank=True, null=True, default=None) - #slug = AutoSlugField(max_length=255, populate_from='name', unique=True, blank=False, editable=True) + slug = models.CharField( + max_length=255, db_index=True, blank=True, null=True, default=None + ) + # slug = AutoSlugField(max_length=255, populate_from='name', unique=True, blank=False, editable=True) name = models.CharField(max_length=255) - image = models.FileField(upload_to='uploads/badges', blank=True) - image_preview = models.FileField(upload_to='uploads/badges', blank=True, null=True) + image = models.FileField(upload_to="uploads/badges", blank=True) + imageFrame = models.BooleanField(default=True) + image_preview = models.FileField(upload_to="uploads/badges", blank=True, null=True) description = models.TextField(blank=True, null=True, default=None) + # TODO: criteria_url and criteria_text are deprecated criteria_url = models.CharField(max_length=254, blank=True, null=True, default=None) criteria_text = models.TextField(blank=True, null=True) + course_url = models.CharField(max_length=255, blank=True, null=True, default=None) + expiration = models.IntegerField( + blank=True, + null=True, + default=None, + validators=[ + MinValueValidator(1), + MaxValueValidator(36500), # 100 years + ], + help_text="Number of days the badge is valid after being issued.", + ) + + # permissions saved as integer in binary representation + # issuer should always be set + COPY_PERMISSIONS_ISSUER = 0b1 # 1 + COPY_PERMISSIONS_OTHERS = 0b10 # 2 + COPY_PERMISSIONS_NONE = 0b100 # 4 + + COPY_PERMISSIONS_CHOICES = ( + (COPY_PERMISSIONS_ISSUER, "Issuer"), + (COPY_PERMISSIONS_OTHERS, "Everyone"), + (COPY_PERMISSIONS_NONE, "None"), + ) + COPY_PERMISSIONS_KEYS = ("issuer", "others", "none") + copy_permissions = models.PositiveSmallIntegerField(default=COPY_PERMISSIONS_ISSUER) - expires_amount = models.IntegerField(blank=True, null=True, default=None) - expires_duration = models.CharField(max_length=254, choices=EXPIRES_DURATION_CHOICES, blank=True, null=True, default=None) + criteria = models.JSONField(blank=True, null=True) old_json = JSONField() objects = BadgeClassManager() - cached = SlugOrJsonIdCacheModelManager(slug_kwarg_name='entity_id', slug_field_name='entity_id') + cached = SlugOrJsonIdCacheModelManager( + slug_kwarg_name="entity_id", slug_field_name="entity_id" + ) + + # This is the "Area" tag field we are adding + areas = models.ManyToManyField(Area, blank=True, related_name="badgeclasses",verbose_name="Bereich") class Meta: verbose_name_plural = "Badge classes" + def save(self, *args, **kwargs): + self.clean() + return super().save(*args, **kwargs) + + def clean(self): + # Check if the issuer for this badge is verified, otherwise throw an error + if not self.issuer.verified: + raise ValidationError( + "Only verified issuers can create / update badges", code="invalid" + ) + + def generate_badge_image( + self, + category, + badge_image, + issuer_image=None, + network_image=None, + ): + """Generate composed badge image from original image""" + + composer = ImageComposer(category=category) + + image_b64 = composer.compose_badge_from_uploaded_image( + badge_image, issuer_image, network_image, draw_frame=self.imageFrame + ) + + if not image_b64: + raise ValueError("Badge image generation failed") + + if image_b64.startswith("data:image/png;base64,"): + image_b64 = image_b64.split(",", 1)[1] + + image_data = base64.b64decode(image_b64) + + filename = f"issuer_badgeclass_{uuid.uuid4()}.png" + content_file = ContentFile(image_data, name=filename) + + self.image.save(filename, content_file, save=False) + def publish(self): - fields_cache = self._state.fields_cache # stash the fields cache to avoid publishing related objects here + fields_cache = ( + self._state.fields_cache + ) # stash the fields cache to avoid publishing related objects here self._state.fields_cache = dict() super(BadgeClass, self).publish() self.issuer.publish(publish_staff=False) @@ -597,9 +1120,18 @@ def publish(self): def delete(self, *args, **kwargs): # if there are some assertions that have not expired - if self.badgeinstances.filter(revoked=False).filter( - models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())).exists(): - raise ProtectedError("BadgeClass may only be deleted if all BadgeInstances have been revoked.", self) + if ( + self.badgeinstances.filter(revoked=False) + .filter( + models.Q(expires_at__isnull=True) + | models.Q(expires_at__gt=timezone.now()) + ) + .exists() + ): + raise ProtectedError( + "BadgeClass may only be deleted if all BadgeInstances have been revoked.", + self, + ) issuer = self.issuer super(BadgeClass, self).delete(*args, **kwargs) @@ -607,16 +1139,18 @@ def delete(self, *args, **kwargs): def schedule_image_update_task(self): from issuer.tasks import rebake_all_assertions_for_badge_class - batch_size = getattr(settings, 'BADGE_ASSERTION_AUTO_REBAKE_BATCH_SIZE', 100) - rebake_all_assertions_for_badge_class.delay(self.pk, limit=batch_size, replay=True) - def get_absolute_url(self): - return reverse('badgeclass_json', kwargs={'entity_id': self.entity_id}) + batch_size = getattr(settings, "BADGE_ASSERTION_AUTO_REBAKE_BATCH_SIZE", 100) + rebake_all_assertions_for_badge_class.delay( + self.pk, limit=batch_size, replay=True + ) + def get_absolute_url(self): + return reverse("badgeclass_json", kwargs={"entity_id": self.entity_id}) @property def public_url(self): - return OriginSetting.HTTP+self.get_absolute_url() + return OriginSetting.HTTP + self.get_absolute_url() @property def jsonld_id(self): @@ -631,7 +1165,9 @@ def issuer_jsonld_id(self): def get_criteria_url(self): if self.criteria_url: return self.criteria_url - return OriginSetting.HTTP+reverse('badgeclass_criteria', kwargs={'entity_id': self.entity_id}) + return OriginSetting.HTTP + reverse( + "badgeclass_criteria", kwargs={"entity_id": self.entity_id} + ) @property def description_nonnull(self): @@ -653,12 +1189,18 @@ def has_nonrevoked_assertions(self): return self.badgeinstances.filter(revoked=False).exists() """ - Included for legacy purposes. It is inefficient to routinely call this for badge classes with large numbers of assertions. + Included for legacy purposes. It is inefficient to routinely call this for + badge classes with large numbers of assertions. """ + @property def v1_api_recipient_count(self): return self.badgeinstances.filter(revoked=False).count() + @property + def v1_api_recipient_count_issuer(self): + return self.badgeinstances.filter(revoked=False, issuer=self.issuer).count() + @cachemodel.cached_method(auto_publish=True) def cached_alignments(self): return self.badgeclassalignment_set.all() @@ -671,7 +1213,13 @@ def alignment_items(self): def alignment_items(self, value): if value is None: value = [] - keys = ['target_name','target_url','target_description','target_framework', 'target_code'] + keys = [ + "target_name", + "target_url", + "target_description", + "target_framework", + "target_code", + ] def _identity(align): """build a unique identity from alignment json""" @@ -731,155 +1279,429 @@ def tag_items(self, value): def get_extensions_manager(self): return self.badgeclassextension_set - def issue(self, recipient_id=None, evidence=None, narrative=None, notify=False, created_by=None, allow_uppercase=False, badgr_app=None, recipient_type=RECIPIENT_TYPE_EMAIL, **kwargs): + def issue( + self, + recipient_id=None, + evidence=None, + narrative=None, + notify=False, + created_by=None, + allow_uppercase=False, + badgr_app=None, + recipient_type=RECIPIENT_TYPE_EMAIL, + microdegree_id=None, + issuerSlug=None, + activity_start_date=None, + activity_end_date=None, + activity_zip=None, + activity_city=None, + activity_online=False, + course_url="", + **kwargs, + ): return BadgeInstance.objects.create( - badgeclass=self, recipient_identifier=recipient_id, recipient_type=recipient_type, - narrative=narrative, evidence=evidence, - notify=notify, created_by=created_by, allow_uppercase=allow_uppercase, + badgeclass=self, + recipient_identifier=recipient_id, + recipient_type=recipient_type, + narrative=narrative, + evidence=evidence, + notify=notify, + created_by=created_by, + allow_uppercase=allow_uppercase, badgr_app=badgr_app, + microdegree_id=microdegree_id, + issuerSlug=issuerSlug, user=get_user_or_none(recipient_id, recipient_type), - **kwargs + activity_start_date=activity_start_date, + activity_end_date=activity_end_date, + activity_zip=activity_zip, + activity_city=activity_city, + activity_online=activity_online, + course_url=course_url, + **kwargs, ) def image_url(self, public=False): if public: - return OriginSetting.HTTP + reverse('badgeclass_image', kwargs={'entity_id': self.entity_id}) + return OriginSetting.HTTP + reverse( + "badgeclass_image", kwargs={"entity_id": self.entity_id} + ) - if getattr(settings, 'MEDIA_URL').startswith('http'): + if getattr(settings, "MEDIA_URL").startswith("http"): return default_storage.url(self.image.name) else: - return getattr(settings, 'HTTP_ORIGIN') + default_storage.url(self.image.name) - + return getattr(settings, "HTTP_ORIGIN") + default_storage.url( + self.image.name + ) + def get_criteria(self): + try: + categoryExtension = self.cached_extensions().get( + name="extensions:CategoryExtension" + ) + except Exception: + return None - def get_json(self, obi_version=CURRENT_OBI_VERSION, include_extra=True, use_canonical_id=False): + category = json_loads(categoryExtension.original_json) + if self.criteria: + return self.criteria + elif category["Category"] == "competency": + competencyExtensions = {} + + if len(self.cached_extensions()) > 0: + for extension in self.cached_extensions(): + if extension.name == "extensions:CompetencyExtension": + competencyExtensions[extension.name] = json_loads( + extension.original_json + ) + + competencies = [] + + for competency in competencyExtensions.get( + "extensions:CompetencyExtension", [] + ): + competencies.append(competency.get("name")) + + md = f""" + *Folgende Kriterien sind auf Basis deiner Eingaben als Metadaten im Badge hinterlegt*: + Du hast erfolgreich an **{self.name}** teilgenommen. + Dabei hast du folgende Kompetenzen gestärkt: + """ + for comp in competencies: + md += f"- {comp}\n" + + return md.strip() + else: + return f""" + *Folgende Kriterien sind auf Basis deiner Eingaben als Metadaten im Badge hinterlegt*: + Du hast erfolgreich an **{self.name}** teilgenommen. + """ + + def get_json( + self, + obi_version=CURRENT_OBI_VERSION, + include_extra=True, + use_canonical_id=False, + include_orgImg=False, + ): obi_version, context_iri = get_obi_context(obi_version) - json = OrderedDict({'@context': context_iri}) - json.update(OrderedDict( - type='BadgeClass', - id=self.jsonld_id if use_canonical_id else add_obi_version_ifneeded(self.jsonld_id, obi_version), - name=self.name, - description=self.description_nonnull, - issuer=self.cached_issuer.jsonld_id if use_canonical_id else add_obi_version_ifneeded(self.cached_issuer.jsonld_id, obi_version), - )) + json = OrderedDict( + {"@context": [*context_iri] if obi_version == "3_0" else context_iri} + ) + json.update( + OrderedDict( + type="BadgeClass", + id=( + self.jsonld_id + if use_canonical_id + else add_obi_version_ifneeded(self.jsonld_id, obi_version) + ), + name=self.name, + description=self.description_nonnull, + copy_permissions=self.copy_permissions_list, + issuer=( + self.cached_issuer.jsonld_id + if use_canonical_id + else add_obi_version_ifneeded( + self.cached_issuer.jsonld_id, obi_version + ) + ), + created_at=self.created_at, + ) + ) + + json["slug"] = self.entity_id # image if self.image: image_url = self.image_url(public=True) - json['image'] = image_url + json["image"] = image_url if self.original_json: original_json = self.get_original_json() if original_json is not None: - image_info = original_json.get('image', None) + image_info = original_json.get("image", None) if isinstance(image_info, dict): - json['image'] = image_info - json['image']['id'] = image_url + json["image"] = image_info + json["image"]["id"] = image_url # criteria - if obi_version == '1_1': + if obi_version == "1_1": json["criteria"] = self.get_criteria_url() - elif obi_version == '2_0': + elif obi_version == "2_0" or obi_version == "3_0": json["criteria"] = {} if self.criteria_url: - json['criteria']['id'] = self.criteria_url - if self.criteria_text: - json['criteria']['narrative'] = self.criteria_text + json["criteria"]["id"] = self.criteria_url + json["criteria"]["narrative"] = self.get_criteria() # source_url if self.source_url: - if obi_version == '1_1': + if obi_version == "1_1": json["source_url"] = self.source_url json["hosted_url"] = OriginSetting.HTTP + self.get_absolute_url() - elif obi_version == '2_0': + elif obi_version == "2_0": json["sourceUrl"] = self.source_url json["hostedUrl"] = OriginSetting.HTTP + self.get_absolute_url() # alignment / tags - if obi_version == '2_0': - json['alignment'] = [ a.get_json(obi_version=obi_version) for a in self.cached_alignments() ] - json['tags'] = list(t.name for t in self.cached_tags()) + if obi_version == "2_0" or obi_version == "3_0": + json["alignment"] = [ + a.get_json(obi_version=obi_version) for a in self.cached_alignments() + ] + json["tags"] = list(t.name for t in self.cached_tags()) # extensions if len(self.cached_extensions()) > 0: for extension in self.cached_extensions(): - json[extension.name] = json_loads(extension.original_json) + if ( + not include_orgImg + and extension.name != "extensions:OrgImageExtension" + ): + json[extension.name] = json_loads(extension.original_json) # pass through imported json if include_extra: extra = self.get_filtered_json() if extra is not None: - for k,v in list(extra.items()): + for k, v in list(extra.items()): if k not in json: json[k] = v + if obi_version == "2_0": + # add relation to version 3.0 + json["related"] = [ + { + "type": [ + "https://purl.imsglobal.org/spec/vc/ob/vocab.html#Achievement" + ], + "id": add_obi_version_ifneeded(self.jsonld_id, "3_0"), + "version": "Open Badges v3p0", + } + ] + + if obi_version == "3_0": + json["type"] = ["Achievement", "https://w3id.org/openbadges#BadgeClass"] + + # link to version v2 + # https://www.imsglobal.org/spec/ob/v3p0/impl#example-openbadges-3-0-achievement-with-linked-openbadges-2-0-badgeclass-via-related-association + json["related"] = [ + { + "type": ["Related", "https://w3id.org/openbadges#BadgeClass"], + "id": add_obi_version_ifneeded(self.jsonld_id, "2_0"), + "version": "Open Badges v2p0", + } + ] + return json @property def json(self): return self.get_json() - def get_filtered_json(self, excluded_fields=('@context', 'id', 'type', 'name', 'description', 'image', 'criteria', 'issuer')): - return super(BadgeClass, self).get_filtered_json(excluded_fields=excluded_fields) + def get_filtered_json( + self, + excluded_fields=( + "@context", + "id", + "type", + "name", + "description", + "image", + "criteria", + "issuer", + ), + ): + return super(BadgeClass, self).get_filtered_json( + excluded_fields=excluded_fields + ) @property def cached_badgrapp(self): return self.cached_issuer.cached_badgrapp def generate_expires_at(self, issued_on=None): - if not self.expires_duration or not self.expires_amount: + if not self.expiration: return None if issued_on is None: issued_on = timezone.now() - duration_kwargs = dict() - duration_kwargs[self.expires_duration] = self.expires_amount - return issued_on + dateutil.relativedelta.relativedelta(**duration_kwargs) + return issued_on + timezone.timedelta(days=self.expiration) + + @property + def copy_permissions_list(self): + # turn db value into string[] using keys + binary = bin(self.copy_permissions)[:1:-1] + return [self.COPY_PERMISSIONS_KEYS[i] for i, x in enumerate(binary) if int(x)] + + @copy_permissions_list.setter + def copy_permissions_list(self, value): + if not value: + self.copy_permissions = 0 + else: + # turn string[] of KEYS into db value + binary_map = [ + (1 if x in value else 0) << i + for i, x in enumerate(self.COPY_PERMISSIONS_KEYS) + ] + print(binary_map) + self.copy_permissions = sum(map(int, binary_map)) + + +class ImportedBadgeAssertion( + BaseVersionedEntity, BaseAuditedModel, BaseOpenBadgeObjectModel +): + """ + Model for storing imported badges separately from the system's own badges. + This keeps external badge data isolated from internal data models. + """ + + user = models.ForeignKey( + "badgeuser.BadgeUser", blank=True, null=True, on_delete=models.SET_NULL + ) + + badge_name = models.CharField(max_length=255) + badge_description = models.TextField(blank=True, null=True) + badge_criteria_url = models.URLField(blank=True, null=True) + badge_image_url = models.URLField(blank=True, null=True) + + image = models.FileField(upload_to="uploads/badges", blank=True) + + issuer_name = models.CharField(max_length=255) + issuer_url = models.URLField() + issuer_email = models.EmailField(blank=True, null=True) + issuer_image_url = models.URLField(blank=True, null=True) + + issued_on = models.DateTimeField() + expires_at = models.DateTimeField(blank=True, null=True) + + RECIPIENT_TYPE_EMAIL = "email" + RECIPIENT_TYPE_ID = "openBadgeId" + RECIPIENT_TYPE_TELEPHONE = "telephone" + RECIPIENT_TYPE_URL = "url" + + RECIPIENT_TYPE_CHOICES = ( + (RECIPIENT_TYPE_EMAIL, "email"), + (RECIPIENT_TYPE_ID, "openBadgeId"), + (RECIPIENT_TYPE_TELEPHONE, "telephone"), + (RECIPIENT_TYPE_URL, "url"), + ) + + recipient_identifier = models.CharField(max_length=320, db_index=True) + recipient_type = models.CharField( + max_length=255, choices=RECIPIENT_TYPE_CHOICES, default=RECIPIENT_TYPE_EMAIL + ) + + ACCEPTANCE_UNACCEPTED = "Unaccepted" + ACCEPTANCE_ACCEPTED = "Accepted" + ACCEPTANCE_REJECTED = "Rejected" + ACCEPTANCE_CHOICES = ( + (ACCEPTANCE_UNACCEPTED, "Unaccepted"), + (ACCEPTANCE_ACCEPTED, "Accepted"), + (ACCEPTANCE_REJECTED, "Rejected"), + ) + acceptance = models.CharField( + max_length=254, choices=ACCEPTANCE_CHOICES, default=ACCEPTANCE_ACCEPTED + ) + + revoked = models.BooleanField(default=False) + revocation_reason = models.CharField(max_length=255, blank=True, null=True) + + original_json = JSONField() + + hashed = models.BooleanField(default=True) + salt = models.CharField(max_length=254, blank=True, null=True, default=None) + + narrative = models.TextField(blank=True, null=True) + + verification_url = models.URLField(blank=True, null=True) + + class Meta: + verbose_name = "Imported Badge Assertion" + def image_url(self): + if self.image: + return self.image.url + return self.badge_image_url -class BadgeInstance(BaseAuditedModel, - BaseVersionedEntity, - BaseOpenBadgeObjectModel): - entity_class_name = 'Assertion' - COMPARABLE_PROPERTIES = ('badgeclass_id', 'entity_id', 'entity_version', 'issued_on', 'pk', 'narrative', - 'recipient_identifier', 'recipient_type', 'revoked', 'revocation_reason', 'updated_at',) + def get_extensions_manager(self): + return self.importedbadgeassertionextension_set + + +class BadgeInstance(BaseAuditedModel, BaseVersionedEntity, BaseOpenBadgeObjectModel): + entity_class_name = "Assertion" + COMPARABLE_PROPERTIES = ( + "badgeclass_id", + "entity_id", + "entity_version", + "issued_on", + "pk", + "narrative", + "recipient_identifier", + "recipient_type", + "revoked", + "revocation_reason", + "updated_at", + ) issued_on = models.DateTimeField(blank=False, null=False, default=timezone.now) - badgeclass = models.ForeignKey(BadgeClass, blank=False, null=False, on_delete=models.CASCADE, related_name='badgeinstances') - issuer = models.ForeignKey(Issuer, blank=False, null=False, - on_delete=models.CASCADE) - user = models.ForeignKey('badgeuser.BadgeUser', blank=True, null=True, on_delete=models.SET_NULL) + badgeclass = models.ForeignKey( + BadgeClass, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="badgeinstances", + ) + issuer = models.ForeignKey( + Issuer, blank=False, null=False, on_delete=models.CASCADE + ) + user = models.ForeignKey( + "badgeuser.BadgeUser", blank=True, null=True, on_delete=models.SET_NULL + ) RECIPIENT_TYPE_CHOICES = ( - (RECIPIENT_TYPE_EMAIL, 'email'), - (RECIPIENT_TYPE_ID, 'openBadgeId'), - (RECIPIENT_TYPE_TELEPHONE, 'telephone'), - (RECIPIENT_TYPE_URL, 'url'), + (RECIPIENT_TYPE_EMAIL, "email"), + (RECIPIENT_TYPE_ID, "openBadgeId"), + (RECIPIENT_TYPE_TELEPHONE, "telephone"), + (RECIPIENT_TYPE_URL, "url"), + ) + recipient_identifier = models.CharField( + max_length=320, blank=False, null=False, db_index=True + ) + recipient_type = models.CharField( + max_length=255, + choices=RECIPIENT_TYPE_CHOICES, + default=RECIPIENT_TYPE_EMAIL, + blank=False, + null=False, ) - recipient_identifier = models.CharField(max_length=768, blank=False, null=False, db_index=True) - recipient_type = models.CharField(max_length=255, choices=RECIPIENT_TYPE_CHOICES, default=RECIPIENT_TYPE_EMAIL, blank=False, null=False) - image = models.FileField(upload_to='uploads/badges', blank=True) + image = models.FileField(upload_to="uploads/badges", blank=True) + course_url = models.CharField(max_length=255, blank=True, null=True, default=None) # slug has been deprecated for now, but preserve existing values - slug = models.CharField(max_length=255, db_index=True, blank=True, null=True, default=None) - #slug = AutoSlugField(max_length=255, populate_from='get_new_slug', unique=True, blank=False, editable=False) + slug = models.CharField( + max_length=255, db_index=True, blank=True, null=True, default=None + ) revoked = models.BooleanField(default=False, db_index=True) - revocation_reason = models.CharField(max_length=255, blank=True, null=True, default=None) + revocation_reason = models.CharField( + max_length=255, blank=True, null=True, default=None + ) expires_at = models.DateTimeField(blank=True, null=True, default=None) - ACCEPTANCE_UNACCEPTED = 'Unaccepted' - ACCEPTANCE_ACCEPTED = 'Accepted' - ACCEPTANCE_REJECTED = 'Rejected' + ACCEPTANCE_UNACCEPTED = "Unaccepted" + ACCEPTANCE_ACCEPTED = "Accepted" + ACCEPTANCE_REJECTED = "Rejected" ACCEPTANCE_CHOICES = ( - (ACCEPTANCE_UNACCEPTED, 'Unaccepted'), - (ACCEPTANCE_ACCEPTED, 'Accepted'), - (ACCEPTANCE_REJECTED, 'Rejected'), + (ACCEPTANCE_UNACCEPTED, "Unaccepted"), + (ACCEPTANCE_ACCEPTED, "Accepted"), + (ACCEPTANCE_REJECTED, "Rejected"), + ) + acceptance = models.CharField( + max_length=254, choices=ACCEPTANCE_CHOICES, default=ACCEPTANCE_UNACCEPTED ) - acceptance = models.CharField(max_length=254, choices=ACCEPTANCE_CHOICES, default=ACCEPTANCE_UNACCEPTED) hashed = models.BooleanField(default=True) salt = models.CharField(max_length=254, blank=True, null=True, default=None) @@ -889,33 +1711,65 @@ class BadgeInstance(BaseAuditedModel, old_json = JSONField() objects = BadgeInstanceManager() - cached = SlugOrJsonIdCacheModelManager(slug_kwarg_name='entity_id', slug_field_name='entity_id') + cached = SlugOrJsonIdCacheModelManager( + slug_kwarg_name="entity_id", slug_field_name="entity_id" + ) + + activity_start_date = models.DateTimeField( + blank=True, + null=True, + default=None, + help_text="The datetime the activity/course started", + ) + activity_end_date = models.DateTimeField( + blank=True, + null=True, + default=None, + help_text="The datetime the activity/course ended", + ) + + activity_zip = models.CharField(max_length=255, null=True, blank=True, default=None) + activity_city = models.CharField( + max_length=255, null=True, blank=True, default=None + ) + activity_online = models.BooleanField(blank=True, null=False, default=False) + + ob_json_2_0 = models.TextField(blank=True, null=True, default=None) + ob_json_3_0 = models.TextField(blank=True, null=True, default=None) class Meta: - index_together = ( - ('recipient_identifier', 'badgeclass', 'revoked'), - ) + indexes = [ + models.Index(fields=["recipient_identifier", "badgeclass", "revoked"]) + ] @property def extended_json(self): extended_json = self.json - extended_json['badge'] = self.badgeclass.json - extended_json['badge']['issuer'] = self.issuer.json + extended_json["badge"] = self.badgeclass.json + extended_json["badge"]["issuer"] = self.issuer.json return extended_json def image_url(self, public=False): if public: - return OriginSetting.HTTP + reverse('badgeinstance_image', kwargs={'entity_id': self.entity_id}) - if getattr(settings, 'MEDIA_URL').startswith('http'): + return OriginSetting.HTTP + reverse( + "badgeinstance_image", kwargs={"entity_id": self.entity_id} + ) + if getattr(settings, "MEDIA_URL").startswith("http"): return default_storage.url(self.image.name) else: - return getattr(settings, 'HTTP_ORIGIN') + default_storage.url(self.image.name) + return getattr(settings, "HTTP_ORIGIN") + default_storage.url( + self.image.name + ) def get_share_url(self, include_identifier=False): url = self.share_url if include_identifier: - url = '%s?identity__%s=%s' % (url, self.recipient_type, urllib.parse.quote(self.recipient_identifier)) + url = "%s?identity__%s=%s" % ( + url, + self.recipient_type, + urllib.parse.quote(self.recipient_identifier), + ) return url @property @@ -932,7 +1786,12 @@ def cached_badgeclass(self): return BadgeClass.cached.get(pk=self.badgeclass_id) def get_absolute_url(self): - return reverse('badgeinstance_json', kwargs={'entity_id': self.entity_id}) + return reverse("badgeinstance_json", kwargs={"entity_id": self.entity_id}) + + def get_absolute_backpack_url(self): + return reverse( + "v1_api_localbadgeinstance_detail", kwargs={"slug": self.entity_id} + ) @property def jsonld_id(self): @@ -950,7 +1809,7 @@ def issuer_jsonld_id(self): @property def public_url(self): - return OriginSetting.HTTP+self.get_absolute_url() + return OriginSetting.HTTP + self.get_absolute_url() @property def owners(self): @@ -959,17 +1818,25 @@ def owners(self): @property def pending(self): """ - If the associated identifier for this BadgeInstance - does not exist or is unverified the BadgeInstance is - considered "pending" + If the associated identifier for this BadgeInstance + does not exist or is unverified the BadgeInstance is + considered "pending" """ from badgeuser.models import CachedEmailAddress, UserRecipientIdentifier + try: if self.recipient_type == RECIPIENT_TYPE_EMAIL: - existing_identifier = CachedEmailAddress.cached.get(email=self.recipient_identifier) + existing_identifier = CachedEmailAddress.cached.get( + email=self.recipient_identifier + ) else: - existing_identifier = UserRecipientIdentifier.cached.get(identifier=self.recipient_identifier) - except (UserRecipientIdentifier.DoesNotExist, CachedEmailAddress.DoesNotExist,): + existing_identifier = UserRecipientIdentifier.cached.get( + identifier=self.recipient_identifier + ) + except ( + UserRecipientIdentifier.DoesNotExist, + CachedEmailAddress.DoesNotExist, + ): return False if not self.source_url: @@ -980,8 +1847,14 @@ def pending(self): def save(self, *args, **kwargs): if self.pk is None: # First check if recipient is in the blacklist - if blacklist.api_query_is_in_blacklist(self.recipient_type, self.recipient_identifier): - logger.event(badgrlog.BlacklistAssertionNotCreatedEvent(self)) + if blacklist.api_query_is_in_blacklist( + self.recipient_type, self.recipient_identifier + ): + logger.warning( + "The recipient '%s' is in the blacklist for this ('%s') badge class", + self.recipient_identifier, + self.badgeclass.entity_id, + ) raise ValidationError("You may not award this badge to this recipient.") self.salt = uuid.uuid4().hex @@ -994,18 +1867,30 @@ def save(self, *args, **kwargs): if not self.image: badgeclass_name, ext = os.path.splitext(self.badgeclass.image.file.name) new_image = io.BytesIO() - bake(image_file=self.cached_badgeclass.image.file, - assertion_json_string=json_dumps(self.get_json(obi_version=UNVERSIONED_BAKED_VERSION), indent=2), - output_file=new_image) - self.image.save(name='assertion-{id}{ext}'.format(id=self.entity_id, ext=ext), - content=ContentFile(new_image.read()), - save=False) + bake( + image_file=self.cached_badgeclass.image.file, + assertion_json_string=json_dumps( + self.get_json(obi_version=UNVERSIONED_BAKED_VERSION), indent=2 + ), + output_file=new_image, + ) + self.image.save( + name="assertion-{id}{ext}".format(id=self.entity_id, ext=ext), + content=ContentFile(new_image.read()), + save=False, + ) try: from badgeuser.models import CachedEmailAddress - existing_email = CachedEmailAddress.cached.get(email=self.recipient_identifier) - if self.recipient_identifier != existing_email.email and \ - self.recipient_identifier not in [e.email for e in existing_email.cached_variants()]: + + existing_email = CachedEmailAddress.cached.get( + email=self.recipient_identifier + ) + if ( + self.recipient_identifier != existing_email.email + and self.recipient_identifier + not in [e.email for e in existing_email.cached_variants()] + ): existing_email.add_variant(self.recipient_identifier) except CachedEmailAddress.DoesNotExist: pass @@ -1015,15 +1900,22 @@ def save(self, *args, **kwargs): super(BadgeInstance, self).save(*args, **kwargs) - def rebake(self, obi_version=CURRENT_OBI_VERSION, save=True): + def rebake(self, obi_version=UNVERSIONED_BAKED_VERSION, save=True): new_image = io.BytesIO() bake( image_file=self.cached_badgeclass.image.file, - assertion_json_string=json_dumps(self.get_json(obi_version=obi_version), indent=2), - output_file=new_image + assertion_json_string=json_dumps( + self.get_json(obi_version=obi_version), indent=2 + ), + output_file=new_image, ) - new_filename = generate_rebaked_filename(self.image.name, self.cached_badgeclass.image.name) + new_filename = generate_rebaked_filename( + self.image.name, self.cached_badgeclass.image.name + ) + new_filename = self.image.field.generate_filename( + self.image.instance, new_filename + ) new_name = default_storage.save(new_filename, ContentFile(new_image.read())) default_storage.delete(self.image.name) self.image.name = new_name @@ -1031,7 +1923,9 @@ def rebake(self, obi_version=CURRENT_OBI_VERSION, save=True): self.save() def publish(self): - fields_cache = self._state.fields_cache # stash the fields cache to avoid publishing related objects here + fields_cache = ( + self._state.fields_cache + ) # stash the fields cache to avoid publishing related objects here self._state.fields_cache = dict() super(BadgeInstance, self).publish() @@ -1043,7 +1937,7 @@ def publish(self): for collection in self.backpackcollection_set.all(): collection.publish() - self.publish_by('entity_id', 'revoked') + self.publish_by("entity_id", "revoked") self._state.fields_cache = fields_cache # restore the stashed fields cache def delete(self, *args, **kwargs): @@ -1053,7 +1947,7 @@ def delete(self, *args, **kwargs): badgeclass.publish() if self.recipient_user: self.recipient_user.publish() - self.publish_delete('entity_id', 'revoked') + self.publish_delete("entity_id", "revoked") def revoke(self, revocation_reason): if self.revoked: @@ -1067,10 +1961,47 @@ def revoke(self, revocation_reason): self.image.delete() self.save() - def notify_earner(self, badgr_app=None, renotify=False): + # TODO: Use email related to the new domain, when one is created. Not urgent in this phase. + def notify_earner(self, badgr_app=None, renotify=False, microdegree_id=None): """ Sends an email notification to the badge recipient. """ + + categoryExtension = None + + competencyExtensions = {} + + if len(self.badgeclass.cached_extensions()) > 0: + for extension in self.badgeclass.cached_extensions(): + if extension.name == "extensions:CompetencyExtension": + competencyExtensions[extension.name] = json_loads( + extension.original_json + ) + if extension.name == "extensions:CategoryExtension": + categoryExtension = json_loads(extension.original_json) + + competencies = [] + + for competency in competencyExtensions.get( + "extensions:CompetencyExtension", [] + ): + studyload = competency.get("studyLoad") + studyloadFmt = "%s:%s h" % ( + math.floor(studyload / 60), + str(studyload % 60).zfill(2), + ) + + competency_entry = { + "name": competency.get("name"), + "description": competency.get("description"), + "framework": competency.get("framework"), + "framework_identifier": competency.get("framework_identifier"), + "source": competency.get("source"), + "studyLoad": studyloadFmt, + "skill": competency.get("category"), + } + competencies.append(competency_entry) + if self.recipient_type != RECIPIENT_TYPE_EMAIL: return @@ -1080,7 +2011,15 @@ def notify_earner(self, badgr_app=None, renotify=False): # Allow sending, as this email is not blacklisted. pass else: - logger.event(badgrlog.BlacklistEarnerNotNotifiedEvent(self)) + logger.warning( + "The email for the badge with ID '%s' is blacklisted and was not sent", + self.entity_id, + ) + logger.debug( + "Recipient: '%s'; badge instance: '%s'", + self.recipient_identifier, + self.json, + ) return if badgr_app is None: @@ -1088,48 +2027,89 @@ def notify_earner(self, badgr_app=None, renotify=False): if badgr_app is None: badgr_app = BadgrApp.objects.get_current(None) + adapter = get_adapter() + + # get the base url for the badge instance + httpPrefix = "https://" if settings.SECURE_SSL_REDIRECT else "http://" + base_url = httpPrefix + badgr_app.cors + + pdf_document = adapter.generate_pdf_content( + slug=self.entity_id, base_url=base_url + ) + encoded_pdf_document = base64.b64encode(pdf_document).decode("utf-8") + data_url = f"data:application/pdf;base64,{encoded_pdf_document}" + try: if self.issuer.image: - issuer_image_url = self.issuer.public_url + '/image' + issuer_image_url = self.issuer.public_url + "/image" else: issuer_image_url = None + if self.recipient_type == RECIPIENT_TYPE_EMAIL: + name = get_name(self) + + url_name = "v1_api_user_collect_badges_in_backpack" + + save_url = OriginSetting.HTTP + reverse(url_name) + + url = "{url}?a={badgr_app}".format(url=save_url, badgr_app=badgr_app) + email_context = { - 'badge_name': self.badgeclass.name, - 'badge_id': self.entity_id, - 'badge_description': self.badgeclass.description, - 'help_email': getattr(settings, 'HELP_EMAIL', 'help@badgr.io'), - 'issuer_name': re.sub(r'[^\w\s]+', '', self.issuer.name, 0, re.I), - 'issuer_url': self.issuer.url, - 'issuer_email': self.issuer.email, - 'issuer_detail': self.issuer.public_url, - 'issuer_image_url': issuer_image_url, - 'badge_instance_url': self.public_url, - 'image_url': self.public_url + '/image?type=png', - 'download_url': self.public_url + "?action=download", - 'site_name': badgr_app.name, - 'site_url': badgr_app.signup_redirect, - 'badgr_app': badgr_app + "name": name, + "badge_name": self.badgeclass.name, + "badge_category": categoryExtension["Category"], + "badge_id": self.entity_id, + "badge_description": self.badgeclass.description, + "badge_competencies": competencies, + "help_email": getattr(settings, "HELP_EMAIL", "info@opensenselab.org"), + "issuer_name": self.issuer.name, + "issuer_url": self.issuer.url, + "issuer_email": self.issuer.email, + "issuer_detail": self.issuer.public_url, + "issuer_image_url": issuer_image_url, + "badge_instance_url": self.public_url, + "badge_instance_image": self.image.path, + "pdf_download": data_url, + "pdf_document": pdf_document, + "image_url": self.public_url + "/image?type=png", + "download_url": self.public_url + "?action=download", + "site_name": "Open Educational Badges", + "badgr_app": badgr_app, + "activate_url": url, + "call_to_action_label": "Badge im Rucksack sammeln", } - if badgr_app.cors == 'badgr.io': - email_context['promote_mobile'] = True + if badgr_app.cors == "badgr.io": + email_context["promote_mobile"] = True if renotify: - email_context['renotify'] = 'Reminder' + email_context["renotify"] = "Reminder" except KeyError as e: # A property isn't stored right in json raise e - template_name = 'issuer/email/notify_earner' - try: - from badgeuser.models import CachedEmailAddress - CachedEmailAddress.objects.get(email=self.recipient_identifier, verified=True) - template_name = 'issuer/email/notify_account_holder' - email_context['site_url'] = badgr_app.ui_login_redirect - except CachedEmailAddress.DoesNotExist: - pass + template_name = "issuer/email/notify_earner" - adapter = get_adapter() - adapter.send_mail(template_name, self.recipient_identifier, context=email_context) + if ( + categoryExtension["Category"] == "learningpath" + and microdegree_id is not None + ): + # if the recipient does not have an account no micro degree email is sent + if self.user is not None: + template_name = "issuer/email/notify_micro_degree_earner" + + url_name = "v1_api_user_save_microdegree" + + save_url = OriginSetting.HTTP + reverse( + url_name, kwargs={"entity_id": microdegree_id} + ) + + url = "{url}?a={badgr_app}".format(url=save_url, badgr_app=badgr_app) + + email_context["activate_url"] = url + email_context["call_to_action_label"] = "Micro Degree auf OEB ansehen" + + adapter.send_mail( + template_name, self.recipient_identifier, context=email_context + ) def get_extensions_manager(self): return self.badgeinstanceextension_set @@ -1137,13 +2117,18 @@ def get_extensions_manager(self): @property def recipient_user(self): from badgeuser.models import CachedEmailAddress, UserRecipientIdentifier + try: - email_address = CachedEmailAddress.cached.get(email=self.recipient_identifier) + email_address = CachedEmailAddress.cached.get( + email=self.recipient_identifier + ) if email_address.verified: return email_address.user except CachedEmailAddress.DoesNotExist: try: - identifier = UserRecipientIdentifier.cached.get(identifier=self.recipient_identifier) + identifier = UserRecipientIdentifier.cached.get( + identifier=self.recipient_identifier + ) if identifier.verified: return identifier.user except UserRecipientIdentifier.DoesNotExist: @@ -1151,91 +2136,172 @@ def recipient_user(self): pass return None - def get_json(self, obi_version=CURRENT_OBI_VERSION, expand_badgeclass=False, expand_issuer=False, include_extra=True, use_canonical_id=False): - obi_version, context_iri = get_obi_context(obi_version) + def get_json( + self, + obi_version=None, + expand_badgeclass=False, + expand_issuer=False, + include_extra=True, + use_canonical_id=False, + force_recreate=False, + ): + # choose obi version + if not obi_version: + obi_version = "3_0" if self.ob_json_3_0 else "2_0" + + # FIXME: special case + # badgr-ui frontend uses this to display the public/assertions/ endpoint + # also maybe social media sharing / widget.ts to display badge name + def expand_json_ifneeded(json): + if expand_badgeclass: + json["badge"] = self.cached_badgeclass.get_json(obi_version=obi_version) + json["badge"]["slug"] = self.cached_badgeclass.entity_id + networkShare = self.cached_badgeclass.network_shares.filter( + is_active=True + ).first() + if networkShare: + network = networkShare.network + json["badge"]["sharedOnNetwork"] = { + "slug": network.entity_id, + "name": network.name, + "image": network.image.url, + "description": network.description, + } + else: + json["badge"]["sharedOnNetwork"] = None + + json["badge"]["isNetworkBadge"] = ( + self.cached_badgeclass.cached_issuer.is_network + and json["badge"]["sharedOnNetwork"] is None + ) + + if json["badge"]["isNetworkBadge"]: + json["badge"]["networkName"] = ( + self.cached_badgeclass.cached_issuer.name + ) + json["badge"]["networkImage"] = ( + self.cached_badgeclass.cached_issuer.image.url + ) + else: + json["badge"]["networkImage"] = None + json["badge"]["networkName"] = None + + if expand_issuer: + json["badge"]["issuer"] = self.cached_issuer.get_json( + obi_version=obi_version + ) + json["image"] = self.image.url + + # FIXME: 'support' 1_1 for v1 serializer classes + if obi_version == "1_1": + obi_version = "2_0" + + if obi_version == "2_0": + if not self.ob_json_2_0 or force_recreate: + self.ob_json_2_0 = json_dumps(self.get_json_2_0()) + if self.pk: + self.save(update_fields=["ob_json_2_0"]) + + json = json_loads(self.ob_json_2_0, object_pairs_hook=OrderedDict) + + expand_json_ifneeded(json) + + return json + + if obi_version == "3_0": + if not self.ob_json_3_0 or force_recreate: + self.ob_json_3_0 = json_dumps(self.get_json_3_0()) + if self.pk: + self.save(update_fields=["ob_json_3_0"]) + + json = json_loads(self.ob_json_3_0, object_pairs_hook=OrderedDict) + + expand_json_ifneeded(json) - json = OrderedDict([ - ('@context', context_iri), - ('type', 'Assertion'), - ('id', add_obi_version_ifneeded(self.jsonld_id, obi_version)), - ('badge', add_obi_version_ifneeded(self.cached_badgeclass.jsonld_id, obi_version)), - ]) + return json + + raise NotImplementedError("Unsupported OB Version") + + def get_json_2_0(self): + obi_version, context_iri = get_obi_context("2_0") + + json = OrderedDict( + [ + ("@context", context_iri), + ("type", "Assertion"), + ("id", add_obi_version_ifneeded(self.jsonld_id, obi_version, True)), + ( + "badge", + add_obi_version_ifneeded( + self.cached_badgeclass.jsonld_id, obi_version, True + ), + ), + ("slug", self.entity_id), + ] + ) image_url = self.image_url(public=True) - json['image'] = image_url + json["image"] = image_url if self.original_json: - image_info = self.get_original_json().get('image', None) + image_info = self.get_original_json().get("image", None) if isinstance(image_info, dict): - json['image'] = image_info - json['image']['id'] = image_url - - if expand_badgeclass: - json['badge'] = self.cached_badgeclass.get_json(obi_version=obi_version, include_extra=include_extra) - - if expand_issuer: - json['badge']['issuer'] = self.cached_issuer.get_json(obi_version=obi_version, include_extra=include_extra) + json["image"] = image_info + json["image"]["id"] = image_url if self.revoked: - return OrderedDict([ - ('@context', context_iri), - ('type', 'Assertion'), - ('id', self.jsonld_id if use_canonical_id else add_obi_version_ifneeded(self.jsonld_id, obi_version)), - ('revoked', self.revoked), - ('revocationReason', self.revocation_reason if self.revocation_reason else "") - ]) - - if obi_version == '1_1': - json["uid"] = self.entity_id - json["verify"] = { - "url": self.public_url if use_canonical_id else add_obi_version_ifneeded(self.public_url, obi_version), - "type": "hosted" - } - elif obi_version == '2_0': - json["verification"] = { - "type": "HostedBadge" - } + return OrderedDict( + [ + ("@context", context_iri), + ("type", "Assertion"), + ( + "id", + (add_obi_version_ifneeded(self.jsonld_id, obi_version, True)), + ), + ("revoked", self.revoked), + ( + "revocationReason", + self.revocation_reason if self.revocation_reason else "", + ), + ] + ) + + json["verification"] = {"type": "HostedBadge"} # source url if self.source_url: - if obi_version == '1_1': - json["source_url"] = self.source_url - json["hosted_url"] = OriginSetting.HTTP + self.get_absolute_url() - elif obi_version == '2_0': - json["sourceUrl"] = self.source_url - json["hostedUrl"] = OriginSetting.HTTP + self.get_absolute_url() + json["sourceUrl"] = self.source_url + json["hostedUrl"] = OriginSetting.HTTP + self.get_absolute_url() # evidence if self.evidence_url: - if obi_version == '1_1': - # obi v1 single evidence url - json['evidence'] = self.evidence_url - elif obi_version == '2_0': - # obi v2 multiple evidence - json['evidence'] = [e.get_json(obi_version) for e in self.cached_evidence()] + # obi v2 multiple evidence + json["evidence"] = [e.get_json(obi_version) for e in self.cached_evidence()] # narrative - if self.narrative and obi_version == '2_0': - json['narrative'] = self.narrative + if self.narrative: + json["narrative"] = self.narrative # issuedOn / expires - json['issuedOn'] = self.issued_on.isoformat() + json["issuedOn"] = self.issued_on.isoformat() if self.expires_at: - json['expires'] = self.expires_at.isoformat() + json["expires"] = self.expires_at.isoformat() # recipient if self.hashed: - json['recipient'] = { + json["recipient"] = { "hashed": True, "type": self.recipient_type, - "identity": generate_sha256_hashstring(self.recipient_identifier, self.salt), + "identity": generate_sha256_hashstring( + self.recipient_identifier, self.salt + ), } if self.salt: - json['recipient']['salt'] = self.salt + json["recipient"]["salt"] = self.salt else: - json['recipient'] = { + json["recipient"] = { "hashed": False, "type": self.recipient_type, - "identity": self.recipient_identifier + "identity": self.recipient_identifier, } # extensions @@ -1243,29 +2309,232 @@ def get_json(self, obi_version=CURRENT_OBI_VERSION, expand_badgeclass=False, exp for extension in self.cached_extensions(): json[extension.name] = json_loads(extension.original_json) - # pass through imported json - if include_extra: - extra = self.get_filtered_json() - if extra is not None: - for k,v in list(extra.items()): - if k not in json: - json[k] = v + return json + + def get_json_3_0(self): + obi_version, context_iri = get_obi_context("3_0") + + hashed_recipient = generate_sha256_hashstring( + self.recipient_identifier, self.salt + ) + + credential_subject = { + "type": ["AchievementSubject"], + "identifier": [ + { + "type": "IdentityObject", + "identityHash": hashed_recipient, + "identityType": "emailAddress", + "hashed": True, + "salt": self.salt, + } + ], + "achievement": { + "id": add_obi_version_ifneeded( + self.cached_badgeclass.jsonld_id, obi_version + ), + "type": ["Achievement"], + "name": self.cached_badgeclass.name, + "description": self.cached_badgeclass.description, + "achievementType": "Badge", + "criteria": { + "narrative": self.narrative or "", + }, + "image": { + "id": self.image_url(public=True), + "type": "Image", + }, + }, + } + + if self.activity_start_date: + credential_subject["activityStartDate"] = ( + self.activity_start_date.isoformat() + ) + if self.activity_end_date: + credential_subject["activityEndDate"] = self.activity_end_date.isoformat() + + if self.activity_city or self.activity_zip: + activity_location = {"type": ["Address"]} + + if self.activity_city: + activity_location["addressLocality"] = self.activity_city + if self.activity_zip: + activity_location["postalCode"] = self.activity_zip + + credential_subject["activityLocation"] = activity_location + + if self.activity_online: + credential_subject["activityFormat"] = "Online" + + json = OrderedDict( + [ + ( + "@context", + [ + "https://www.w3.org/ns/credentials/v2", + *context_iri, + "https://purl.imsglobal.org/spec/ob/v3p0/extensions.json", + ], + ), + ("id", add_obi_version_ifneeded(self.jsonld_id, obi_version)), + ("type", ["VerifiableCredential", "OpenBadgeCredential"]), + ("name", self.cached_badgeclass.name), + ("evidence", [e.get_json(obi_version) for e in self.cached_evidence()]), + ( + "issuer", + { + "id": add_obi_version_ifneeded( + self.cached_issuer.jsonld_id, obi_version + ), + "type": ["Profile"], + "name": self.cached_issuer.name, + "url": self.cached_issuer.url, + "email": self.cached_issuer.email, + }, + ), + ("validFrom", self.issued_on.isoformat()), + ("credentialSubject", credential_subject), + ] + ) + + if self.expires_at: + json["validUntil"] = self.expires_at.isoformat() + + json["credentialStatus"] = { + "id": f"{self.jsonld_id}/revocations", + "type": "1EdTechRevocationList", + } + + if len(self.cached_extensions()) > 0: + extension_contexts = [] + for extension in self.cached_extensions(): + extension_json = json_loads(extension.original_json) + extension_name = extension.name + + try: + extension_context = extension_json["@context"] + if isinstance(extension_context, list): + extension_contexts += extension_context + else: + extension_contexts.append(extension_context) + + except KeyError: + pass + + json[extension_name] = extension_json + + # unique + extension_contexts = list(set(extension_contexts)) + json["@context"] += extension_contexts + + ##### proof / signing ##### + + # load private key + private_key = serialization.load_pem_private_key( + self.cached_issuer.private_key.encode(), settings.SECRET_KEY.encode() + ) + + # basic proof dict with added @context + proof = OrderedDict( + [ + ("@context", "https://www.w3.org/ns/credentials/v2"), + ("type", "DataIntegrityProof"), + ("cryptosuite", "eddsa-rdfc-2022"), + ("created", self.issued_on.isoformat()), + ( + "verificationMethod", + f"{add_obi_version_ifneeded(self.cached_issuer.jsonld_id, obi_version)}#key-0", + ), + ("proofPurpose", "assertionMethod"), + ] + ) + + # transform https://www.w3.org/TR/vc-di-eddsa/#transformation-eddsa-rdfc-2022 + + canonicalized_proof = jsonld.normalize( + proof, {"algorithm": "URDNA2015", "format": "application/n-quads"} + ) + canonicalized_json = jsonld.normalize( + json, {"algorithm": "URDNA2015", "format": "application/n-quads"} + ) + + # hash transformed documents, 32bit each + hashed_proof = sha256(canonicalized_proof.encode()).digest() + hashed_json = sha256(canonicalized_json.encode()).digest() + + # concat for 64bit hash and sign + signature = private_key.sign(hashed_proof + hashed_json) + + # base58 encode with multibase prefix z + proof["proofValue"] = f"z{base58.b58encode(signature).decode()}" + + # remove proof @context + del proof["@context"] + + # add proof to json + json["proof"] = [proof] return json + def get_revocation_json(self): + revocation_list = { + "id": f"{self.jsonld_id}/revocations", + "issuer": add_obi_version_ifneeded(self.cached_issuer.jsonld_id, "3_0"), + "revokedCredential": [], + } + if self.revoked: + revocation_list["revokedCredential"].append( + { + "id": add_obi_version_ifneeded(self.jsonld_id, "3_0"), + "revoked": True, + "revocationReason": self.revocation_reason + if self.revocation_reason + else "", + } + ) + + return revocation_list + @property def json(self): return self.get_json() - def get_filtered_json(self, excluded_fields=('@context', 'id', 'type', 'uid', 'recipient', 'badge', 'issuedOn', 'image', 'evidence', 'narrative', 'revoked', 'revocationReason', 'verify', 'verification')): - filtered = super(BadgeInstance, self).get_filtered_json(excluded_fields=excluded_fields) + def get_filtered_json( + self, + excluded_fields=( + "@context", + "id", + "type", + "uid", + "recipient", + "badge", + "issuedOn", + "image", + "evidence", + "narrative", + "revoked", + "revocationReason", + "verify", + "verification", + ), + ): + filtered = super(BadgeInstance, self).get_filtered_json( + excluded_fields=excluded_fields + ) # Ensure that the expires date string is in the expected ISO-85601 UTC format - if filtered is not None and filtered.get('expires', None) and not str(filtered.get('expires')).endswith('Z'): - filtered['expires'] = parse_original_datetime(filtered['expires']) + if ( + filtered is not None + and filtered.get("expires", None) + and not str(filtered.get("expires")).endswith("Z") + ): + filtered["expires"] = parse_original_datetime(filtered["expires"]) return filtered @cachemodel.cached_method(auto_publish=True) def cached_evidence(self): + if not self.pk: + return [] return self.badgeinstanceevidence_set.all() @property @@ -1287,9 +2556,15 @@ def evidence_items(self): @evidence_items.setter def evidence_items(self, value): def _key(narrative, url): - return '{}-{}'.format(narrative or '', url or '') - existing_evidence_idx = {_key(e.narrative, e.evidence_url): e for e in self.evidence_items} - new_evidence_idx = {_key(v.get('narrative', None), v.get('evidence_url', None)): v for v in value} + return "{}-{}".format(narrative or "", url or "") + + existing_evidence_idx = { + _key(e.narrative, e.evidence_url): e for e in self.evidence_items + } + new_evidence_idx = { + _key(v.get("narrative", None), v.get("evidence_url", None)): v + for v in value + } with transaction.atomic(): if not self.pk: @@ -1297,17 +2572,25 @@ def _key(narrative, url): # add missing for evidence_data in value: - key = _key(evidence_data.get('narrative', None), evidence_data.get('evidence_url', None)) + key = _key( + evidence_data.get("narrative", None), + evidence_data.get("evidence_url", None), + ) if key not in existing_evidence_idx: - evidence_record, created = BadgeInstanceEvidence.cached.get_or_create( - badgeinstance=self, - narrative=evidence_data.get('narrative', None), - evidence_url=evidence_data.get('evidence_url', None) + evidence_record, created = ( + BadgeInstanceEvidence.cached.get_or_create( + badgeinstance=self, + narrative=evidence_data.get("narrative", None), + evidence_url=evidence_data.get("evidence_url", None), + ) ) # remove old for evidence_record in self.evidence_items: - key = _key(evidence_record.narrative or None, evidence_record.evidence_url or None) + key = _key( + evidence_record.narrative or None, + evidence_record.evidence_url or None, + ) if key not in new_evidence_idx: evidence_record.delete() @@ -1315,64 +2598,149 @@ def _key(narrative, url): def cached_badgrapp(self): return self.cached_issuer.cached_badgrapp - def get_baked_image_url(self, obi_version=CURRENT_OBI_VERSION): + def get_baked_image_url(self, obi_version): if obi_version == UNVERSIONED_BAKED_VERSION: # requested version is the one referenced in assertion.image return self.image.url try: - baked_image = BadgeInstanceBakedImage.cached.get(badgeinstance=self, obi_version=obi_version) + baked_image = BadgeInstanceBakedImage.cached.get( + badgeinstance=self, obi_version=obi_version + ) except BadgeInstanceBakedImage.DoesNotExist: # rebake - baked_image = BadgeInstanceBakedImage(badgeinstance=self, obi_version=obi_version) + baked_image = BadgeInstanceBakedImage( + badgeinstance=self, obi_version=obi_version + ) json_to_bake = self.get_json( obi_version=obi_version, expand_issuer=True, expand_badgeclass=True, - include_extra=True + include_extra=True, + force_recreate=True, ) badgeclass_name, ext = os.path.splitext(self.badgeclass.image.file.name) new_image = io.BytesIO() - bake(image_file=self.cached_badgeclass.image.file, - assertion_json_string=json_dumps(json_to_bake, indent=2), - output_file=new_image) + bake( + image_file=self.cached_badgeclass.image.file, + assertion_json_string=json_dumps(json_to_bake, indent=2), + output_file=new_image, + ) baked_image.image.save( - name='assertion-{id}-{version}{ext}'.format(id=self.entity_id, ext=ext, version=obi_version), + name="assertion-{id}-{version}{ext}".format( + id=self.entity_id, ext=ext, version=obi_version + ), content=ContentFile(new_image.read()), - save=False + save=False, ) baked_image.save() return baked_image.image.url + def generate_assertion_image(self, issuer_image=None, network_image=None): + """Generate composed assertion image""" + + extensions = self.badgeclass.cached_extensions() + categoryExtension = extensions.get(name="extensions:CategoryExtension") + category = json_loads(categoryExtension.original_json)["Category"] + org_img_ext = extensions.get(name="extensions:OrgImageExtension") + original_image = json_loads(org_img_ext.original_json)["OrgImage"] + + composer = ImageComposer(category=category) + + image_b64 = composer.compose_badge_from_uploaded_image( + original_image, + issuer_image, + network_image, + draw_frame=self.badgeclass.imageFrame, + ) + + if not image_b64: + raise ValueError("Assertion image generation failed") + + if image_b64.startswith("data:image/png;base64,"): + image_b64 = image_b64.split(",", 1)[1] + + image_data = base64.b64decode(image_b64) + + filename = f"assertion_{uuid.uuid4()}.png" + content_file = ContentFile(image_data, name=filename) + + self.image.save(filename, content_file, save=False) + def _baked_badge_instance_filename_generator(instance, filename): return "baked/{version}/{filename}".format( - version=instance.obi_version, - filename=filename + version=instance.obi_version, filename=filename + ) + + +class BadgeClassNetworkShare(models.Model): + """ + Represents a badge that has been shared with a network. + Partner issuers of this network can award this badge. + A badge cannot be removed from the network after it has been shared. + """ + + badgeclass = models.ForeignKey( + BadgeClass, + on_delete=models.CASCADE, + related_name="network_shares", ) + network = models.ForeignKey( + Issuer, + on_delete=models.CASCADE, + related_name="shared_badges", + limit_choices_to={"is_network": True}, + ) + shared_at = models.DateTimeField(auto_now_add=True) + shared_by_user = models.ForeignKey( + "badgeuser.BadgeUser", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="badge_shares", + ) + shared_by_issuer = models.ForeignKey( + Issuer, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="network_shares_created", + ) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ("badgeclass", "network") + verbose_name = "Badge Class Network Share" + verbose_name_plural = "Badge Class Network Shares" + + def __str__(self): + return f"{self.badgeclass} shared with {self.network}" class BadgeInstanceBakedImage(cachemodel.CacheModel): - badgeinstance = models.ForeignKey('issuer.BadgeInstance', - on_delete=models.CASCADE) + badgeinstance = models.ForeignKey("issuer.BadgeInstance", on_delete=models.CASCADE) obi_version = models.CharField(max_length=254) - image = models.FileField(upload_to=_baked_badge_instance_filename_generator, blank=True) + image = models.FileField( + upload_to=_baked_badge_instance_filename_generator, blank=True + ) def publish(self): - self.publish_by('badgeinstance', 'obi_version') + self.publish_by("badgeinstance", "obi_version") return super(BadgeInstanceBakedImage, self).publish() def delete(self, *args, **kwargs): - self.publish_delete('badgeinstance', 'obi_version') + self.publish_delete("badgeinstance", "obi_version") return super(BadgeInstanceBakedImage, self).delete(*args, **kwargs) class BadgeInstanceEvidence(OriginalJsonMixin, cachemodel.CacheModel): - badgeinstance = models.ForeignKey('issuer.BadgeInstance', - on_delete=models.CASCADE) - evidence_url = models.CharField(max_length=2083, blank=True, null=True, default=None) + badgeinstance = models.ForeignKey("issuer.BadgeInstance", on_delete=models.CASCADE) + evidence_url = models.CharField( + max_length=2083, blank=True, null=True, default=None + ) narrative = models.TextField(blank=True, null=True, default=None) objects = BadgeInstanceEvidenceManager() @@ -1391,19 +2759,24 @@ def get_json(self, obi_version=CURRENT_OBI_VERSION, include_context=False): json = OrderedDict() if include_context: obi_version, context_iri = get_obi_context(obi_version) - json['@context'] = context_iri + json["@context"] = context_iri + + if obi_version == "2_0": + json["type"] = "Evidence" + + if obi_version == "3_0": + json["type"] = ["Evidence"] - json['type'] = 'Evidence' if self.evidence_url: - json['id'] = self.evidence_url + json["id"] = self.evidence_url if self.narrative: - json['narrative'] = self.narrative + json["narrative"] = self.narrative + return json class BadgeClassAlignment(OriginalJsonMixin, cachemodel.CacheModel): - badgeclass = models.ForeignKey('issuer.BadgeClass', - on_delete=models.CASCADE) + badgeclass = models.ForeignKey("issuer.BadgeClass", on_delete=models.CASCADE) target_name = models.TextField() target_url = models.CharField(max_length=2083) target_description = models.TextField(blank=True, null=True, default=None) @@ -1422,23 +2795,22 @@ def get_json(self, obi_version=CURRENT_OBI_VERSION, include_context=False): json = OrderedDict() if include_context: obi_version, context_iri = get_obi_context(obi_version) - json['@context'] = context_iri + json["@context"] = context_iri - json['targetName'] = self.target_name - json['targetUrl'] = self.target_url + json["targetName"] = self.target_name + json["targetUrl"] = self.target_url if self.target_description: - json['targetDescription'] = self.target_description + json["targetDescription"] = self.target_description if self.target_framework: - json['targetFramework'] = self.target_framework + json["targetFramework"] = self.target_framework if self.target_code: - json['targetCode'] = self.target_code + json["targetCode"] = self.target_code return json class BadgeClassTag(cachemodel.CacheModel): - badgeclass = models.ForeignKey('issuer.BadgeClass', - on_delete=models.CASCADE) + badgeclass = models.ForeignKey("issuer.BadgeClass", on_delete=models.CASCADE) name = models.CharField(max_length=254, db_index=True) def __str__(self): @@ -1453,9 +2825,22 @@ def delete(self, *args, **kwargs): self.badgeclass.publish() +class LearningPathTag(cachemodel.CacheModel): + learningPath = models.ForeignKey("issuer.LearningPath", on_delete=models.CASCADE) + name = models.CharField(max_length=254, db_index=True) + + def __str__(self): + return self.name + + def publish(self): + super(LearningPathTag, self).publish() + + def delete(self, *args, **kwargs): + super(LearningPathTag, self).delete(*args, **kwargs) + + class IssuerExtension(BaseOpenBadgeExtension): - issuer = models.ForeignKey('issuer.Issuer', - on_delete=models.CASCADE) + issuer = models.ForeignKey("issuer.Issuer", on_delete=models.CASCADE) def publish(self): super(IssuerExtension, self).publish() @@ -1467,8 +2852,7 @@ def delete(self, *args, **kwargs): class BadgeClassExtension(BaseOpenBadgeExtension): - badgeclass = models.ForeignKey('issuer.BadgeClass', - on_delete=models.CASCADE) + badgeclass = models.ForeignKey("issuer.BadgeClass", on_delete=models.CASCADE) def publish(self): super(BadgeClassExtension, self).publish() @@ -1480,8 +2864,7 @@ def delete(self, *args, **kwargs): class BadgeInstanceExtension(BaseOpenBadgeExtension): - badgeinstance = models.ForeignKey('issuer.BadgeInstance', - on_delete=models.CASCADE) + badgeinstance = models.ForeignKey("issuer.BadgeInstance", on_delete=models.CASCADE) def publish(self): super(BadgeInstanceExtension, self).publish() @@ -1490,3 +2873,348 @@ def publish(self): def delete(self, *args, **kwargs): super(BadgeInstanceExtension, self).delete(*args, **kwargs) self.badgeinstance.publish() + + +class ImportedBadgeAssertionExtension(BaseOpenBadgeExtension): + importedBadge = models.ForeignKey( + "issuer.ImportedBadgeAssertion", on_delete=models.CASCADE + ) + + def publish(self): + super(ImportedBadgeAssertionExtension, self).publish() + self.importedBadge.publish() + + def delete(self, *args, **kwargs): + super(ImportedBadgeAssertionExtension, self).delete(*args, **kwargs) + self.importedBadge.publish() + + +class QrCode(BaseVersionedEntity): + badgeclass = models.ForeignKey( + BadgeClass, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="qrcodes", + ) + + issuer = models.ForeignKey(Issuer, on_delete=models.CASCADE) + + title = models.CharField(max_length=254, blank=False, null=False) + + createdBy = models.CharField(max_length=254, blank=False, null=False) + + created_by_user = models.ForeignKey( + "badgeuser.BadgeUser", + null=True, + related_name="+", + on_delete=models.SET_NULL, + ) + + activity_start_date = models.DateTimeField( + blank=True, + null=True, + default=None, + help_text="The datetime the activity/course started", + ) + activity_end_date = models.DateTimeField( + blank=True, + null=True, + default=None, + help_text="The datetime the activity/course ended", + ) + + activity_zip = models.CharField(max_length=255, null=True, blank=True) + activity_city = models.CharField(max_length=255, null=True, blank=True) + activity_online = models.BooleanField(blank=True, null=False, default=False) + + course_url = models.CharField(max_length=255, blank=True, null=True, default=None) + + valid_from = models.DateTimeField(blank=True, null=True, default=None) + + expires_at = models.DateTimeField(blank=True, null=True, default=None) + + evidence_items = JSONField(default=list, blank=True) + + notifications = models.BooleanField(null=False, default=False) + + +class RequestedBadge(BaseVersionedEntity): + badgeclass = models.ForeignKey( + BadgeClass, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="requestedbadges", + ) + user = models.ForeignKey( + "badgeuser.BadgeUser", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + + qrcode = models.ForeignKey( + QrCode, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="requestedbadges", + ) + + firstName = models.CharField(max_length=254, blank=False, null=False) + lastName = models.CharField(max_length=254, blank=False, null=False) + email = models.CharField(max_length=254, blank=True, null=True) + + requestedOn = models.DateTimeField(blank=False, null=False, default=timezone.now) + + status = models.CharField( + max_length=254, blank=False, null=False, default="Pending" + ) + + +class LearningPath(BaseVersionedEntity, BaseAuditedModel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._original_activated = self.activated + + name = models.CharField(max_length=254, blank=False, null=False) + description = models.TextField(blank=True, null=True, default=None) + issuer = models.ForeignKey( + Issuer, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="learningpaths", + ) + participationBadge = models.ForeignKey( + BadgeClass, blank=False, null=False, on_delete=models.CASCADE + ) + badgrapp = models.ForeignKey( + "mainsite.BadgrApp", + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + ) + slug = models.CharField( + max_length=255, db_index=True, blank=True, null=True, default=None + ) + + required_badges_count = models.PositiveIntegerField() + activated = models.BooleanField(null=False, default=False) + + @property + def public_url(self): + return OriginSetting.HTTP + self.get_absolute_url() + + @property + def v1_api_participant_count(self): + # count users with issued lp badges + lp_badges = LearningPathBadge.objects.filter(learning_path=self) + lp_badgeclasses = [lp_badge.badge for lp_badge in lp_badges] + instances = BadgeInstance.objects.filter( + badgeclass__in=lp_badgeclasses, revoked=False + ) + users = set([i.user for i in instances]) + return len(users) + + @property + def cached_badgrapp(self): + id = self.badgrapp_id if self.badgrapp_id else None + return BadgrApp.objects.get_by_id_or_default(badgrapp_id=id) + + @property + def cached_issuer(self): + return Issuer.cached.get(pk=self.issuer_id) + + @cachemodel.cached_method(auto_publish=True) + def cached_learningpathbadges(self): + return self.learningpathbadge_set.all() + + @property + def learningpath_badges(self): + # TODO: return from cache + # return self.cached_learningpathbadges() + return self.learningpathbadge_set.all() + + @learningpath_badges.setter + def learningpath_badges(self, badges_with_order): + self.learningpathbadge_set.all().delete() + + for badge, order in badges_with_order: + LearningPathBadge.objects.create( + learning_path=self, badge=badge, order=order + ) + + @cachemodel.cached_method(auto_publish=True) + def cached_tags(self): + return self.learningpathtag_set.all() + + @property + def tag_items(self): + return self.cached_tags() + + @tag_items.setter + def tag_items(self, value): + if value is None: + value = [] + existing_idx = [t.name for t in self.tag_items] + new_idx = value + + with transaction.atomic(): + if not self.pk: + self.save() + + # add missing + for t in value: + if t not in existing_idx: + tag = self.learningpathtag_set.create(name=t) + + # remove old + for tag in self.tag_items: + if tag.name not in new_idx: + tag.delete() + + def save(self, *args, **kwargs): + activated = False + + if self.pk: + if not self._original_activated and self.activated: + activated = True + else: + if self.activated: + activated = True + + super().save(*args, **kwargs) + self._original_activated = self.activated + + if activated: + from mainsite.tasks import process_learning_path_activation + + process_learning_path_activation.delay(self.pk) + + def get_json( + self, + obi_version=CURRENT_OBI_VERSION, + ): + json = OrderedDict({}) + json.update( + OrderedDict( + name=self.name, + description=self.description, + slug=self.entity_id, + issuer_id=self.issuer.entity_id, + created_at=self.created_at, + ) + ) + + tags = self.learningpathtag_set.all() + badges = self.learningpathbadge_set.all() + image = "{}{}?type=png".format( + OriginSetting.HTTP, + reverse( + "badgeclass_image", + kwargs={"entity_id": self.participationBadge.entity_id}, + ), + ) + + json["tags"] = list(t.name for t in tags) + + json["badges"] = [ + { + "badge": badge.badge.get_json(obi_version=obi_version), + "order": badge.order, + } + for badge in badges + ] + + json["participationBadge_image"] = image + + json["activated"] = self.activated + + return json + + def get_absolute_url(self): + return reverse("learningpath_json", kwargs={"entity_id": self.entity_id}) + + def user_has_completed(self, recipient_identifier): + badgeclasses = [lp_badge.badge for lp_badge in self.learningpath_badges] + badgeinstances = BadgeInstance.objects.filter( + recipient_identifier=recipient_identifier, + badgeclass__in=badgeclasses, + revoked=False, + ) + completed_badges = list( + {badgeinstance.badgeclass for badgeinstance in badgeinstances} + ) + + return len(completed_badges) >= self.required_badges_count + + def user_should_have_badge(self, recipient_identifier): + if self.user_has_completed(recipient_identifier): + # check to only award the participationBadge once + badgeinstances = BadgeInstance.objects.filter( + badgeclass=self.participationBadge, + recipient_identifier=recipient_identifier, + revoked=False, + ) + return len(badgeinstances) == 0 + + return False + + def calculate_progress(self, badgeclasses): + return sum( + json_loads(ext.original_json)["StudyLoad"] + for badge in badgeclasses + for ext in badge.cached_extensions() + if ext.name == "extensions:StudyLoadExtension" + ) + + def get_lp_badgeinstance(self, recipient_identifier): + return BadgeInstance.objects.filter( + badgeclass=self.participationBadge, + recipient_identifier=recipient_identifier, + revoked=False, + ).first() + + def get_studyload(self): + studyLoadExt = self.participationBadge.cached_extensions().get( + name="extensions:StudyLoadExtension" + ) + studyLoadJson = json_loads(studyLoadExt.original_json) + return studyLoadJson["StudyLoad"] + + +class LearningPathBadge(cachemodel.CacheModel): + learning_path = models.ForeignKey(LearningPath, on_delete=models.CASCADE) + badge = models.ForeignKey(BadgeClass, on_delete=models.CASCADE) + order = models.PositiveIntegerField() + + def publish(self): + super(LearningPathBadge, self).publish() + + def delete(self, *args, **kwargs): + super(LearningPathBadge, self).delete(*args, **kwargs) + + +class RequestedLearningPath(BaseVersionedEntity): + learningpath = models.ForeignKey( + LearningPath, + blank=False, + null=False, + on_delete=models.CASCADE, + related_name="requested_learningpath", + ) + user = models.ForeignKey( + "badgeuser.BadgeUser", + blank=False, + null=False, + on_delete=models.CASCADE, + ) + + requestedOn = models.DateTimeField(blank=False, null=False, default=timezone.now) + + status = models.CharField( + max_length=254, blank=False, null=False, default="Pending" + ) diff --git a/apps/issuer/permissions.py b/apps/issuer/permissions.py index 9fd1b856b..0af43b37e 100644 --- a/apps/issuer/permissions.py +++ b/apps/issuer/permissions.py @@ -1,72 +1,229 @@ import oauth2_provider -import rest_framework from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from rest_framework import permissions import rules -from issuer.models import IssuerStaff +from issuer.models import Issuer, IssuerStaff, NetworkMembership -SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] +SAFE_METHODS = ["GET", "HEAD", "OPTIONS"] @rules.predicate def is_owner(user, issuer): - if not hasattr(issuer, 'cached_issuerstaff'): + if not hasattr(issuer, "cached_issuerstaff"): return False + for staff_record in issuer.cached_issuerstaff(): - if staff_record.user_id == user.id and staff_record.role == IssuerStaff.ROLE_OWNER: + if ( + staff_record.user_id == user.id + and staff_record.role == IssuerStaff.ROLE_OWNER + ): return True + + if hasattr(issuer, "is_network") and issuer.is_network: + for membership in issuer.memberships.all(): + partner_issuer = membership.issuer + for staff_record in partner_issuer.cached_issuerstaff(): + if ( + staff_record.user_id == user.id + and staff_record.role == IssuerStaff.ROLE_OWNER + ): + return True + return False @rules.predicate def is_editor(user, issuer): - if not hasattr(issuer, 'cached_issuerstaff'): + if not hasattr(issuer, "cached_issuerstaff"): return False + for staff_record in issuer.cached_issuerstaff(): - if staff_record.user_id == user.id and staff_record.role in (IssuerStaff.ROLE_OWNER, IssuerStaff.ROLE_EDITOR): + if staff_record.user_id == user.id and staff_record.role in ( + IssuerStaff.ROLE_OWNER, + IssuerStaff.ROLE_EDITOR, + ): return True + + if hasattr(issuer, "is_network") and issuer.is_network: + for membership in issuer.memberships.all(): + partner_issuer = membership.issuer + for staff_record in partner_issuer.cached_issuerstaff(): + if staff_record.user_id == user.id and staff_record.role in ( + IssuerStaff.ROLE_OWNER, + IssuerStaff.ROLE_EDITOR, + ): + return True + return False @rules.predicate def is_staff(user, issuer): - if not hasattr(issuer, 'cached_issuerstaff'): + if not hasattr(issuer, "cached_issuerstaff"): return False + for staff_record in issuer.cached_issuerstaff(): if staff_record.user_id == user.id: return True + + if hasattr(issuer, "is_network") and issuer.is_network: + for membership in issuer.memberships.all(): + partner_issuer = membership.issuer + for staff_record in partner_issuer.cached_issuerstaff(): + if staff_record.user_id == user.id: + return True + return False is_on_staff = is_owner | is_staff is_staff_editor = is_owner | is_editor -rules.add_perm('issuer.is_owner', is_owner) -rules.add_perm('issuer.is_editor', is_staff_editor) -rules.add_perm('issuer.is_staff', is_on_staff) +# FIXME: should those be set here? +try: + rules.add_perm("issuer.is_owner", is_owner) + rules.add_perm("issuer.is_editor", is_staff_editor) + rules.add_perm("issuer.is_staff", is_on_staff) +except KeyError: + pass @rules.predicate def is_badgeclass_owner(user, badgeclass): - return any(staff.role == IssuerStaff.ROLE_OWNER for staff in badgeclass.cached_issuer.cached_issuerstaff() if staff.user_id == user.id) + return any( + staff.role == IssuerStaff.ROLE_OWNER + for staff in badgeclass.cached_issuer.cached_issuerstaff() + if staff.user_id == user.id + ) @rules.predicate def is_badgeclass_editor(user, badgeclass): - return any(staff.role in [IssuerStaff.ROLE_EDITOR, IssuerStaff.ROLE_OWNER] for staff in badgeclass.cached_issuer.cached_issuerstaff() if staff.user_id == user.id) + return any( + staff.role in [IssuerStaff.ROLE_EDITOR, IssuerStaff.ROLE_OWNER] + for staff in badgeclass.cached_issuer.cached_issuerstaff() + if staff.user_id == user.id + ) @rules.predicate def is_badgeclass_staff(user, badgeclass): - return any(staff.user_id == user.id for staff in badgeclass.cached_issuer.cached_issuerstaff()) + return any( + staff.user_id == user.id + for staff in badgeclass.cached_issuer.cached_issuerstaff() + ) + + +@rules.predicate +def is_partner_issuer_staff(user, badgeclass): + """ + Check if user is staff of a partner issuer in a network where this badge has been shared. + Returns True if: + 1. The badge has been shared with a network (via BadgeClassNetworkShare) or is a network badge + 2. The user is staff of an issuer that is a member of that network + 3. The share is active + """ + issuer = badgeclass.issuer + if issuer.is_network: + for membership in issuer.memberships.all(): + partner_staff = ( + membership.issuer.cached_issuerstaff().filter(user=user).first() + ) + if partner_staff: + return True -can_issue_badgeclass = is_badgeclass_owner | is_badgeclass_staff + network_shares = badgeclass.network_shares.filter(is_active=True).select_related( + "network" + ) + + for share in network_shares: + network = share.network + partner_memberships = network.memberships.select_related("issuer") + + for membership in partner_memberships: + partner_issuer = membership.issuer + if any( + staff.user_id == user.id + for staff in partner_issuer.cached_issuerstaff() + ): + return True + + return False + + +@rules.predicate +def is_learningpath_staff(user, learningpath): + return any( + staff.user_id == user.id + for staff in learningpath.cached_issuer.cached_issuerstaff() + ) + + +@rules.predicate +def is_learningpath_editor(user, learningpath): + return any( + staff.role in [IssuerStaff.ROLE_EDITOR, IssuerStaff.ROLE_OWNER] + for staff in learningpath.cached_issuer.cached_issuerstaff() + if staff.user_id == user.id + ) + + +@rules.predicate +def is_learningpath_owner(user, learningpath): + return any( + staff.role == IssuerStaff.ROLE_OWNER + for staff in learningpath.cached_issuer.cached_issuerstaff() + if staff.user_id == user.id + ) + + +@rules.predicate +def is_network_member(user, network): + """ + Check if user is staff of any issuer that is a member of the given network + """ + if not network or not network.is_network: + return False + + user_staff_issuer_ids = IssuerStaff.objects.filter(user=user).values_list( + "issuer_id", flat=True + ) + + return NetworkMembership.objects.filter( + network=network, issuer_id__in=user_staff_issuer_ids + ).exists() + + +can_issue_badgeclass = ( + is_badgeclass_owner | is_badgeclass_staff | is_partner_issuer_staff +) can_edit_badgeclass = is_badgeclass_owner | is_badgeclass_editor -rules.add_perm('issuer.can_issue_badge', can_issue_badgeclass) -rules.add_perm('issuer.can_edit_badgeclass', can_edit_badgeclass) +can_issue_learningpath = is_learningpath_staff +can_edit_learningpath = is_learningpath_owner | is_learningpath_editor + +# FIXME: should those be set here? +try: + rules.add_perm("issuer.can_issue_badge", can_issue_badgeclass) + rules.add_perm("issuer.can_edit_badgeclass", can_edit_badgeclass) + rules.add_perm("issuer.can_issue_learningpath", can_issue_learningpath) + rules.add_perm("issuer.can_edit_learningpath", can_edit_learningpath) + rules.add_perm("network.is_member", is_network_member) +except KeyError: + pass + + +class MayIssueLearningPath(permissions.BasePermission): + """ + --- + model: LearningPath + """ + + def has_object_permission(self, request, view, learningpath): + return _is_server_admin(request) or request.user.has_perm( + "issuer.can_issue_learningpath", learningpath + ) class MayIssueBadgeClass(permissions.BasePermission): @@ -78,7 +235,9 @@ class MayIssueBadgeClass(permissions.BasePermission): """ def has_object_permission(self, request, view, badgeclass): - return _is_server_admin(request) or request.user.has_perm('issuer.can_issue_badge', badgeclass) + return _is_server_admin(request) or request.user.has_perm( + "issuer.can_issue_badge", badgeclass + ) class MayEditBadgeClass(permissions.BasePermission): @@ -94,9 +253,9 @@ def has_object_permission(self, request, view, badgeclass): if _is_server_admin(request): return True if request.method in SAFE_METHODS: - return request.user.has_perm('issuer.can_issue_badge', badgeclass) + return request.user.has_perm("issuer.can_issue_badge", badgeclass) else: - return request.user.has_perm('issuer.can_edit_badgeclass', badgeclass) + return request.user.has_perm("issuer.can_edit_badgeclass", badgeclass) class IsOwnerOrStaff(permissions.BasePermission): @@ -104,13 +263,14 @@ class IsOwnerOrStaff(permissions.BasePermission): Ensures request user is owner for unsafe operations, or at least staff for safe operations. """ + def has_object_permission(self, request, view, issuer): if _is_server_admin(request): return True if request.method in SAFE_METHODS: - return request.user.has_perm('issuer.is_staff', issuer) + return request.user.has_perm("issuer.is_staff", issuer) else: - return request.user.has_perm('issuer.is_owner', issuer) + return request.user.has_perm("issuer.is_owner", issuer) class IsEditor(permissions.BasePermission): @@ -125,9 +285,9 @@ def has_object_permission(self, request, view, issuer): if _is_server_admin(request): return True if request.method in SAFE_METHODS: - return request.user.has_perm('issuer.is_staff', issuer) + return request.user.has_perm("issuer.is_staff", issuer) else: - return request.user.has_perm('issuer.is_editor', issuer) + return request.user.has_perm("issuer.is_editor", issuer) class IsEditorButOwnerForDelete(permissions.BasePermission): @@ -140,11 +300,11 @@ class IsEditorButOwnerForDelete(permissions.BasePermission): def has_object_permission(self, request, view, issuer): if request.method in SAFE_METHODS: - return request.user.has_perm('issuer.is_staff', issuer) - elif request.method == 'DELETE': - return request.user.has_perm('issuer.is_owner', issuer) + return request.user.has_perm("issuer.is_staff", issuer) + elif request.method == "DELETE": + return request.user.has_perm("issuer.is_owner", issuer) else: - return request.user.has_perm('issuer.is_editor', issuer) + return request.user.has_perm("issuer.is_editor", issuer) class IsStaff(permissions.BasePermission): @@ -154,20 +314,59 @@ class IsStaff(permissions.BasePermission): --- model: Issuer """ + def has_object_permission(self, request, view, issuer): - return _is_server_admin(request) or request.user.has_perm('issuer.is_staff', issuer) + return _is_server_admin(request) or request.user.has_perm( + "issuer.is_staff", issuer + ) + + +class IsNetworkMember(permissions.BasePermission): + """ + Request.user is authorized to access network resources if they are staff + of an issuer that is a member of the network. + --- + model: Issuer (Network) + """ + + def has_object_permission(self, request, view, network): + if not network.is_network: + return False + + return request.user.has_perm("network.is_member", network) + + def has_permission(self, request, view): + """ + For list views where we need to check network membership + based on URL parameters or query parameters + """ + + network_id = view.kwargs.get("network_id") or request.GET.get("network_id") + + if not network_id: + return False + + try: + network = Issuer.objects.get(id=network_id, is_network=True) + return request.user.has_perm("network.is_member", network) + except Issuer.DoesNotExist: + return False class ApprovedIssuersOnly(permissions.BasePermission): def has_object_permission(self, request, view, obj): if _is_server_admin(request): return True - if request.method == 'POST' and getattr(settings, 'BADGR_APPROVED_ISSUERS_ONLY', False): - return request.user.has_perm('issuer.add_issuer') + if request.method == "POST" and getattr( + settings, "BADGR_APPROVED_ISSUERS_ONLY", False + ): + return request.user.has_perm("issuer.add_issuer") return True def has_permission(self, request, view): - return _is_server_admin(request) or self.has_object_permission(request, view, None) + return _is_server_admin(request) or self.has_object_permission( + request, view, None + ) class AuditedModelOwner(permissions.BasePermission): @@ -176,8 +375,9 @@ class AuditedModelOwner(permissions.BasePermission): --- model: BaseAuditedModel """ + def has_object_permission(self, request, view, obj): - created_by_id = getattr(obj, 'created_by_id', None) + created_by_id = getattr(obj, "created_by_id", None) return created_by_id and request.user.id == created_by_id @@ -188,20 +388,29 @@ class VerifiedEmailMatchesRecipientIdentifier(permissions.BasePermission): --- model: BadgeInstance """ + def has_object_permission(self, request, view, obj): if _is_server_admin(request): return True - recipient_identifier = getattr(obj, 'recipient_identifier', None) - if getattr(obj, 'pending', False): - return recipient_identifier and recipient_identifier in request.user.all_recipient_identifiers - return recipient_identifier and recipient_identifier in request.user.all_verified_recipient_identifiers + recipient_identifier = getattr(obj, "recipient_identifier", None) + if getattr(obj, "pending", False): + return ( + recipient_identifier + and recipient_identifier in request.user.all_recipient_identifiers + ) + return ( + recipient_identifier + and recipient_identifier in request.user.all_verified_recipient_identifiers + ) class AuthorizationIsBadgrOAuthToken(permissions.BasePermission): - message = 'Invalid token' + message = "Invalid token" def has_permission(self, request, view): - return _is_server_admin(request) or isinstance(request.auth, oauth2_provider.models.AccessToken) + return _is_server_admin(request) or isinstance( + request.auth, oauth2_provider.models.AccessToken + ) class BadgrOAuthTokenHasScope(permissions.BasePermission): @@ -210,12 +419,12 @@ def has_permission(self, request, view): token = request.auth if not token: - if '*' in valid_scopes: + if "*" in valid_scopes: return True # fallback scopes for authenticated users if request.user and request.user.is_authenticated: - default_auth_scopes = set(['rw:profile', 'rw:issuer', 'rw:backpack']) + default_auth_scopes = set(["rw:profile", "rw:issuer", "rw:backpack"]) if len(set(valid_scopes) & default_auth_scopes) > 0: return True @@ -230,7 +439,6 @@ def has_permission(self, request, view): matching_scopes = set(valid_scopes) & set(token.scope.split()) return not token.is_expired() and len(matching_scopes) > 0 - @classmethod def valid_scopes_for_view(cls, view, method=None): valid_scopes = getattr(view, "valid_scopes", []) @@ -252,22 +460,26 @@ def has_object_permission(self, request, view, obj): return False # badgeclass/assertion objects defer to the issuer for permissions - if hasattr(obj, 'cached_issuer'): + if hasattr(obj, "cached_issuer"): entity_id = obj.cached_issuer.entity_id else: entity_id = obj.entity_id valid_scopes = self._get_valid_scopes(request, view) - valid_scopes = [s for s in valid_scopes if '*' in s] - valid_scopes = set([self._resolve_wildcard(scope, entity_id) for scope in valid_scopes]) + valid_scopes = [s for s in valid_scopes if "*" in s] + valid_scopes = set( + [self._resolve_wildcard(scope, entity_id) for scope in valid_scopes] + ) token_scopes = set(token.scope.split()) - return not token.is_expired() and len(valid_scopes.intersection(token_scopes)) > 0 + return ( + not token.is_expired() and len(valid_scopes.intersection(token_scopes)) > 0 + ) def _resolve_wildcard(self, scope, entity_id): - if scope.endswith(':*'): - base_scope, _ = scope.rsplit(':*', 1) - return ':'.join([base_scope, entity_id]) + if scope.endswith(":*"): + base_scope, _ = scope.rsplit(":*", 1) + return ":".join([base_scope, entity_id]) else: return scope @@ -280,6 +492,6 @@ def _get_valid_scopes(self, request, view): def _is_server_admin(request): try: - return 'rw:serverAdmin' in request.auth.scopes + return "rw:serverAdmin" in request.auth.scopes except AttributeError: return False diff --git a/apps/issuer/public_api.py b/apps/issuer/public_api.py index 3babb1413..9344b4c2a 100644 --- a/apps/issuer/public_api.py +++ b/apps/issuer/public_api.py @@ -1,37 +1,61 @@ -import math +import io import os import re -import io -import urllib.request, urllib.parse, urllib.error import urllib.parse import cairosvg -import openbadges -from PIL import Image +from backpack.models import BackpackCollection from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.files.storage import DefaultStorage -from django.urls import resolve, reverse, Resolver404, NoReverseMatch -from django.http import Http404, HttpResponseRedirect -from django.shortcuts import redirect, render_to_response +from django.db.models import Q +from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.shortcuts import redirect, render +from django.urls import NoReverseMatch, Resolver404, resolve, reverse from django.views.generic import RedirectView +from entity.api import ( + BaseEntityDetailViewPublic, + BaseEntityListView, + UncachedPaginatedViewMixin, + VersionedObjectMixin, +) from entity.serializers import BaseSerializerV2 -from rest_framework import status, permissions +from mainsite.models import BadgrApp +from mainsite.utils import ( + OriginSetting, + convert_svg_to_png, + first_node_match, + fit_image_to_height, + set_url_query_params, +) +from PIL import Image +from rest_framework import permissions, status from rest_framework.exceptions import ValidationError from rest_framework.response import Response from rest_framework.views import APIView +import openbadges -import badgrlog from . import utils -from backpack.models import BackpackCollection -from entity.api import VersionedObjectMixin, BaseEntityListView, UncachedPaginatedViewMixin -from mainsite.models import BadgrApp -from mainsite.utils import (OriginSetting, set_url_query_params, first_node_match, fit_image_to_height, - convert_svg_to_png) -from .serializers_v1 import BadgeClassSerializerV1, IssuerSerializerV1 -from .models import Issuer, BadgeClass, BadgeInstance -logger = badgrlog.BadgrLogger() +from .models import ( + BadgeClass, + BadgeInstance, + Issuer, + LearningPath, + LearningPathBadge, + QrCode, +) +from .serializers_v1 import ( + BadgeClassSerializerV1, + IssuerSerializerV1, + LearningPathSerializerV1, + QrCodeSerializerV1, +) +import logging +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter +from drf_spectacular.types import OpenApiTypes + +logger = logging.getLogger("Badgr.Events") class SlugToEntityIdRedirectMixin(object): @@ -40,7 +64,7 @@ class SlugToEntityIdRedirectMixin(object): def get_entity_id_by_slug(self, slug): try: object = self.model.cached.get(slug=slug) - return getattr(object, 'entity_id', None) + return getattr(object, "entity_id", None) except self.model.DoesNotExist: return None @@ -50,59 +74,80 @@ def get_slug_to_entity_id_redirect_url(self, slug): entity_id = self.get_entity_id_by_slug(slug) if entity_id is None: raise Http404 - return reverse(pattern_name, kwargs={'entity_id': entity_id}) + return reverse(pattern_name, kwargs={"entity_id": entity_id}) except (Resolver404, NoReverseMatch): return None def get_slug_to_entity_id_redirect(self, slug): redirect_url = self.get_slug_to_entity_id_redirect_url(slug) if redirect_url is not None: - query = self.request.META.get('QUERY_STRING', '') + query = self.request.META.get("QUERY_STRING", "") if query: redirect_url = "{}?{}".format(redirect_url, query) return redirect(redirect_url, permanent=True) else: raise Http404 + class JSONListView(BaseEntityListView, UncachedPaginatedViewMixin): """ Abstract List Class """ + permission_classes = (permissions.AllowAny,) allow_any_unauthenticated_access = True def log(self, obj): pass + def get_queryset(self, request, **kwargs): + return self.model.objects.all() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + exclude_orgImg = self.request.query_params.get("exclude_orgImg", None) + if exclude_orgImg: + context["exclude_fields"] = [ + *context.get("exclude_fields", []), + "extensions:OrgImageExtension", + ] + + return context + def get(self, request, **kwargs): - objects = self.model.objects + objects = UncachedPaginatedViewMixin.get_objects(self, request, **kwargs) context = self.get_context_data(**kwargs) serializer_class = self.serializer_class serializer = serializer_class(objects, many=True, context=context) headers = dict() - paginator = getattr(self, 'paginator', None) - if paginator and callable(getattr(paginator, 'get_link_header', None)): + paginator = getattr(self, "paginator", None) + if paginator and callable(getattr(paginator, "get_link_header", None)): link_header = paginator.get_link_header() if link_header: - headers['Link'] = link_header + headers["Link"] = link_header return Response(serializer.data, headers=headers) + class JSONComponentView(VersionedObjectMixin, APIView, SlugToEntityIdRedirectMixin): """ Abstract Component Class """ + permission_classes = (permissions.AllowAny,) allow_any_unauthenticated_access = True authentication_classes = () html_renderer_class = None - template_name = 'public/bot_openbadge.html' + template_name = "public/bot_openbadge.html" def log(self, obj): pass def get_json(self, request, **kwargs): try: - json = self.current_object.get_json(obi_version=self._get_request_obi_version(request), **kwargs) + json = self.current_object.get_json( + obi_version=self._get_request_obi_version(request), **kwargs + ) except ObjectDoesNotExist: raise Http404 @@ -113,7 +158,9 @@ def get(self, request, **kwargs): self.current_object = self.get_object(request, **kwargs) except Http404: if self.slugToEntityIdRedirect: - return self.get_slug_to_entity_id_redirect(kwargs.get('entity_id', None)) + return self.get_slug_to_entity_id_redirect( + kwargs.get("entity_id", None) + ) else: raise @@ -121,7 +168,7 @@ def get(self, request, **kwargs): if self.is_bot(): # if user agent matches a known bot, return a stub html with opengraph tags - return render_to_response(self.template_name, context=self.get_context_data()) + return render(request, self.template_name, context=self.get_context_data()) if self.is_requesting_html(): return HttpResponseRedirect(redirect_to=self.get_badgrapp_redirect()) @@ -133,8 +180,10 @@ def is_bot(self): """ bots get an stub that contains opengraph tags """ - bot_useragents = getattr(settings, 'BADGR_PUBLIC_BOT_USERAGENTS', ['LinkedInBot']) - user_agent = self.request.META.get('HTTP_USER_AGENT', '') + bot_useragents = getattr( + settings, "BADGR_PUBLIC_BOT_USERAGENTS", ["LinkedInBot"] + ) + user_agent = self.request.META.get("HTTP_USER_AGENT", "") if any(a in user_agent for a in bot_useragents): return True return False @@ -143,19 +192,21 @@ def is_wide_bot(self): """ some bots prefer a wide aspect ratio for the image """ - bot_useragents = getattr(settings, 'BADGR_PUBLIC_BOT_USERAGENTS_WIDE', ['LinkedInBot']) - user_agent = self.request.META.get('HTTP_USER_AGENT', '') + bot_useragents = getattr( + settings, "BADGR_PUBLIC_BOT_USERAGENTS_WIDE", ["LinkedInBot"] + ) + user_agent = self.request.META.get("HTTP_USER_AGENT", "") if any(a in user_agent for a in bot_useragents): return True return False def is_requesting_html(self): - if self.format_kwarg == 'json': + if self.format_kwarg == "json": return False - html_accepts = ['text/html'] + html_accepts = ["text/html"] - http_accept = self.request.META.get('HTTP_ACCEPT', 'application/json') + http_accept = self.request.META.get("HTTP_ACCEPT", "application/json") if self.is_bot() or any(a in http_accept for a in html_accepts): return True @@ -164,29 +215,34 @@ def is_requesting_html(self): def get_badgrapp_redirect(self): badgrapp = self.current_object.cached_badgrapp - badgrapp = BadgrApp.cached.get(pk=badgrapp.pk) # ensure we have latest badgrapp information + badgrapp = BadgrApp.cached.get( + pk=badgrapp.pk + ) # ensure we have latest badgrapp information if not badgrapp.public_pages_redirect: - badgrapp = BadgrApp.objects.get_current(request=None) # use the default badgrapp + badgrapp = BadgrApp.objects.get_current( + request=None + ) # use the default badgrapp redirect = badgrapp.public_pages_redirect if not redirect: - redirect = 'https://{}/public/'.format(badgrapp.cors) + redirect = "https://{}/public/".format(badgrapp.cors) else: - if not redirect.endswith('/'): - redirect += '/' + if not redirect.endswith("/"): + redirect += "/" path = self.request.path - stripped_path = re.sub(r'^/public/', '', path) - query_string = self.request.META.get('QUERY_STRING', None) - ret = '{redirect}{path}{query}'.format( + stripped_path = re.sub(r"^/public/", "", path) + query_string = self.request.META.get("QUERY_STRING", None) + ret = "{redirect}{path}{query}".format( redirect=redirect, path=stripped_path, - query='?'+query_string if query_string else '') + query="?" + query_string if query_string else "", + ) return ret @staticmethod def _get_request_obi_version(request): - return request.query_params.get('v', utils.CURRENT_OBI_VERSION) + return request.query_params.get("v") class ImagePropertyDetailView(APIView, SlugToEntityIdRedirectMixin): @@ -202,11 +258,14 @@ def get_object(self, entity_id): return current_object def get(self, request, **kwargs): - - entity_id = kwargs.get('entity_id') + entity_id = kwargs.get("entity_id") current_object = self.get_object(entity_id) - if current_object is None and self.slugToEntityIdRedirect and getattr(request, 'version', 'v1') == 'v2': - return self.get_slug_to_entity_id_redirect(kwargs.get('entity_id', None)) + if ( + current_object is None + and self.slugToEntityIdRedirect + and getattr(request, "version", "v1") == "v2" + ): + return self.get_slug_to_entity_id_redirect(kwargs.get("entity_id", None)) elif current_object is None: return Response(status=status.HTTP_404_NOT_FOUND) @@ -214,15 +273,12 @@ def get(self, request, **kwargs): if not bool(image_prop): return Response(status=status.HTTP_404_NOT_FOUND) - image_type = request.query_params.get('type', 'original') - if image_type not in ['original', 'png']: + image_type = request.query_params.get("type", "original") + if image_type not in ["original", "png"]: raise ValidationError("invalid image type: {}".format(image_type)) - supported_fmts = { - 'square': (1, 1), - 'wide': (1.91, 1) - } - image_fmt = request.query_params.get('fmt', 'square').lower() + supported_fmts = {"square": (1, 1), "wide": (1.91, 1)} + image_fmt = request.query_params.get("fmt", "square").lower() if image_fmt not in list(supported_fmts.keys()): raise ValidationError("invalid image format: {}".format(image_fmt)) @@ -230,24 +286,26 @@ def get(self, request, **kwargs): filename, ext = os.path.splitext(image_prop.name) basename = os.path.basename(filename) dirname = os.path.dirname(filename) - version_suffix = getattr(settings, 'CAIROSVG_VERSION_SUFFIX', '1') - new_name = '{dirname}/converted{version}/{basename}{fmt_suffix}.png'.format( + version_suffix = getattr(settings, "CAIROSVG_VERSION_SUFFIX", "1") + new_name = "{dirname}/converted{version}/{basename}{fmt_suffix}.png".format( dirname=dirname, basename=basename, version=version_suffix, - fmt_suffix="-{}".format(image_fmt) if image_fmt != 'square' else "" + fmt_suffix="-{}".format(image_fmt) if image_fmt != "square" else "", ) storage = DefaultStorage() - if image_type == 'original' and image_fmt == 'square': + if image_type == "original" and image_fmt == "square": image_url = image_prop.url - elif ext == '.svg': + elif ext == ".svg": if not storage.exists(new_name): png_buf = None - with storage.open(image_prop.name, 'rb') as input_svg: - if getattr(settings, 'SVG_HTTP_CONVERSION_ENABLED', False): - max_square = getattr(settings, 'IMAGE_FIELD_MAX_PX', 400) - png_buf = convert_svg_to_png(input_svg.read(), max_square, max_square) + with storage.open(image_prop.name, "rb") as input_svg: + if getattr(settings, "SVG_HTTP_CONVERSION_ENABLED", False): + max_square = getattr(settings, "IMAGE_FIELD_MAX_PX", 600) + png_buf = convert_svg_to_png( + input_svg.read(), max_square, max_square + ) # If conversion using the HTTP service fails, try falling back to python solution if not png_buf: png_buf = io.BytesIO() @@ -255,41 +313,63 @@ def get(self, request, **kwargs): try: cairosvg.svg2png(file_obj=input_svg, write_to=png_buf) except IOError: - return redirect(storage.url(image_prop.name)) # If conversion fails, return existing file. + return redirect( + storage.url(image_prop.name) + ) # If conversion fails, return existing file. img = Image.open(png_buf) img = fit_image_to_height(img, supported_fmts[image_fmt]) out_buf = io.BytesIO() - img.save(out_buf, format='png') + img.save(out_buf, format="png") storage.save(new_name, out_buf) image_url = storage.url(new_name) else: if not storage.exists(new_name): - with storage.open(image_prop.name, 'rb') as input_png: + with storage.open(image_prop.name, "rb") as input_png: out_buf = io.BytesIO() # height and width set to the Height and Width of the original badge img = Image.open(input_png) img = fit_image_to_height(img, supported_fmts[image_fmt]) - img.save(out_buf, format='png') + img.save(out_buf, format="png") storage.save(new_name, out_buf) image_url = storage.url(new_name) return redirect(image_url) +@extend_schema( + description="Returns public issuer details", + parameters=[ + OpenApiParameter( + name="entity_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="Issuer ID", + ) + ], + responses={ + 200: OpenApiResponse( + description="Issuer JSON", + response=BadgeClassSerializerV1, + ) + }, + tags=["Issuers"], +) class IssuerJson(JSONComponentView): permission_classes = (permissions.AllowAny,) model = Issuer def log(self, obj): - logger.event(badgrlog.IssuerRetrievedEvent(obj, self.request)) + logger.info("Retrieved issuer '%s'", obj) def get_context_data(self, **kwargs): image_url = "{}{}?type=png".format( OriginSetting.HTTP, - reverse('issuer_image', kwargs={'entity_id': self.current_object.entity_id}) + reverse( + "issuer_image", kwargs={"entity_id": self.current_object.entity_id} + ), ) if self.is_wide_bot(): image_url = "{}&fmt=wide".format(image_url) @@ -298,31 +378,178 @@ def get_context_data(self, **kwargs): title=self.current_object.name, description=self.current_object.description, public_url=self.current_object.public_url, - image_url=image_url + image_url=image_url, ) +@extend_schema( + description="Returns public details of badgeclasses for an issuer", + parameters=[ + OpenApiParameter( + name="entity_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="Issuer ID", + ) + ], + responses={ + 200: OpenApiResponse( + description="Issuer Badges JSON", + response=BadgeClassSerializerV1, + ) + }, + tags=["BadgeClasses"], +) class IssuerBadgesJson(JSONComponentView): permission_classes = (permissions.AllowAny,) model = Issuer def log(self, obj): - logger.event(badgrlog.IssuerBadgesRetrievedEvent(obj, self.request)) + logger.info("Retrieved issuer badges '%s'", obj) + + def get_json(self, request): + obi_version = self._get_request_obi_version(request) + + lps = self.current_object.learningpaths.all() + ignore_classes = [i.participationBadge for i in lps if not i.activated] + + return [ + b.get_json(obi_version=obi_version) + for b in self.current_object.cached_badgeclasses() + if b not in ignore_classes + ] + + +@extend_schema( + description="Returns public details of learning paths for an issuer", + parameters=[ + OpenApiParameter( + name="entity_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="Issuer ID", + ) + ], + responses={ + 200: OpenApiResponse( + description="Issuer LearningPaths JSON", + response=LearningPathSerializerV1, + ) + }, + tags=["LearningPaths"], +) +class IssuerLearningPathsJson(JSONComponentView): + permission_classes = (permissions.AllowAny,) + model = Issuer def get_json(self, request): - obi_version=self._get_request_obi_version(request) + obi_version = self._get_request_obi_version(request) + + return [ + b.get_json(obi_version=obi_version) + for b in self.current_object.cached_learningpaths() + ] + - return [b.get_json(obi_version=obi_version) for b in self.current_object.cached_badgeclasses()] +@extend_schema(exclude=True) +class NetworkIssuersJson(JSONComponentView): + permission_classes = (permissions.AllowAny,) + model = Issuer + def log(self, obj): + logger.info("Retrieved network issuers '%s'", obj) + def get(self, request, **kwargs): + """ + Retrieves networks for a given issuer identified by its slug. + """ + network_slug = kwargs.get("entity_id") + try: + network = Issuer.objects.get(entity_id=network_slug) + except Issuer.DoesNotExist: + return Response({"detail": "Issuer not found."}, status=404) + + self.log(network) + + json_data = self.get_json(request=request, network=network) + + return Response(json_data) + + def get_json(self, request, network): + """ + Get the list of issuers for a given network. + """ + issuers = Issuer.objects.filter(network_memberships__network=network) + + return [ + { + "slug": issuer.entity_id, + "name": issuer.name, + "image": issuer.image.url, + } + for issuer in issuers + ] + + +@extend_schema(exclude=True) +class IssuerNetworksJson(JSONComponentView): + permission_classes = (permissions.AllowAny,) + model = Issuer + + def log(self, obj): + logger.info("Retrieved networks for issuer '%s'", obj) + + def get_json(self, request, issuer): + """ + Get the list of networks for a given issuer. + """ + networks = Issuer.objects.filter(memberships__issuer=issuer, is_network=True) + + return [ + { + "slug": network.entity_id, + "name": network.name, + "image": network.image.url, + } + for network in networks + ] + + def get(self, request, **kwargs): + """ + Retrieves networks for a given issuer identified by its slug. + """ + issuer_slug = kwargs.get("entity_id") + try: + issuer = Issuer.objects.get(entity_id=issuer_slug) + except Issuer.DoesNotExist: + return Response({"detail": "Issuer not found."}, status=404) + + self.log(issuer) + + json_data = self.get_json(request=request, issuer=issuer) + + return Response(json_data) + + +@extend_schema(exclude=True) class IssuerImage(ImagePropertyDetailView): model = Issuer - prop = 'image' + prop = "image" def log(self, obj): - logger.event(badgrlog.IssuerImageRetrievedEvent(obj, self.request)) + logger.info("Issuer image retrieved event '%s'", obj) +@extend_schema( + description="Returns public issuers", + responses={ + 200: OpenApiResponse( + description="Issuer list", + response=IssuerSerializerV1, + ) + }, + tags=["Issuers"], +) class IssuerList(JSONListView): permission_classes = (permissions.AllowAny,) model = Issuer @@ -331,31 +558,118 @@ class IssuerList(JSONListView): def log(self, obj): pass + def get_context_data(self, **kwargs): + context = super(IssuerList, self).get_context_data(**kwargs) + + # some fields have to be excluded due to data privacy concerns + # in the get routes + if self.request.method == "GET": + context["exclude_fields"] = [ + *context.get("exclude_fields", []), + "staff", + "created_by", + "email", + ] + return context + def get_json(self, request): return super(IssuerList, self).get_json(request) +@extend_schema( + description="Returns issuers that match the search term", + responses={ + 200: OpenApiResponse( + description="Issuer Search", + response=IssuerSerializerV1, + ) + }, + tags=["Issuers"], +) +class IssuerSearch(JSONListView): + permission_classes = (permissions.AllowAny,) + model = Issuer + serializer_class = IssuerSerializerV1 + + def log(self, obj): + pass + + def get(self, request, **kwargs): + objects = self.model.objects + + issuers = [] + search_term = kwargs.get("searchterm", "") + if search_term: + issuers = objects.filter( + Q(name__icontains=search_term) | Q(description__icontains=search_term) + ) + serializer_class = self.serializer_class + serializer = serializer_class( + issuers, many=True, context={"exclude_fields": ["staff", "created_by"]} + ) + return Response(serializer.data) + + +@extend_schema( + description="Returns public badge class details", + responses={ + 200: OpenApiResponse( + description="Badge class JSON", + response=BadgeClassSerializerV1, + ) + }, + parameters=[ + OpenApiParameter( + name="entity_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="BadgeClass ID", + ), + OpenApiParameter( + name="expand", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + style="form", + explode=True, + description='Fields to expand ("issuer")', + ), + ], + tags=["BadgeClasses"], +) class BadgeClassJson(JSONComponentView): permission_classes = (permissions.AllowAny,) model = BadgeClass def log(self, obj): - logger.event(badgrlog.BadgeClassRetrievedEvent(obj, self.request)) + logger.info("Badge class retrieved '%s'", obj) def get_json(self, request): - expands = request.GET.getlist('expand', []) + expands = request.GET.getlist("expand", []) json = super(BadgeClassJson, self).get_json(request) obi_version = self._get_request_obi_version(request) - if 'issuer' in expands: - json['issuer'] = self.current_object.cached_issuer.get_json(obi_version=obi_version) + if self.current_object.cached_issuer.is_network: + json["isNetworkBadge"] = True + json["networkName"] = self.current_object.cached_issuer.name + json["networkImage"] = self.current_object.cached_issuer.image.url + else: + json["isNetworkBadge"] = False + json["networkName"] = None + json["networkImage"] = None + + if "issuer" in expands: + json["issuer"] = self.current_object.cached_issuer.get_json( + obi_version=obi_version + ) return json def get_context_data(self, **kwargs): image_url = "{}{}?type=png".format( OriginSetting.HTTP, - reverse('badgeclass_image', kwargs={'entity_id': self.current_object.entity_id}) + reverse( + "badgeclass_image", kwargs={"entity_id": self.current_object.entity_id} + ), ) if self.is_wide_bot(): image_url = "{}&fmt=wide".format(image_url) @@ -363,46 +677,70 @@ def get_context_data(self, **kwargs): title=self.current_object.name, description=self.current_object.description, public_url=self.current_object.public_url, - image_url=image_url + image_url=image_url, ) - +@extend_schema( + description="Returns list of badge classes", + responses={ + 200: OpenApiResponse( + description="Badge class list", + response=BadgeClassSerializerV1, + ) + }, + tags=["BadgeClasses"], +) class BadgeClassList(JSONListView): permission_classes = (permissions.AllowAny,) model = BadgeClass serializer_class = BadgeClassSerializerV1 def log(self, obj): - logger.event(badgrlog.BadgeClassRetrievedEvent(obj, self.request)) + logger.info("Badge class list retrieved '%s'", obj) + + def get_context_data(self, **kwargs): + context = super(BadgeClassList, self).get_context_data(**kwargs) + + # some fields have to be excluded due to data privacy concerns + # in the get routes + if self.request.method == "GET": + context["exclude_fields"] = [ + *context.get("exclude_fields", []), + "created_by", + ] + return context def get_json(self, request): return super(BadgeClassList, self).get_json(request) +@extend_schema(exclude=True) class BadgeClassImage(ImagePropertyDetailView): model = BadgeClass - prop = 'image' + prop = "image" def log(self, obj): - logger.event(badgrlog.BadgeClassImageRetrievedEvent(obj, self.request)) + logger.info("Badge class image retrieved '%s'", obj) +@extend_schema(exclude=True) class BadgeClassCriteria(RedirectView, SlugToEntityIdRedirectMixin): permanent = False model = BadgeClass def get_redirect_url(self, *args, **kwargs): try: - badge_class = self.model.cached.get(entity_id=kwargs.get('entity_id')) + badge_class = self.model.cached.get(entity_id=kwargs.get("entity_id")) except self.model.DoesNotExist: if self.slugToEntityIdRedirect: - return self.get_slug_to_entity_id_redirect_url(kwargs.get('entity_id')) + return self.get_slug_to_entity_id_redirect_url(kwargs.get("entity_id")) else: return None return badge_class.get_absolute_url() +@extend_schema(exclude=True) class BadgeInstanceJson(JSONComponentView): permission_classes = (permissions.AllowAny,) model = BadgeInstance @@ -413,11 +751,11 @@ def has_object_permissions(self, request, obj): return super(BadgeInstanceJson, self).has_object_permissions(request, obj) def get_json(self, request): - expands = request.GET.getlist('expand', []) + expands = request.GET.getlist("expand", []) json = super(BadgeInstanceJson, self).get_json( request, - expand_badgeclass=('badge' in expands), - expand_issuer=('badge.issuer' in expands) + expand_badgeclass=("badge" in expands), + expand_issuer=("badge.issuer" in expands), ) return json @@ -425,33 +763,37 @@ def get_json(self, request): def get_context_data(self, **kwargs): image_url = "{}{}?type=png".format( OriginSetting.HTTP, - reverse('badgeclass_image', kwargs={'entity_id': self.current_object.cached_badgeclass.entity_id}) + reverse( + "badgeclass_image", + kwargs={"entity_id": self.current_object.cached_badgeclass.entity_id}, + ), ) if self.is_wide_bot(): image_url = "{}&fmt=wide".format(image_url) - oembed_link_url = '{}{}?format=json&url={}'.format( - getattr(settings, 'HTTP_ORIGIN'), - reverse('oembed_api_endpoint'), - urllib.parse.quote(self.current_object.public_url) + oembed_link_url = "{}{}?format=json&url={}".format( + getattr(settings, "HTTP_ORIGIN"), + reverse("oembed_api_endpoint"), + urllib.parse.quote(self.current_object.public_url), ) return dict( - user_agent=self.request.META.get('HTTP_USER_AGENT', ''), + user_agent=self.request.META.get("HTTP_USER_AGENT", ""), title=self.current_object.cached_badgeclass.name, description=self.current_object.cached_badgeclass.description, public_url=self.current_object.public_url, image_url=image_url, - oembed_link_url=oembed_link_url + oembed_link_url=oembed_link_url, ) +@extend_schema(exclude=True) class BadgeInstanceImage(ImagePropertyDetailView): model = BadgeInstance - prop = 'image' + prop = "image" def log(self, badge_instance): - logger.event(badgrlog.BadgeInstanceDownloadedEvent(badge_instance, self.request)) + logger.info("Badge instance '%s' downloaded", badge_instance.entity_id) def get_object(self, slug): obj = super(BadgeInstanceImage, self).get_object(slug) @@ -460,18 +802,31 @@ def get_object(self, slug): return obj +@extend_schema(exclude=True) +class BadgeInstanceRevocations(JSONComponentView): + model = BadgeInstance + + def get_json(self, request): + return self.current_object.get_revocation_json() + + +@extend_schema(exclude=True) class BackpackCollectionJson(JSONComponentView): permission_classes = (permissions.AllowAny,) model = BackpackCollection - entity_id_field_name = 'share_hash' def get_context_data(self, **kwargs): - image_url = '' + image_url = "" if self.current_object.cached_badgeinstances().exists(): - chosen_assertion = sorted(self.current_object.cached_badgeinstances(), key=lambda b: b.issued_on)[0] + chosen_assertion = sorted( + self.current_object.cached_badgeinstances(), key=lambda b: b.issued_on + )[0] image_url = "{}{}?type=png".format( OriginSetting.HTTP, - reverse('badgeinstance_image', kwargs={'entity_id': chosen_assertion.entity_id}) + reverse( + "badgeinstance_image", + kwargs={"entity_id": chosen_assertion.entity_id}, + ), ) if self.is_wide_bot(): image_url = "{}&fmt=wide".format(image_url) @@ -480,23 +835,65 @@ def get_context_data(self, **kwargs): title=self.current_object.name, description=self.current_object.description, public_url=self.current_object.share_url, - image_url=image_url + image_url=image_url, ) + def get(self, request, **kwargs): + try: + return super().get(request, **kwargs) + except Http404: + if self.is_requesting_html(): + return HttpResponseRedirect( + redirect_to=self.get_default_badgrapp_redirect() + ) + else: + return HttpResponse(status=204) + + def get_default_badgrapp_redirect(self): + badgrapp = BadgrApp.objects.get_current( + request=None + ) # use the default badgrapp + + redirect = badgrapp.public_pages_redirect + if not redirect: + redirect = "https://{}/public/".format(badgrapp.cors) + else: + if not redirect.endswith("/"): + redirect += "/" + + path = self.request.path + stripped_path = re.sub(r"^/public/", "", path) + + if self.kwargs.get("entity_id", None): + stripped_path = re.sub( + self.kwargs.get("entity_id", ""), "not-found", stripped_path + ) + ret = "{redirect}{path}".format( + redirect=redirect, + path=stripped_path, + ) + return ret + def get_json(self, request): - expands = request.GET.getlist('expand', []) + # bypass cached version with old share_hash + self.current_object.refresh_from_db() + + expands = request.GET.getlist("expand", []) if not self.current_object.published: raise Http404 json = self.current_object.get_json( obi_version=self._get_request_obi_version(request), - expand_badgeclass=('badges.badge' in expands), - expand_issuer=('badges.badge.issuer' in expands) + expand_badgeclass=("badges.badge" in expands), + expand_issuer=("badges.badge.issuer" in expands), ) return json -class BakedBadgeInstanceImage(VersionedObjectMixin, APIView, SlugToEntityIdRedirectMixin): +@extend_schema(exclude=True) +class BakedBadgeInstanceImage( + VersionedObjectMixin, APIView, SlugToEntityIdRedirectMixin +): permission_classes = (permissions.AllowAny,) allow_any_unauthenticated_access = True model = BadgeInstance @@ -506,22 +903,26 @@ def get(self, request, **kwargs): assertion = self.get_object(request, **kwargs) except Http404: if self.slugToEntityIdRedirect: - return self.get_slug_to_entity_id_redirect(kwargs.get('entity_id', None)) + return self.get_slug_to_entity_id_redirect( + kwargs.get("entity_id", None) + ) else: raise - requested_version = request.query_params.get('v', utils.CURRENT_OBI_VERSION) + requested_version = request.query_params.get("v") + + if not requested_version: + requested_version = "3_0" if assertion.ob_json_3_0 else "2_0" + if requested_version not in list(utils.OBI_VERSION_CONTEXT_IRIS.keys()): raise ValidationError("Invalid OpenBadges version") - # self.log(assertion) - redirect_url = assertion.get_baked_image_url(obi_version=requested_version) return redirect(redirect_url, permanent=True) - +@extend_schema(exclude=True) class OEmbedAPIEndpoint(APIView): permission_classes = (permissions.AllowAny,) @@ -534,52 +935,55 @@ def get_object(url): except Http404: raise Http404("Cannot find resource.") - if resolved.url_name == 'badgeinstance_json': - return BadgeInstance.cached.get(entity_id=resolved.kwargs.get('entity_id')) - raise Http404('Cannot find resource.') + if resolved.url_name == "badgeinstance_json": + return BadgeInstance.cached.get(entity_id=resolved.kwargs.get("entity_id")) + raise Http404("Cannot find resource.") def get_badgrapp_redirect(self, entity): badgrapp = entity.cached_badgrapp if not badgrapp or not badgrapp.public_pages_redirect: - badgrapp = BadgrApp.objects.get_current(request=None) # use the default badgrapp + badgrapp = BadgrApp.objects.get_current( + request=None + ) # use the default badgrapp redirect_url = badgrapp.public_pages_redirect if not redirect_url: - redirect_url = 'https://{}/public/'.format(badgrapp.cors) + redirect_url = "https://{}/public/".format(badgrapp.cors) else: - if not redirect_url.endswith('/'): - redirect_url += '/' + if not redirect_url.endswith("/"): + redirect_url += "/" path = entity.get_absolute_url() - stripped_path = re.sub(r'^/public/', '', path) - ret = '{redirect}{path}'.format( - redirect=redirect_url, - path=stripped_path) + stripped_path = re.sub(r"^/public/", "", path) + ret = "{redirect}{path}".format(redirect=redirect_url, path=stripped_path) ret = set_url_query_params(ret, embedVersion=2) return ret def get_max_constrained_height(self, request): min_height = 420 - height = int(request.query_params.get('maxwidth', min_height)) + height = int(request.query_params.get("maxwidth", min_height)) return max(min_height, height) def get_max_constrained_width(self, request): max_width = 800 min_width = 320 - width = int(request.query_params.get('maxwidth', max_width)) + width = int(request.query_params.get("maxwidth", max_width)) return max(min_width, min(width, max_width)) def get(self, request, **kwargs): try: - url = request.query_params.get('url') + url = request.query_params.get("url") constrained_height = self.get_max_constrained_height(request) constrained_width = self.get_max_constrained_width(request) - response_format = request.query_params.get('format', 'json') + response_format = request.query_params.get("format", "json") except (TypeError, ValueError): raise ValidationError("Cannot parse OEmbed request parameters.") - if response_format != 'json': - return Response("Only json format is supported at this time.", status=status.HTTP_501_NOT_IMPLEMENTED) + if response_format != "json": + return Response( + "Only json format is supported at this time.", + status=status.HTTP_501_NOT_IMPLEMENTED, + ) try: badgeinstance = self.get_object(url) @@ -591,32 +995,45 @@ def get(self, request, **kwargs): badgrapp = BadgrApp.objects.get_current(request) data = { - 'type': 'rich', - 'version': '1.0', - 'title': badgeclass.name, - 'author_name': issuer.name, - 'author_url': issuer.url, - 'provider_name': badgrapp.name, - 'provider_url': badgrapp.ui_login_redirect, - 'thumbnail_url': badgeinstance.image_url(), - 'thumnail_width': 200, # TODO: get real data; respect maxwidth - 'thumbnail_height': 200, # TODO: get real data; respect maxheight - 'width': constrained_width, - 'height': constrained_height + "type": "rich", + "version": "1.0", + "title": badgeclass.name, + "author_name": issuer.name, + "author_url": issuer.url, + "provider_name": badgrapp.name, + "provider_url": badgrapp.ui_login_redirect, + "thumbnail_url": badgeinstance.image_url(), + "thumnail_width": 200, # TODO: get real data; respect maxwidth + "thumbnail_height": 200, # TODO: get real data; respect maxheight + "width": constrained_width, + "height": constrained_height, } - data['html'] = """""".format( - src=self.get_badgrapp_redirect(badgeinstance), - width=constrained_width, - height=constrained_height + data["html"] = ( + """""".format( + src=self.get_badgrapp_redirect(badgeinstance), + width=constrained_width, + height=constrained_height, + ) ) return Response(data, status=status.HTTP_200_OK) - +@extend_schema( + summary="Verify a Badge", + description="Verify a badge by entity ID. Returns the badge instance JSON including expanded badge class and issuer.", + request={"application/json": None}, + responses={ + 200: {"type": "object"}, + 404: {"type": "object"}, + 400: {"type": "object"}, + }, + tags=["Assertions"], +) class VerifyBadgeAPIEndpoint(JSONComponentView): permission_classes = (permissions.AllowAny,) + @staticmethod def get_object(entity_id): try: @@ -626,65 +1043,105 @@ def get_object(entity_id): raise Http404 def post(self, request, **kwargs): - entity_id = request.data.get('entity_id') + entity_id = request.data.get("entity_id") badge_instance = self.get_object(entity_id) - #only do badgecheck verify if not a local badge - if (badge_instance.source_url): + # only do badgecheck verify if not a local badge + if badge_instance.source_url: recipient_profile = { badge_instance.recipient_type: badge_instance.recipient_identifier } badge_check_options = { - 'include_original_json': True, - 'use_cache': True, + "include_original_json": True, + "use_cache": True, } try: - response = openbadges.verify(badge_instance.jsonld_id, recipient_profile=recipient_profile, **badge_check_options) + response = openbadges.verify( + badge_instance.jsonld_id, + recipient_profile=recipient_profile, + **badge_check_options, + ) except ValueError as e: - raise ValidationError([{'name': "INVALID_BADGE", 'description': str(e)}]) + raise ValidationError( + [{"name": "INVALID_BADGE", "description": str(e)}] + ) - graph = response.get('graph', []) + graph = response.get("graph", []) revoked_obo = first_node_match(graph, dict(revoked=True)) if bool(revoked_obo): - instance = BadgeInstance.objects.get(source_url=revoked_obo['id']) + instance = BadgeInstance.objects.get(source_url=revoked_obo["id"]) if not instance.revoked: - instance.revoke(revoked_obo.get('revocationReason', 'Badge is revoked')) + instance.revoke( + revoked_obo.get("revocationReason", "Badge is revoked") + ) else: - report = response.get('report', {}) - is_valid = report.get('valid') + report = response.get("report", {}) + is_valid = report.get("valid") if not is_valid: - if report.get('errorCount', 0) > 0: - errors = [{'name': 'UNABLE_TO_VERIFY', 'description': 'Unable to verify the assertion'}] + if report.get("errorCount", 0) > 0: + errors = [ + { + "name": "UNABLE_TO_VERIFY", + "description": "Unable to verify the assertion", + } + ] raise ValidationError(errors) - validation_subject = report.get('validationSubject') + validation_subject = report.get("validationSubject") - badge_instance_obo = first_node_match(graph, dict(id=validation_subject)) + badge_instance_obo = first_node_match( + graph, dict(id=validation_subject) + ) if not badge_instance_obo: - raise ValidationError([{'name': 'ASSERTION_NOT_FOUND', 'description': 'Unable to find an badge instance'}]) - - badgeclass_obo = first_node_match(graph, dict(id=badge_instance_obo.get('badge', None))) + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find an badge instance", + } + ] + ) + + badgeclass_obo = first_node_match( + graph, dict(id=badge_instance_obo.get("badge", None)) + ) if not badgeclass_obo: - raise ValidationError([{'name': 'ASSERTION_NOT_FOUND', 'description': 'Unable to find a badgeclass'}]) - - issuer_obo = first_node_match(graph, dict(id=badgeclass_obo.get('issuer', None))) + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find a badgeclass", + } + ] + ) + + issuer_obo = first_node_match( + graph, dict(id=badgeclass_obo.get("issuer", None)) + ) if not issuer_obo: - raise ValidationError([{'name': 'ASSERTION_NOT_FOUND', 'description': 'Unable to find an issuer'}]) + raise ValidationError( + [ + { + "name": "ASSERTION_NOT_FOUND", + "description": "Unable to find an issuer", + } + ] + ) - original_json = response.get('input').get('original_json', {}) + original_json = response.get("input").get("original_json", {}) BadgeInstance.objects.update_from_ob2( badge_instance.badgeclass, badge_instance_obo, badge_instance.recipient_identifier, badge_instance.recipient_type, - original_json.get(badge_instance_obo.get('id', ''), None) + original_json.get(badge_instance_obo.get("id", ""), None), ) badge_instance.rebake(save=True) @@ -692,13 +1149,133 @@ def post(self, request, **kwargs): BadgeClass.objects.update_from_ob2( badge_instance.issuer, badgeclass_obo, - original_json.get(badgeclass_obo.get('id', ''), None) + original_json.get(badgeclass_obo.get("id", ""), None), ) Issuer.objects.update_from_ob2( - issuer_obo, - original_json.get(issuer_obo.get('id', ''), None) + issuer_obo, original_json.get(issuer_obo.get("id", ""), None) ) - result = self.get_object(entity_id).get_json(expand_badgeclass=True, expand_issuer=True) + result = self.get_object(entity_id).get_json( + expand_badgeclass=True, expand_issuer=True + ) + + return Response( + BaseSerializerV2.response_envelope([result], True, "OK"), + status=status.HTTP_200_OK, + ) + + +@extend_schema( + summary="Retrieve a Learning Path", + description="Get detailed JSON data for a Learning Path by its slug or ID.", + parameters=[ + OpenApiParameter( + name="entity_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="Slug or ID of the Learning Path", + ) + ], + responses=LearningPathSerializerV1, + tags=["LearningPaths"], +) +class LearningPathJson(BaseEntityDetailViewPublic, SlugToEntityIdRedirectMixin): + permission_classes = (permissions.AllowAny,) + model = LearningPath + serializer_class = LearningPathSerializerV1 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["exclude_fields"] = [ + *context.get("exclude_fields", []), + "created_by", + ] + + return context + + +@extend_schema( + summary="Get list of public learning paths", + responses=LearningPathSerializerV1, + tags=["LearningPaths"], +) +class LearningPathList(JSONListView): + permission_classes = (permissions.AllowAny,) + model = LearningPath + serializer_class = LearningPathSerializerV1 + + def get_queryset(self, request, **kwargs): + queryset = LearningPath.objects.filter(activated=True) + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["exclude_fields"] = [ + *context.get("exclude_fields", []), + "created_by", + ] + + return context + + def get_json(self, request): + return super(LearningPathList, self).get_json(request) + + +@extend_schema(exclude=True) +class BadgeLearningPathList(JSONListView): + permission_classes = (permissions.AllowAny,) + model = LearningPath + serializer_class = LearningPathSerializerV1 + + def get(self, request, entity_id=None, *args, **kwargs): + try: + badge = BadgeClass.objects.get(entity_id=entity_id) + except BadgeClass.DoesNotExist: + raise Http404 + + learningpath_badges = LearningPathBadge.objects.filter( + badge=badge + ).select_related("learning_path") + + learning_paths = { + lpb.learning_path for lpb in learningpath_badges + } # Use set comprehension for uniqueness + + serialized_learning_paths = self.serializer_class( + learning_paths, + many=True, + context={"request": request, "exclude_fields": ["created_by"]}, + ) + + return Response(serialized_learning_paths.data) + + +@extend_schema( + summary="Get details of qr code", + parameters=[ + OpenApiParameter( + name="entity_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="QR Code ID", + ) + ], + responses=QrCodeSerializerV1, + tags=["QrCodes"], +) +class QRCodeJson(BaseEntityDetailViewPublic, SlugToEntityIdRedirectMixin): + """ + Public QRCode endpoint for badge requests + Allows unauthenticated users to fetch QR code details + """ + + permission_classes = (permissions.AllowAny,) + model = QrCode + serializer_class = QrCodeSerializerV1 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) - return Response(BaseSerializerV2.response_envelope([result], True, 'OK'), status=status.HTTP_200_OK) + return context diff --git a/apps/issuer/public_api_urls.py b/apps/issuer/public_api_urls.py index 46ccd76d0..91dacaf01 100644 --- a/apps/issuer/public_api_urls.py +++ b/apps/issuer/public_api_urls.py @@ -1,33 +1,152 @@ -from django.conf.urls import url +from django.urls import re_path from django.views.decorators.clickjacking import xframe_options_exempt from rest_framework.urlpatterns import format_suffix_patterns -from .public_api import (IssuerJson, IssuerList, IssuerBadgesJson, IssuerImage, BadgeClassJson,BadgeClassList, - BadgeClassImage, BadgeClassCriteria, BadgeInstanceJson, - BadgeInstanceImage, BackpackCollectionJson, BakedBadgeInstanceImage, - OEmbedAPIEndpoint, VerifyBadgeAPIEndpoint) +from .public_api import ( + BackpackCollectionJson, + BadgeClassCriteria, + BadgeClassImage, + BadgeClassJson, + BadgeClassList, + BadgeInstanceImage, + BadgeInstanceRevocations, + BadgeInstanceJson, + BadgeLearningPathList, + BakedBadgeInstanceImage, + IssuerBadgesJson, + IssuerImage, + IssuerJson, + IssuerLearningPathsJson, + IssuerList, + IssuerNetworksJson, + IssuerSearch, + LearningPathJson, + LearningPathList, + NetworkIssuersJson, + OEmbedAPIEndpoint, + QRCodeJson, + VerifyBadgeAPIEndpoint, +) json_patterns = [ - url(r'^issuers/(?P[^/.]+)$', xframe_options_exempt(IssuerJson.as_view(slugToEntityIdRedirect=True)), name='issuer_json'), - url(r'^issuers/(?P[^/.]+)/badges$', xframe_options_exempt(IssuerBadgesJson.as_view(slugToEntityIdRedirect=True)), name='issuer_badges_json'), - url(r'^all-issuers$', xframe_options_exempt(IssuerList.as_view()), name='issuer_list_json'), - url(r'^badges/(?P[^/.]+)$', xframe_options_exempt(BadgeClassJson.as_view(slugToEntityIdRedirect=True)), name='badgeclass_json'), - url(r'^all-badges$', xframe_options_exempt(BadgeClassList.as_view()), name='badgeclass_list_json'), - url(r'^assertions/(?P[^/.]+)$', xframe_options_exempt(BadgeInstanceJson.as_view(slugToEntityIdRedirect=True)), name='badgeinstance_json'), - - url(r'^collections/(?P[^/.]+)$', xframe_options_exempt(BackpackCollectionJson.as_view(slugToEntityIdRedirect=True)), name='collection_json'), - - url(r'^oembed$', OEmbedAPIEndpoint.as_view(), name='oembed_api_endpoint'), - - url(r'^verify$', VerifyBadgeAPIEndpoint.as_view(), name='verify_badge_api_endpoint') + re_path( + r"^issuers/(?P[^/.]+)$", + xframe_options_exempt(IssuerJson.as_view(slugToEntityIdRedirect=True)), + name="issuer_json", + ), + re_path( + r"^issuers/search/(?P[^/]+)$", + xframe_options_exempt(IssuerSearch.as_view()), + name="issuer_search", + ), + re_path( + r"^issuers/(?P[^/.]+)/badges$", + xframe_options_exempt(IssuerBadgesJson.as_view(slugToEntityIdRedirect=True)), + name="issuer_badges_json", + ), + re_path( + r"^issuers/(?P[^/.]+)/learningpaths$", + xframe_options_exempt( + IssuerLearningPathsJson.as_view(slugToEntityIdRedirect=True) + ), + name="issuer_learningpaths_json", + ), + re_path( + r"^issuers/(?P[^/.]+)/networks$", + xframe_options_exempt(IssuerNetworksJson.as_view(slugToEntityIdRedirect=True)), + name="issuer_networks_json", + ), + re_path( + r"^networks/(?P[^/.]+)/issuers$", + xframe_options_exempt(NetworkIssuersJson.as_view(slugToEntityIdRedirect=True)), + name="network_issuers_json", + ), + re_path( + r"^all-issuers$", + xframe_options_exempt(IssuerList.as_view()), + name="issuer_list_json", + ), + re_path( + r"^badges/(?P[^/.]+)$", + xframe_options_exempt(BadgeClassJson.as_view(slugToEntityIdRedirect=True)), + name="badgeclass_json", + ), + re_path( + r"^badges/(?P[^/.]+)/learningpaths$", + xframe_options_exempt(BadgeLearningPathList.as_view()), + name="badge_learningpath_list_json", + ), + re_path( + r"^learningpaths/(?P[^/.]+)$", + xframe_options_exempt(LearningPathJson.as_view(slugToEntityIdRedirect=True)), + name="learningpath_json", + ), + re_path( + r"^qrcode/(?P[^/.]+)$", + xframe_options_exempt(QRCodeJson.as_view(slugToEntityIdRedirect=True)), + name="qrcode_json", + ), + re_path( + r"^all-badges$", + xframe_options_exempt(BadgeClassList.as_view()), + name="badgeclass_list_json", + ), + re_path( + r"^all-learningpaths$", + xframe_options_exempt(LearningPathList.as_view()), + name="learningpath_list_json", + ), + re_path( + r"^assertions/(?P[^/.]+)$", + xframe_options_exempt(BadgeInstanceJson.as_view(slugToEntityIdRedirect=True)), + name="badgeinstance_json", + ), + re_path( + r"^assertions/(?P[^/.]+)/revocations$", + xframe_options_exempt( + BadgeInstanceRevocations.as_view(slugToEntityIdRedirect=False) + ), + name="badgeinstance_revocations", + ), + re_path( + r"^collections/(?P[^/.]+)$", + xframe_options_exempt( + BackpackCollectionJson.as_view(slugToEntityIdRedirect=True) + ), + name="collection_json", + ), + re_path(r"^oembed$", OEmbedAPIEndpoint.as_view(), name="oembed_api_endpoint"), + re_path( + r"^verify$", VerifyBadgeAPIEndpoint.as_view(), name="verify_badge_api_endpoint" + ), ] image_patterns = [ - url(r'^issuers/(?P[^/]+)/image$', IssuerImage.as_view(slugToEntityIdRedirect=True), name='issuer_image'), - url(r'^badges/(?P[^/]+)/image', BadgeClassImage.as_view(slugToEntityIdRedirect=True), name='badgeclass_image'), - url(r'^badges/(?P[^/]+)/criteria', BadgeClassCriteria.as_view(slugToEntityIdRedirect=True), name='badgeclass_criteria'), - url(r'^assertions/(?P[^/]+)/image', BadgeInstanceImage.as_view(slugToEntityIdRedirect=True), name='badgeinstance_image'), - url(r'^assertions/(?P[^/]+)/baked', BakedBadgeInstanceImage.as_view(slugToEntityIdRedirect=True), name='badgeinstance_bakedimage'), + re_path( + r"^issuers/(?P[^/]+)/image$", + IssuerImage.as_view(slugToEntityIdRedirect=True), + name="issuer_image", + ), + re_path( + r"^badges/(?P[^/]+)/image", + BadgeClassImage.as_view(slugToEntityIdRedirect=True), + name="badgeclass_image", + ), + re_path( + r"^badges/(?P[^/]+)/criteria", + BadgeClassCriteria.as_view(slugToEntityIdRedirect=True), + name="badgeclass_criteria", + ), + re_path( + r"^assertions/(?P[^/]+)/image", + BadgeInstanceImage.as_view(slugToEntityIdRedirect=True), + name="badgeinstance_image", + ), + re_path( + r"^assertions/(?P[^/]+)/baked", + BakedBadgeInstanceImage.as_view(slugToEntityIdRedirect=True), + name="badgeinstance_bakedimage", + ), ] -urlpatterns = format_suffix_patterns(json_patterns, allowed=['json']) + image_patterns +urlpatterns = format_suffix_patterns(json_patterns, allowed=["json"]) + image_patterns diff --git a/apps/issuer/serializers_v1.py b/apps/issuer/serializers_v1.py index 6459c96be..e5d4c9cc0 100644 --- a/apps/issuer/serializers_v1.py +++ b/apps/issuer/serializers_v1.py @@ -1,33 +1,65 @@ -from apps import badgrlog -import os -import pytz -import uuid import json - import logging +import os +import uuid +from functools import reduce - +from rest_framework.fields import JSONField +import pytz +from badgeuser.models import TermsVersion +from badgeuser.serializers_v1 import ( + BadgeUserIdentifierFieldV1, + BadgeUserProfileSerializerV1, +) from django.core.exceptions import ValidationError as DjangoValidationError -from django.urls import reverse from django.core.validators import EmailValidator, URLValidator from django.db.models import Q -from django.utils.html import strip_tags +from django.urls import reverse from django.utils import timezone -from django.conf import settings -from rest_framework import serializers - -from . import utils -from badgeuser.serializers_v1 import BadgeUserProfileSerializerV1, BadgeUserIdentifierFieldV1 +from django.utils.html import strip_tags from mainsite.drf_fields import ValidImageField from mainsite.models import BadgrApp -from mainsite.serializers import DateTimeWithUtcZAtEndField, HumanReadableBooleanField, StripTagsCharField, MarkdownCharField, \ - OriginalJsonSerializerMixin -from mainsite.utils import OriginSetting -from mainsite.exceptions import BadgrValidationError, BadgrValidationFieldError -from mainsite.validators import ChoicesValidator, BadgeExtensionValidator, PositiveIntegerValidator, TelephoneValidator -from .models import Issuer, BadgeClass, IssuerStaff, BadgeInstance, BadgeClassExtension, RECIPIENT_TYPE_EMAIL, RECIPIENT_TYPE_ID, RECIPIENT_TYPE_URL +from mainsite.serializers import ( + DateTimeWithUtcZAtEndField, + ExcludeFieldsMixin, + HumanReadableBooleanField, + MarkdownCharField, + OriginalJsonSerializerMixin, + StripTagsCharField, +) +from mainsite.utils import OriginSetting, verifyIssuerAutomatically +from mainsite.validators import ( + BadgeExtensionValidator, + ChoicesValidator, + TelephoneValidator, +) +from rest_framework import serializers + +from .models import ( + RECIPIENT_TYPE_EMAIL, + RECIPIENT_TYPE_ID, + RECIPIENT_TYPE_URL, + Area, + BadgeClass, + BadgeClassExtension, + BadgeClassNetworkShare, + BadgeInstance, + Issuer, + IssuerStaff, + IssuerStaffRequest, + LearningPath, + LearningPathBadge, + NetworkInvite, + QrCode, + RequestedBadge, + RequestedLearningPath, +) +from django.db import transaction +from drf_spectacular.utils import extend_schema_field +from drf_spectacular.types import OpenApiTypes + +logger = logging.getLogger("Badgr.Events") -logger = logging.getLogger(__name__) class ExtensionsSaverMixin(object): def remove_extensions(self, instance, extensions_to_remove): @@ -36,7 +68,9 @@ def remove_extensions(self, instance, extensions_to_remove): if ext.name in extensions_to_remove: ext.delete() - def update_extensions(self, instance, extensions_to_update, received_extension_items): + def update_extensions( + self, instance, extensions_to_update, received_extension_items + ): logger.debug("UPDATING EXTENSION") logger.debug(received_extension_items) current_extensions = instance.cached_extensions() @@ -48,159 +82,356 @@ def update_extensions(self, instance, extensions_to_update, received_extension_i def save_extensions(self, validated_data, instance): logger.debug("SAVING EXTENSION IN MIXIN") - logger.debug(validated_data.get('extension_items', False)) - if validated_data.get('extension_items', False): - extension_items = validated_data.pop('extension_items') + logger.debug(validated_data.get("extension_items", False)) + if validated_data.get("extension_items", False): + extension_items = validated_data.pop("extension_items") received_extensions = list(extension_items.keys()) current_extension_names = list(instance.extension_items.keys()) - remove_these_extensions = set(current_extension_names) - set(received_extensions) - update_these_extensions = set(current_extension_names).intersection(set(received_extensions)) - add_these_extensions = set(received_extensions) - set(current_extension_names) + remove_these_extensions = set(current_extension_names) - set( + received_extensions + ) + update_these_extensions = set(current_extension_names).intersection( + set(received_extensions) + ) + add_these_extensions = set(received_extensions) - set( + current_extension_names + ) logger.debug(add_these_extensions) self.remove_extensions(instance, remove_these_extensions) self.update_extensions(instance, update_these_extensions, extension_items) self.add_extensions(instance, add_these_extensions, extension_items) + class CachedListSerializer(serializers.ListSerializer): def to_representation(self, data): return [self.child.to_representation(item) for item in data] class IssuerStaffSerializerV1(serializers.Serializer): - """ A read_only serializer for staff roles """ - user = BadgeUserProfileSerializerV1(source='cached_user') - role = serializers.CharField(validators=[ChoicesValidator(list(dict(IssuerStaff.ROLE_CHOICES).keys()))]) + """A read_only serializer for staff roles""" + + user = BadgeUserProfileSerializerV1(source="cached_user") + role = serializers.CharField( + validators=[ChoicesValidator(list(dict(IssuerStaff.ROLE_CHOICES).keys()))] + ) class Meta: list_serializer_class = CachedListSerializer - apispec_definition = ('IssuerStaff', { - 'properties': { - 'role': { - 'type': "string", - 'enum': ["staff", "editor", "owner"] - } - } - }) +class BaseIssuerSerializerV1( + OriginalJsonSerializerMixin, ExcludeFieldsMixin, serializers.Serializer +): + """Base serializer for issuers and networks""" + class Meta: + abstract = True -class IssuerSerializerV1(OriginalJsonSerializerMixin, serializers.Serializer): created_at = DateTimeWithUtcZAtEndField(read_only=True) created_by = BadgeUserIdentifierFieldV1() name = StripTagsCharField(max_length=1024) - slug = StripTagsCharField(max_length=255, source='entity_id', read_only=True) + slug = StripTagsCharField(max_length=255, source="entity_id", read_only=True) image = ValidImageField(required=False) - email = serializers.EmailField(max_length=255, required=True) description = StripTagsCharField(max_length=16384, required=False) - url = serializers.URLField(max_length=1024, required=True) - staff = IssuerStaffSerializerV1(read_only=True, source='cached_issuerstaff', many=True) - badgrapp = serializers.CharField(read_only=True, max_length=255, source='cached_badgrapp') + badgrapp = serializers.CharField( + read_only=True, max_length=255, source="cached_badgrapp" + ) + staff = IssuerStaffSerializerV1( + read_only=True, source="cached_issuerstaff", many=True + ) + source_url = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) + country = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) + + state = serializers.CharField( + max_length=254, required=False, allow_blank=True, allow_null=True + ) + + is_network = serializers.BooleanField(default=False) + + linkedinId = serializers.CharField( + max_length=255, required=False, allow_blank=True, default="" + ) + + def get_fields(self): + fields = super().get_fields() + + # Use the mixin to exclude any fields that are unwantend in the final result + exclude_fields = self.context.get("exclude_fields", []) + self.exclude_fields(fields, exclude_fields) + + return fields + + def validate_image(self, image): + if image is not None: + img_name, img_ext = os.path.splitext(image.name) + image.name = "issuer_logo_" + str(uuid.uuid4()) + img_ext + return image + + +class NetworkSerializerV1(BaseIssuerSerializerV1): + url = serializers.URLField(max_length=1024, required=False, allow_blank=True) + + def create(self, validated_data, **kwargs): + new_network = Issuer(**validated_data) + + new_network.is_network = True + # verify network as only verified issuers can create badges + new_network.verified = True + + new_network.badgrapp = BadgrApp.objects.get_current( + self.context.get("request", None) + ) + + new_network.save() + + return new_network + + def update(self, instance, validated_data): + force_image_resize = False + instance.name = validated_data.get("name") + instance.country = validated_data.get("country") + instance.description = validated_data.get("description") + instance.url = validated_data.get("url") + instance.state = validated_data.get("state") + + if "image" in validated_data: + instance.image = validated_data.get("image") + force_image_resize = True + + instance.save(force_resize=force_image_resize) + return instance + + def to_representation(self, instance): + representation = super(NetworkSerializerV1, self).to_representation(instance) + representation["json"] = instance.get_json( + obi_version="1_1", use_canonical_id=True + ) + representation["badgeClassCount"] = len(instance.cached_badgeclasses()) + # TODO: retrieve from cache? + representation["learningPathCount"] = instance.learningpaths.count() + representation["partnerBadgesCount"] = instance.shared_badges.count() + + exclude_fields = self.context.get("exclude_fields", []) + if "partner_issuers" not in exclude_fields: + partner_issuers = instance.partner_issuers.all() + representation["partner_issuers"] = IssuerSerializerV1( + partner_issuers, many=True, context=self.context + ).data + + request = self.context.get("request") + + if request and request.user and not request.user.is_anonymous: + representation["current_user_network_role"] = self._get_user_network_role( + instance, request.user + ) + else: + representation["current_user_network_role"] = None + + return representation + + def _get_user_network_role(self, network, user): + """Get user's role within this network (either direct or through partner issuer)""" + direct_staff = network.cached_issuerstaff().filter(user=user).first() + if direct_staff: + # direct owners of networks get a special role assigned + # that allows editing of some network properties + if direct_staff.role == "owner": + return "creator" + return direct_staff.role + + for membership in network.memberships.all(): + partner_staff = ( + membership.issuer.cached_issuerstaff().filter(user=user).first() + ) + if partner_staff: + return partner_staff.role + + return None + + +class IssuerSerializerV1(BaseIssuerSerializerV1): + email = serializers.EmailField(max_length=255, required=True) + networks = serializers.SerializerMethodField() verified = serializers.BooleanField(default=False) category = serializers.CharField(max_length=255, required=True, allow_null=True) - source_url = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) - street = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) - streetnumber = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) - zip = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) - city = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) - country = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) - - lat = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) - lon = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) + street = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) + streetnumber = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) + zip = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) + city = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) - class Meta: - apispec_definition = ('Issuer', {}) + url = serializers.URLField(max_length=1024, required=True) - def validate_image(self, image): - if image is not None: - img_name, img_ext = os.path.splitext(image.name) - image.name = 'issuer_logo_' + str(uuid.uuid4()) + img_ext - return image + intendedUseVerified = serializers.BooleanField(default=False) + + lat = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) + lon = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_networks(self, obj): + from .serializers_v1 import NetworkSerializerV1 + + # Check if networks should be excluded to prevent circular reference + exclude_fields = self.context.get("exclude_fields", []) + if "networks" in exclude_fields: + return [] + + network_memberships = obj.network_memberships.select_related("network") + networks = [membership.network for membership in network_memberships] + + # Exclude 'partner_issuers' field from nested network serialization to prevent circular reference + context = self.context.copy() + context["exclude_fields"] = context.get("exclude_fields", []) + [ + "partner_issuers" + ] + + return NetworkSerializerV1(networks, many=True, context=context).data def create(self, validated_data, **kwargs): - user = validated_data['created_by'] - potential_email = validated_data['email'] + user = validated_data["created_by"] + potential_email = validated_data["email"] if not user.is_email_verified(potential_email): raise serializers.ValidationError( - "Issuer email must be one of your verified addresses. Add this email to your profile and try again.") + "Issuer email must be one of your verified addresses. " + "Add this email to your profile and try again." + ) new_issuer = Issuer(**validated_data) - new_issuer.category = validated_data.get('category') - new_issuer.street = validated_data.get('street') - new_issuer.streetnumber = validated_data.get('streetnumber') - new_issuer.zip = validated_data.get('zip') - new_issuer.city = validated_data.get('city') - new_issuer.country = validated_data.get('country') + new_issuer.category = validated_data.get("category") + new_issuer.street = validated_data.get("street") + new_issuer.streetnumber = validated_data.get("streetnumber") + new_issuer.zip = validated_data.get("zip") + new_issuer.city = validated_data.get("city") + new_issuer.country = validated_data.get("country") + new_issuer.intendedUseVerified = validated_data.get("intendedUseVerified") + if "linkedinId" in validated_data: + new_issuer.linkedinId = validated_data.get("linkedinId") + + # Check whether issuer email domain matches institution website domain to verify it automatically + if verifyIssuerAutomatically( + validated_data.get("url"), validated_data.get("email") + ): + new_issuer.verified = True # set badgrapp - new_issuer.badgrapp = BadgrApp.objects.get_current(self.context.get('request', None)) + new_issuer.badgrapp = BadgrApp.objects.get_current( + self.context.get("request", None) + ) new_issuer.save() + return new_issuer def update(self, instance, validated_data): force_image_resize = False - instance.name = validated_data.get('name') + instance.name = validated_data.get("name") - if 'image' in validated_data: - instance.image = validated_data.get('image') + if "image" in validated_data: + instance.image = validated_data.get("image") force_image_resize = True - instance.email = validated_data.get('email') - instance.description = validated_data.get('description') - instance.url = validated_data.get('url') - - instance.category = validated_data.get('category') - instance.street = validated_data.get('street') - instance.streetnumber = validated_data.get('streetnumber') - instance.zip = validated_data.get('zip') - instance.city = validated_data.get('city') - instance.country = validated_data.get('country') + instance.email = validated_data.get("email") + instance.description = validated_data.get("description") + instance.url = validated_data.get("url") + + instance.category = validated_data.get("category") + instance.street = validated_data.get("street") + instance.streetnumber = validated_data.get("streetnumber") + instance.zip = validated_data.get("zip") + instance.city = validated_data.get("city") + instance.country = validated_data.get("country") + + if "linkedinId" in validated_data: + instance.linkedinId = validated_data.get("linkedinId") # set badgrapp if not instance.badgrapp_id: - instance.badgrapp = BadgrApp.objects.get_current(self.context.get('request', None)) + instance.badgrapp = BadgrApp.objects.get_current( + self.context.get("request", None) + ) + + if not instance.verified: + if verifyIssuerAutomatically( + validated_data.get("url"), validated_data.get("email") + ): + instance.verified = True instance.save(force_resize=force_image_resize) return instance - def to_representation(self, obj): - representation = super(IssuerSerializerV1, self).to_representation(obj) - representation['json'] = obj.get_json(obi_version='1_1', use_canonical_id=True) - - if self.context.get('embed_badgeclasses', False): - representation['badgeclasses'] = BadgeClassSerializerV1(obj.badgeclasses.all(), many=True, context=self.context).data - - representation['badgeClassCount'] = len(obj.cached_badgeclasses()) - representation['recipientGroupCount'] = 0 - representation['recipientCount'] = 0 - representation['pathwayCount'] = 0 + def to_representation(self, instance): + representation = super(IssuerSerializerV1, self).to_representation(instance) + representation["json"] = instance.get_json( + obi_version="1_1", use_canonical_id=True + ) + + if self.context.get("embed_badgeclasses", False): + representation["badgeclasses"] = BadgeClassSerializerV1( + instance.badgeclasses.all(), many=True, context=self.context + ).data + representation["badgeClassCount"] = len(instance.cached_badgeclasses()) - len( + instance.cached_learningpaths().filter(activated=False) + ) + representation["learningPathCount"] = len( + instance.cached_learningpaths().filter(activated=True) + ) + representation["recipientGroupCount"] = 0 + representation["recipientCount"] = 0 + representation["pathwayCount"] = 0 + + representation["ownerAcceptedTos"] = any( + user.agreed_terms_version == TermsVersion.cached.latest_version() + for user in instance.owners + ) return representation class IssuerRoleActionSerializerV1(serializers.Serializer): - """ A serializer used for validating user role change POSTS """ - action = serializers.ChoiceField(('add', 'modify', 'remove'), allow_blank=True) + """A serializer used for validating user role change POSTS""" + + action = serializers.ChoiceField(("add", "modify", "remove"), allow_blank=True) username = serializers.CharField(allow_blank=True, required=False) email = serializers.EmailField(allow_blank=True, required=False) role = serializers.CharField( validators=[ChoicesValidator(list(dict(IssuerStaff.ROLE_CHOICES).keys()))], - default=IssuerStaff.ROLE_STAFF) + default=IssuerStaff.ROLE_STAFF, + ) url = serializers.URLField(max_length=1024, required=False) telephone = serializers.CharField(max_length=100, required=False) def validate(self, attrs): - identifiers = [attrs.get('username'), attrs.get('email'), attrs.get('url'), attrs.get('telephone')] + identifiers = [ + attrs.get("username"), + attrs.get("email"), + attrs.get("url"), + attrs.get("telephone"), + ] identifier_count = len(list(filter(None.__ne__, identifiers))) if identifier_count > 1: raise serializers.ValidationError( - 'Please provided only one of the following: a username, email address, url, or telephone recipient identifier.' + "Please provided only one of the following: a username, email address, " + "url, or telephone recipient identifier." ) return attrs @@ -208,72 +439,137 @@ def validate(self, attrs): class AlignmentItemSerializerV1(serializers.Serializer): target_name = StripTagsCharField() target_url = serializers.URLField() - target_description = StripTagsCharField(required=False, allow_blank=True, allow_null=True) - target_framework = StripTagsCharField(required=False, allow_blank=True, allow_null=True) + target_description = StripTagsCharField( + required=False, allow_blank=True, allow_null=True + ) + target_framework = StripTagsCharField( + required=False, allow_blank=True, allow_null=True + ) target_code = StripTagsCharField(required=False, allow_blank=True, allow_null=True) - class Meta: - apispec_definition = ('BadgeClassAlignment', {}) - - -class BadgeClassExpirationSerializerV1(serializers.Serializer): - amount = serializers.IntegerField(source='expires_amount', allow_null=True, validators=[PositiveIntegerValidator()]) - duration = serializers.ChoiceField(source='expires_duration', allow_null=True, choices=BadgeClass.EXPIRES_DURATION_CHOICES) - -class BadgeClassSerializerV1(OriginalJsonSerializerMixin, ExtensionsSaverMixin, serializers.Serializer): +class BadgeClassSerializerV1( + OriginalJsonSerializerMixin, + ExtensionsSaverMixin, + ExcludeFieldsMixin, + serializers.Serializer, +): created_at = DateTimeWithUtcZAtEndField(read_only=True) + updated_at = DateTimeWithUtcZAtEndField(read_only=True) created_by = BadgeUserIdentifierFieldV1() id = serializers.IntegerField(required=False, read_only=True) name = StripTagsCharField(max_length=255) image = ValidImageField(required=False) - slug = StripTagsCharField(max_length=255, read_only=True, source='entity_id') - criteria = MarkdownCharField(allow_blank=True, required=False, write_only=True) + imageFrame = serializers.BooleanField(default=True, required=False) + slug = StripTagsCharField(max_length=255, read_only=True, source="entity_id") + course_url = StripTagsCharField( + required=False, allow_blank=True, allow_null=True, validators=[URLValidator()] + ) + criteria = JSONField(required=False, allow_null=True) criteria_text = MarkdownCharField(required=False, allow_null=True, allow_blank=True) - criteria_url = StripTagsCharField(required=False, allow_blank=True, allow_null=True, validators=[URLValidator()]) - recipient_count = serializers.IntegerField(required=False, read_only=True, source='v1_api_recipient_count') + criteria_url = StripTagsCharField( + required=False, allow_blank=True, allow_null=True, validators=[URLValidator()] + ) + recipient_count = serializers.IntegerField( + required=False, read_only=True, source="v1_api_recipient_count" + ) + description = StripTagsCharField(max_length=16384, required=True, convert_null=True) - alignment = AlignmentItemSerializerV1(many=True, source='alignment_items', required=False) - tags = serializers.ListField(child=StripTagsCharField(max_length=1024), source='tag_items', required=False) + alignment = AlignmentItemSerializerV1( + many=True, source="alignment_items", required=False + ) + tags = serializers.ListField( - extensions = serializers.DictField(source='extension_items', required=False, validators=[BadgeExtensionValidator()]) + child=StripTagsCharField(max_length=254), source="tag_items", required=False - expires = BadgeClassExpirationSerializerV1(source='*', required=False, allow_null=True) - - source_url = serializers.CharField(max_length=255, required=False, allow_blank=True, allow_null=True) + ) - class Meta: - apispec_definition = ('BadgeClass', {}) + areas = serializers.SlugRelatedField( + + many=True, + + slug_field='name', + + queryset=Area.objects.all(), + + required=False + + ) + extensions = serializers.DictField( + source="extension_items", required=False, validators=[BadgeExtensionValidator()] + ) + + expiration = serializers.IntegerField(required=False, allow_null=True) + + source_url = serializers.CharField( + max_length=255, required=False, allow_blank=True, allow_null=True + ) - def to_internal_value(self, data): - if 'expires' in data: - if not data['expires'] or len(data['expires']) == 0: - # if expires was included blank, remove it so to_internal_value() doesnt choke - del data['expires'] - return super(BadgeClassSerializerV1, self).to_internal_value(data) + issuerVerified = serializers.BooleanField( + read_only=True, source="cached_issuer.verified" + ) + + copy_permissions = serializers.ListField(source="copy_permissions_list") def to_representation(self, instance): representation = super(BadgeClassSerializerV1, self).to_representation(instance) - representation['issuerName'] = instance.cached_issuer.name - representation['issuer'] = OriginSetting.HTTP+reverse('issuer_json', kwargs={'entity_id': instance.cached_issuer.entity_id}) - representation['json'] = instance.get_json(obi_version='1_1', use_canonical_id=True) + + exclude_fields = self.context.get("exclude_fields", []) + self.exclude_fields(representation, exclude_fields) + + representation["issuerName"] = instance.cached_issuer.name + representation["issuerOwnerAcceptedTos"] = any( + user.agreed_terms_version == TermsVersion.cached.latest_version() + for user in instance.cached_issuer.owners + ) + + networkShare = instance.network_shares.filter(is_active=True).first() + if networkShare: + network = networkShare.network + representation["sharedOnNetwork"] = { + "slug": network.entity_id, + "name": network.name, + "image": network.image.url, + "description": network.description, + } + else: + representation["sharedOnNetwork"] = None + + representation["isNetworkBadge"] = ( + instance.cached_issuer.is_network + and representation["sharedOnNetwork"] is None + ) + + if representation["isNetworkBadge"]: + representation["networkName"] = instance.cached_issuer.name + representation["networkImage"] = instance.cached_issuer.image.url + else: + representation["networkImage"] = None + representation["networkName"] = None + + representation["issuer"] = OriginSetting.HTTP + reverse( + "issuer_json", kwargs={"entity_id": instance.cached_issuer.entity_id} + ) + representation["json"] = instance.get_json( + obi_version="1_1", use_canonical_id=True + ) return representation def validate_image(self, image): if image is not None: img_name, img_ext = os.path.splitext(image.name) - image.name = 'issuer_badgeclass_' + str(uuid.uuid4()) + img_ext + image.name = "issuer_badgeclass_" + str(uuid.uuid4()) + img_ext return image def validate_criteria_text(self, criteria_text): - if criteria_text is not None and criteria_text != '': + if criteria_text is not None and criteria_text != "": return criteria_text else: return None def validate_criteria_url(self, criteria_url): - if criteria_url is not None and criteria_url != '': + if criteria_url is not None and criteria_url != "": return criteria_url else: return None @@ -286,118 +582,166 @@ def validate_extensions(self, extensions): # raise BadgrValidationError( # error_code=999, # error_message=f"extensions @context invalid {ext['@context']}") - if (ext_name.endswith('ECTSExtension') - or ext_name.endswith('StudyLoadExtension') - or ext_name.endswith('CategoryExtension') - or ext_name.endswith('LevelExtension') - or ext_name.endswith('BasedOnExtension')): + if ( + ext_name.endswith("ECTSExtension") + or ext_name.endswith("StudyLoadExtension") + or ext_name.endswith("CategoryExtension") + or ext_name.endswith("LevelExtension") + or ext_name.endswith("CompetencyExtension") + or ext_name.endswith("LicenseExtension") + or ext_name.endswith("BasedOnExtension") + ): is_formal = True self.formal = is_formal return extensions + def validate_expiration(self, value): + if value is not None: + if value < 1: + raise serializers.ValidationError("Expiration must be at least 1 day.") + if value > 36500: + raise serializers.ValidationError( + "Expiration cannot exceed 100 years (36500 days)." + ) + return value + def add_extensions(self, instance, add_these_extensions, extension_items): for extension_name in add_these_extensions: original_json = extension_items[extension_name] - extension = BadgeClassExtension(name=extension_name, - original_json=json.dumps(original_json), - badgeclass_id=instance.pk) + extension = BadgeClassExtension( + name=extension_name, + original_json=json.dumps(original_json), + badgeclass_id=instance.pk, + ) extension.save() - def update(self, instance, validated_data): logger.info("UPDATE BADGECLASS") logger.debug(validated_data) - force_image_resize = False + with transaction.atomic(): + new_name = validated_data.get("name") + if new_name: + new_name = strip_tags(new_name) + instance.name = new_name - new_name = validated_data.get('name') - if new_name: - new_name = strip_tags(new_name) - instance.name = new_name + new_description = validated_data.get("description") + if new_description: + instance.description = strip_tags(new_description) - new_description = validated_data.get('description') - if new_description: - instance.description = strip_tags(new_description) + if "image" in validated_data: + instance.image = validated_data.get("image") - # Assure both criteria_url and criteria_text will not be empty - if 'criteria_url' in validated_data or 'criteria_text' in validated_data: - end_criteria_url = validated_data['criteria_url'] if 'criteria_url' in validated_data \ - else instance.criteria_url - end_criteria_text = validated_data['criteria_text'] if 'criteria_text' in validated_data \ - else instance.criteria_text + if "criteria" in validated_data: + instance.criteria = validated_data.get("criteria") - if ((end_criteria_url is None or not end_criteria_url.strip()) - and (end_criteria_text is None or not end_criteria_text.strip())): - raise serializers.ValidationError( - 'Changes cannot be made that would leave both criteria_url and criteria_text blank.' - ) - else: - instance.criteria_text = end_criteria_text - instance.criteria_url = end_criteria_url + instance.alignment_items = validated_data.get("alignment_items") + instance.tag_items = validated_data.get("tag_items") - if 'image' in validated_data: - instance.image = validated_data.get('image') - force_image_resize = True + instance.expiration = validated_data.get("expiration", None) + instance.course_url = validated_data.get("course_url", "") + instance.imageFrame = validated_data.get("imageFrame", True) - instance.alignment_items = validated_data.get('alignment_items') - instance.tag_items = validated_data.get('tag_items') + instance.copy_permissions_list = validated_data.get( + "copy_permissions_list", ["issuer"] + ) - instance.expires_amount = validated_data.get('expires_amount', None) - instance.expires_duration = validated_data.get('expires_duration', None) + logger.debug("SAVING EXTENSION") + self.save_extensions(validated_data, instance) + + if instance.imageFrame: + extensions = instance.cached_extensions() + try: + category_ext = extensions.get(name="extensions:CategoryExtension") + category = json.loads(category_ext.original_json)["Category"] + org_img_ext = extensions.get(name="extensions:OrgImageExtension") + original_image = json.loads(org_img_ext.original_json)["OrgImage"] + + instance.generate_badge_image( + category, original_image, instance.issuer.image + ) + instance.save() + except BadgeClassExtension.DoesNotExist as e: + raise serializers.ValidationError({"extensions": str(e)}) + except Exception as e: + raise serializers.ValidationError( + f"Badge image generation failed: {e}" + ) - logger.debug("SAVING EXTENSION") - self.save_extensions(validated_data, instance) + else: + instance.save(force_resize=True) + return instance - instance.save(force_resize=force_image_resize) + def create(self, validated_data, **kwargs): + logger.info("CREATE NEW BADGECLASS") + logger.debug(validated_data) - return instance + tags = validated_data.pop("tag_items", []) + alignments = validated_data.pop("alignment_items", []) + extension_data = validated_data.pop("extension_items", []) - def validate(self, data): - if 'criteria' in data: - if 'criteria_url' in data or 'criteria_text' in data: - raise serializers.ValidationError( - "The criteria field is mutually-exclusive with the criteria_url and criteria_text fields" - ) + with transaction.atomic(): + if "image" not in validated_data: + raise serializers.ValidationError({"image": ["This field is required"]}) - if utils.is_probable_url(data.get('criteria')): - data['criteria_url'] = data.pop('criteria') - elif not isinstance(data.get('criteria'), str): - raise serializers.ValidationError( - "Provided criteria text could not be properly processed as URL or plain text." - ) - else: - data['criteria_text'] = data.pop('criteria') - return data + if "issuer" in self.context: + validated_data["issuer"] = self.context.get("issuer") - def create(self, validated_data, **kwargs): + new_badgeclass = BadgeClass.objects.create(**validated_data) - logger.info("CREATE NEW BADGECLASS") - logger.debug(validated_data) + new_badgeclass.tag_items = tags - if 'image' not in validated_data: - raise serializers.ValidationError({"image": ["This field is required"]}) + new_badgeclass.alignment_items = alignments - if 'issuer' in self.context: - validated_data['issuer'] = self.context.get('issuer') + new_badgeclass.extension_items = extension_data - if validated_data.get('criteria_text', None) is None and validated_data.get('criteria_url', None) is None: - raise serializers.ValidationError( - "One or both of the criteria_text and criteria_url fields must be provided" - ) + if new_badgeclass.imageFrame: + try: + saved_extensions = new_badgeclass.cached_extensions() + categoryExtension = saved_extensions.get( + name="extensions:CategoryExtension" + ) + category = json.loads(categoryExtension.original_json)["Category"] + orgImage = saved_extensions.get(name="extensions:OrgImageExtension") + original_image = json.loads(orgImage.original_json)["OrgImage"] + except BadgeClassExtension.DoesNotExist as e: + raise serializers.ValidationError({"extensions": str(e)}) + + try: + issuer_image = None + network_image = None + + if not ( + new_badgeclass.issuer.is_network and category == "learningpath" + ): + if new_badgeclass.issuer.is_network: + network_image = new_badgeclass.issuer.image + else: + issuer_image = new_badgeclass.issuer.image - new_badgeclass = BadgeClass.objects.create(**validated_data) - return new_badgeclass + new_badgeclass.generate_badge_image( + category, + original_image, + issuer_image, + network_image, + ) + new_badgeclass.save(update_fields=["image"]) + except Exception as e: + raise serializers.ValidationError( + f"Badge image generation failed: {e}" + ) + + return new_badgeclass class EvidenceItemSerializer(serializers.Serializer): - evidence_url = serializers.URLField(max_length=1024, required=False, allow_blank=True) + evidence_url = serializers.URLField( + max_length=1024, required=False, allow_blank=True + ) narrative = MarkdownCharField(required=False, allow_blank=True) - class Meta: - apispec_definition = ('AssertionEvidence', {}) - def validate(self, attrs): - if not (attrs.get('evidence_url', None) or attrs.get('narrative', None)): + if not (attrs.get("evidence_url", None) or attrs.get("narrative", None)): raise serializers.ValidationError("Either url or narrative is required") return attrs @@ -405,33 +749,59 @@ def validate(self, attrs): class BadgeInstanceSerializerV1(OriginalJsonSerializerMixin, serializers.Serializer): created_at = DateTimeWithUtcZAtEndField(read_only=True, default_timezone=pytz.utc) created_by = BadgeUserIdentifierFieldV1(read_only=True) - slug = serializers.CharField(max_length=255, read_only=True, source='entity_id') + slug = serializers.CharField(max_length=255, read_only=True, source="entity_id") image = serializers.FileField(read_only=True) # use_url=True, might be necessary - email = serializers.EmailField(max_length=1024, required=False, write_only=True) - recipient_identifier = serializers.CharField(max_length=1024, required=False) + email = serializers.EmailField(max_length=320, required=False, write_only=True) + recipient_identifier = serializers.CharField(max_length=320, required=False) recipient_type = serializers.CharField(default=RECIPIENT_TYPE_EMAIL) - allow_uppercase = serializers.BooleanField(default=False, required=False, write_only=True) - evidence = serializers.URLField(write_only=True, required=False, allow_blank=True, max_length=1024) + allow_uppercase = serializers.BooleanField( + default=False, required=False, write_only=True + ) + evidence = serializers.URLField( + write_only=True, required=False, allow_blank=True, max_length=1024 + ) narrative = MarkdownCharField(required=False, allow_blank=True, allow_null=True) evidence_items = EvidenceItemSerializer(many=True, required=False) - + course_url = StripTagsCharField( + required=False, allow_blank=True, allow_null=True, validators=[URLValidator()] + ) revoked = HumanReadableBooleanField(read_only=True) revocation_reason = serializers.CharField(read_only=True) - expires = DateTimeWithUtcZAtEndField(source='expires_at', required=False, allow_null=True, default_timezone=pytz.utc) - - create_notification = HumanReadableBooleanField(write_only=True, required=False, default=False) - allow_duplicate_awards = serializers.BooleanField(write_only=True, required=False, default=True) - hashed = serializers.NullBooleanField(default=None, required=False) - - extensions = serializers.DictField(source='extension_items', required=False, validators=[BadgeExtensionValidator()]) - - class Meta: - apispec_definition = ('Assertion', {}) + expires = DateTimeWithUtcZAtEndField( + source="expires_at", required=False, allow_null=True, default_timezone=pytz.utc + ) + + activity_start_date = DateTimeWithUtcZAtEndField( + required=False, allow_null=True, default_timezone=pytz.utc + ) + activity_end_date = DateTimeWithUtcZAtEndField( + required=False, allow_null=True, default_timezone=pytz.utc + ) + + activity_zip = serializers.CharField( + required=False, default=None, allow_null=True, allow_blank=True + ) + activity_city = serializers.CharField( + required=False, default=None, allow_null=True, allow_blank=True + ) + activity_online = serializers.BooleanField(required=False, default=False) + + create_notification = HumanReadableBooleanField( + write_only=True, required=False, default=False + ) + allow_duplicate_awards = serializers.BooleanField( + write_only=True, required=False, default=True + ) + hashed = serializers.BooleanField(default=None, allow_null=True, required=False) + + extensions = serializers.DictField( + source="extension_items", required=False, validators=[BadgeExtensionValidator()] + ) def validate(self, data): - recipient_type = data.get('recipient_type') - if data.get('recipient_identifier') and data.get('email') is None: + recipient_type = data.get("recipient_type") + if data.get("recipient_identifier") and data.get("email") is None: if recipient_type == RECIPIENT_TYPE_EMAIL: recipient_validator = EmailValidator() elif recipient_type in (RECIPIENT_TYPE_URL, RECIPIENT_TYPE_ID): @@ -440,30 +810,33 @@ def validate(self, data): recipient_validator = TelephoneValidator() try: - recipient_validator(data['recipient_identifier']) + recipient_validator(data["recipient_identifier"]) except DjangoValidationError as e: raise serializers.ValidationError(e.message) - elif data.get('email') and data.get('recipient_identifier') is None: - data['recipient_identifier'] = data.get('email') + elif data.get("email") and data.get("recipient_identifier") is None: + data["recipient_identifier"] = data.get("email") - allow_duplicate_awards = data.pop('allow_duplicate_awards') - if allow_duplicate_awards is False and self.context.get('badgeclass') is not None: + allow_duplicate_awards = data.pop("allow_duplicate_awards") + if ( + allow_duplicate_awards is False + and self.context.get("badgeclass") is not None + ): previous_awards = BadgeInstance.objects.filter( - recipient_identifier=data['recipient_identifier'], badgeclass=self.context['badgeclass'] - ).filter( - Q(expires_at__isnull=True) | Q(expires_at__lt=timezone.now()) - ) + recipient_identifier=data["recipient_identifier"], + badgeclass=self.context["badgeclass"], + ).filter(Q(expires_at__isnull=True) | Q(expires_at__lt=timezone.now())) if previous_awards.exists(): raise serializers.ValidationError( - "A previous award of this badge already exists for this recipient.") + "A previous award of this badge already exists for this recipient." + ) - hashed = data.get('hashed', None) + hashed = data.get("hashed", None) if hashed is None: if recipient_type in (RECIPIENT_TYPE_URL, RECIPIENT_TYPE_ID): - data['hashed'] = False + data["hashed"] = False else: - data['hashed'] = True + data["hashed"] = True return data @@ -474,62 +847,98 @@ def validate_narrative(self, data): return data def to_representation(self, instance): - representation = super(BadgeInstanceSerializerV1, self).to_representation(instance) - representation['json'] = instance.get_json(obi_version="1_1", use_canonical_id=True) - if self.context.get('include_issuer', False): - representation['issuer'] = IssuerSerializerV1(instance.cached_badgeclass.cached_issuer).data + representation = super(BadgeInstanceSerializerV1, self).to_representation( + instance + ) + representation["json"] = instance.get_json( + obi_version="1_1", use_canonical_id=True + ) + if self.context.get("include_issuer", False): + representation["issuer"] = IssuerSerializerV1( + instance.cached_badgeclass.cached_issuer + ).data else: - representation['issuer'] = OriginSetting.HTTP+reverse('issuer_json', kwargs={'entity_id': instance.cached_issuer.entity_id}) - if self.context.get('include_badge_class', False): - representation['badge_class'] = BadgeClassSerializerV1(instance.cached_badgeclass, context=self.context).data + representation["issuer"] = OriginSetting.HTTP + reverse( + "issuer_json", kwargs={"entity_id": instance.cached_issuer.entity_id} + ) + if self.context.get("include_badge_class", False): + representation["badge_class"] = BadgeClassSerializerV1( + instance.cached_badgeclass, context=self.context + ).data else: - representation['badge_class'] = OriginSetting.HTTP+reverse('badgeclass_json', kwargs={'entity_id': instance.cached_badgeclass.entity_id}) + representation["badge_class"] = OriginSetting.HTTP + reverse( + "badgeclass_json", + kwargs={"entity_id": instance.cached_badgeclass.entity_id}, + ) - representation['public_url'] = OriginSetting.HTTP+reverse('badgeinstance_json', kwargs={'entity_id': instance.entity_id}) + representation["public_url"] = OriginSetting.HTTP + reverse( + "badgeinstance_json", kwargs={"entity_id": instance.entity_id} + ) return representation - def create(self, validated_data): + def create(self, validated_data, **kwargs): """ Requires self.context to include request (with authenticated request.user) and badgeclass: issuer.models.BadgeClass. """ evidence_items = [] + issuer_slug = None + + request = self.context.get("request") + if request and hasattr(request, "parser_context"): + issuer_slug = request.parser_context.get("kwargs", {}).get("issuerSlug") + + # Fallback if no request available (e.g. running in Celery) + if issuer_slug is None: + issuer_slug = self.context.get("issuerSlug") + # ob1 evidence url - evidence_url = validated_data.get('evidence') + evidence_url = validated_data.get("evidence") if evidence_url: - evidence_items.append({'evidence_url': evidence_url}) + evidence_items.append({"evidence_url": evidence_url}) # ob2 evidence items - submitted_items = validated_data.get('evidence_items') + submitted_items = validated_data.get("evidence_items") if submitted_items: evidence_items.extend(submitted_items) try: - return self.context.get('badgeclass').issue( - recipient_id=validated_data.get('recipient_identifier'), - narrative=validated_data.get('narrative'), + return self.context.get("badgeclass").issue( + recipient_id=validated_data.get("recipient_identifier"), + narrative=validated_data.get("narrative"), evidence=evidence_items, - notify=validated_data.get('create_notification'), - created_by=self.context.get('request').user, - allow_uppercase=validated_data.get('allow_uppercase'), - recipient_type=validated_data.get('recipient_type', RECIPIENT_TYPE_EMAIL), - badgr_app=BadgrApp.objects.get_current(self.context.get('request')), - expires_at=validated_data.get('expires_at', None), - extensions=validated_data.get('extension_items', None) + notify=validated_data.get("create_notification"), + created_by=self.context.get("user") + or getattr(self.context.get("request"), "user", None), + allow_uppercase=validated_data.get("allow_uppercase"), + recipient_type=validated_data.get( + "recipient_type", RECIPIENT_TYPE_EMAIL + ), + badgr_app=BadgrApp.objects.get_current(self.context.get("request")), + expires_at=validated_data.get("expires_at", None), + activity_start_date=validated_data.get("activity_start_date", None), + activity_end_date=validated_data.get("activity_end_date", None), + activity_zip=validated_data.get("activity_zip", None), + activity_city=validated_data.get("activity_city", None), + activity_online=validated_data.get("activity_online", False), + extensions=validated_data.get("extension_items", None), + issuerSlug=issuer_slug, + course_url=validated_data.get("course_url", ""), ) except DjangoValidationError as e: raise serializers.ValidationError(e.message) def update(self, instance, validated_data): updateable_fields = [ - 'evidence_items', - 'expires_at', - 'extension_items', - 'hashed', - 'narrative', - 'recipient_identifier', - 'recipient_type' + "evidence_items", + "expires_at", + "extension_items", + "hashed", + "narrative", + "recipient_identifier", + "recipient_type", + "course_url", ] for field_name in updateable_fields: @@ -539,3 +948,459 @@ def update(self, instance, validated_data): instance.save() return instance + + +class QrCodeSerializerV1(serializers.Serializer): + title = serializers.CharField(max_length=254) + slug = StripTagsCharField(max_length=255, source="entity_id", read_only=True) + createdBy = serializers.CharField(max_length=254) + badgeclass_id = serializers.CharField(max_length=254) + issuer_id = serializers.CharField(max_length=254) + request_count = serializers.SerializerMethodField() + notifications = serializers.BooleanField(default=False) + + activity_start_date = DateTimeWithUtcZAtEndField( + required=False, allow_null=True, default_timezone=pytz.utc + ) + activity_end_date = DateTimeWithUtcZAtEndField( + required=False, allow_null=True, default_timezone=pytz.utc + ) + + activity_zip = serializers.CharField( + required=False, default=None, allow_null=True, allow_blank=True + ) + activity_city = serializers.CharField( + required=False, default=None, allow_null=True, allow_blank=True + ) + activity_online = serializers.BooleanField(required=False, default=False) + + valid_from = DateTimeWithUtcZAtEndField( + required=False, allow_null=True, default_timezone=pytz.utc + ) + expires_at = DateTimeWithUtcZAtEndField( + required=False, allow_null=True, default_timezone=pytz.utc + ) + course_url = StripTagsCharField( + required=False, allow_blank=True, allow_null=True, validators=[URLValidator()] + ) + + evidence_items = EvidenceItemSerializer(many=True, required=False) + + def create(self, validated_data, **kwargs): + title = validated_data.get("title") + createdBy = validated_data.get("createdBy") + badgeclass_id = validated_data.get("badgeclass_id") + issuer_id = validated_data.get("issuer_id") + notifications = validated_data.get("notifications") + + try: + issuer = Issuer.objects.get(entity_id=issuer_id) + except Issuer.DoesNotExist: + raise serializers.ValidationError( + f"Issuer with ID '{issuer_id}' does not exist." + ) + + try: + badgeclass = BadgeClass.objects.get(entity_id=badgeclass_id) + except BadgeClass.DoesNotExist: + raise serializers.ValidationError( + f"BadgeClass with ID '{badgeclass_id}' does not exist." + ) + try: + created_by_user = self.context["request"].user + except DjangoValidationError: + raise serializers.ValidationError( + "Cannot determine the creating user of the qr code" + ) + + new_qrcode = QrCode.objects.create( + title=title, + createdBy=createdBy, + issuer=issuer, + badgeclass=badgeclass, + created_by_user=created_by_user, + valid_from=validated_data.get("valid_from"), + expires_at=validated_data.get("expires_at"), + activity_start_date=validated_data.get("activity_start_date", None), + activity_end_date=validated_data.get("activity_end_date", None), + activity_city=validated_data.get("activity_city", None), + activity_zip=validated_data.get("activity_zip", None), + activity_online=validated_data.get("activity_online", False), + evidence_items=validated_data.get("evidence_items", []), + notifications=notifications, + course_url=validated_data.get("course_url", ""), + ) + + return new_qrcode + + def update(self, instance, validated_data): + instance.title = validated_data.get("title", instance.title) + instance.createdBy = validated_data.get("createdBy", instance.createdBy) + if "valid_from" in validated_data: + instance.valid_from = validated_data["valid_from"] + if "expires_at" in validated_data: + instance.expires_at = validated_data["expires_at"] + if "activity_start_date" in validated_data: + instance.activity_start_date = validated_data["activity_start_date"] + if "activity_end_date" in validated_data: + instance.activity_end_date = validated_data["activity_end_date"] + if "activity_zip" in validated_data: + instance.activity_zip = validated_data["activity_zip"] + if "activity_city" in validated_data: + instance.activity_city = validated_data["activity_city"] + if "activity_online" in validated_data: + instance.activity_online = validated_data["activity_online"] + if "evidence_items" in validated_data: + instance.evidence_items = validated_data["evidence_items"] + instance.notifications = validated_data.get( + "notifications", instance.notifications + ) + if "course_url" in validated_data: + instance.course_url = validated_data["course_url"] + instance.save() + return instance + + @extend_schema_field(OpenApiTypes.INT) + def get_request_count(self, obj): + return obj.requestedbadges.count() + + +class RequestedBadgeSerializer(serializers.ModelSerializer): + class Meta: + model = RequestedBadge + fields = "__all__" + + +class IssuerStaffRequestSerializer(serializers.ModelSerializer): + issuer = IssuerSerializerV1(read_only=True) + user = BadgeUserProfileSerializerV1(read_only=True) + + class Meta: + model = IssuerStaffRequest + fields = "__all__" + + +class NetworkInviteSerializer(serializers.ModelSerializer): + network = NetworkSerializerV1(read_only=True) + issuer = IssuerSerializerV1(read_only=True) + + class Meta: + model = NetworkInvite + fields = "__all__" + + +class RequestedLearningPathSerializer(serializers.ModelSerializer): + class Meta: + model = RequestedLearningPath + fields = "__all__" + + def to_representation(self, instance): + representation = super(RequestedLearningPathSerializer, self).to_representation( + instance + ) + representation["user"] = BadgeUserProfileSerializerV1(instance.user).data + return representation + + +class BadgeOrderSerializer(serializers.Serializer): + badge = JSONField() + order = serializers.IntegerField() + + +class LearningPathSerializerV1(ExcludeFieldsMixin, serializers.Serializer): + created_at = DateTimeWithUtcZAtEndField(read_only=True) + updated_at = DateTimeWithUtcZAtEndField(read_only=True) + issuer_id = serializers.CharField(max_length=254) + participationBadge_id = serializers.CharField(max_length=254) + participant_count = serializers.IntegerField( + required=False, read_only=True, source="v1_api_participant_count" + ) + + required_badges_count = serializers.IntegerField(required=True) + activated = serializers.BooleanField(required=True) + + name = StripTagsCharField(max_length=255) + slug = StripTagsCharField(max_length=255, read_only=True, source="entity_id") + description = StripTagsCharField(max_length=16384, required=True, convert_null=True) + + tags = serializers.ListField( + child=StripTagsCharField(max_length=254), source="tag_items", required=False + ) + badges = BadgeOrderSerializer(many=True, required=False) + + participationBadge_image = serializers.SerializerMethodField() + + @extend_schema_field(OpenApiTypes.URI) + def get_participationBadge_image(self, obj): + image = "{}{}?type=png".format( + OriginSetting.HTTP, + reverse( + "badgeclass_image", + kwargs={"entity_id": obj.participationBadge.entity_id}, + ), + ) + return image if obj.participationBadge.image else None + + def get_participationBadge_id(self, obj): + return ( + obj.participationBadge.entity_id + if obj.participationBadge.entity_id + else None + ) + + def to_representation(self, instance): + request = self.context.get("request") + representation = super(LearningPathSerializerV1, self).to_representation( + instance + ) + representation["issuer_name"] = instance.issuer.name + representation["issuer_id"] = instance.issuer.entity_id + representation["participationBadge_id"] = self.get_participationBadge_id( + instance + ) + representation["tags"] = list(instance.tag_items.values_list("name", flat=True)) + representation["issuerOwnerAcceptedTos"] = any( + user.agreed_terms_version == TermsVersion.cached.latest_version() + for user in instance.cached_issuer.owners + ) + representation["badges"] = [ + { + "order": badge.order, + "badge": BadgeClassSerializerV1( + badge.badge, + context={"exclude_fields": ["extensions:OrgImageExtension"]}, + ).data, + } + for badge in instance.learningpathbadge_set.all().order_by("order") + ] + + default_representation = { + "progress": None, + "completed_at": None, + "completed_badges": None, + "requested": False, + } + if not request or not request.user.is_authenticated: + representation.update(default_representation) + return representation + + # get all badgeclasses for this lp + lp_badges = LearningPathBadge.objects.filter(learning_path=instance) + lp_badgeclasses = [lp_badge.badge for lp_badge in lp_badges] + + # get user completed badges filtered by lp badgeclasses + user_badgeinstances = request.user.cached_badgeinstances().filter( + badgeclass__in=lp_badgeclasses, revoked=False + ) + user_completed_badges = list( + {badgeinstance.badgeclass for badgeinstance in user_badgeinstances} + ) + + # calculate lp progress + max_progress = instance.calculate_progress(lp_badgeclasses) + user_progress = instance.calculate_progress(user_completed_badges) + + learningPathBadgeInstance = list( + filter( + lambda badge: badge.badgeclass.entity_id + == representation["participationBadge_id"], + request.user.cached_badgeinstances().filter(revoked=False), + ) + ) + if learningPathBadgeInstance: + learningPathBadgeInstanceSlug = learningPathBadgeInstance[0].entity_id + representation["learningPathBadgeInstanceSlug"] = ( + learningPathBadgeInstanceSlug + ) + # set lp completed at from newest badge issue date + # FIXME: maybe set from issued participation badge instead, would need to get + # user participation badgeclass aswell + completed_at = None + if user_progress >= max_progress: + completed_at = reduce( + lambda x, y: y.issued_on if x is None else max(x, y.issued_on), + user_badgeinstances, + None, + ) + + representation.update( + { + "progress": user_progress, + "completed_at": completed_at, + "completed_badges": BadgeClassSerializerV1( + user_completed_badges, + many=True, + context={"exclude_fields": ["extensions:OrgImageExtension"]}, + ).data, + } + ) + + exclude_fields = self.context.get("exclude_fields", []) + self.exclude_fields(representation, exclude_fields) + + return representation + + def create(self, validated_data, **kwargs): + name = validated_data.get("name") + description = validated_data.get("description") + required_badges_count = validated_data.get("required_badges_count") + activated = validated_data.get("activated") + tags = validated_data.get("tag_items") + issuer_id = validated_data.get("issuer_id") + participationBadge_id = validated_data.get("participationBadge_id") + badges_data = validated_data.get("badges") + + try: + issuer = Issuer.objects.get(entity_id=issuer_id) + except Issuer.DoesNotExist: + raise serializers.ValidationError( + f"Issuer with ID '{issuer_id}' does not exist." + ) + + try: + participationBadge = BadgeClass.objects.get(entity_id=participationBadge_id) + except BadgeClass.DoesNotExist: + raise serializers.ValidationError( + f" with ID '{participationBadge_id}' does not exist." + ) + + badges_with_order = [] + for badge_data in badges_data: + badge = badge_data.get("badge") + order = badge_data.get("order") + + try: + badge = BadgeClass.objects.get(entity_id=badge.get("slug")) + except BadgeClass.DoesNotExist: + raise serializers.ValidationError( + f"Badge with slug '{badge.get('slug')}' does not exist." + ) + + badges_with_order.append((badge, order)) + + new_learningpath = LearningPath.objects.create( + name=name, + description=description, + required_badges_count=required_badges_count, + activated=activated, + issuer=issuer, + participationBadge=participationBadge, + ) + new_learningpath.tag_items = tags + + new_learningpath.learningpath_badges = badges_with_order + return new_learningpath + + def update(self, instance, validated_data): + instance.name = validated_data.get("name", instance.name) + instance.description = validated_data.get("description", instance.description) + instance.required_badges_count = validated_data.get( + "required_badges_count", instance.required_badges_count + ) + instance.activated = validated_data.get("activated", instance.activated) + + tags = validated_data.get("tag_items", None) + if tags is not None: + instance.tag_items = tags + + badges_data = validated_data.get("badges", None) + if badges_data is not None: + badges_with_order = [] + for badge_data in badges_data: + badge = badge_data.get("badge") + order = badge_data.get("order") + + try: + badge = BadgeClass.objects.get(entity_id=badge.get("slug")) + except BadgeClass.DoesNotExist: + raise serializers.ValidationError( + f"Badge with slug '{badge.slug}' does not exist." + ) + + badges_with_order.append((badge, order)) + + instance.learningpath_badges = badges_with_order + + instance.save() + + return instance + + +class LearningPathParticipantSerializerV1(serializers.Serializer): + user = BadgeUserProfileSerializerV1(read_only=True) + completed_at = serializers.DateTimeField(source="issued_on") + + def to_representation(self, instance): + data = super().to_representation(instance) + data["participationBadgeAssertion"] = BadgeInstanceSerializerV1(instance).data + return data + + +class NetworkBadgeInstanceSerializerV1(BadgeInstanceSerializerV1): + pass + + +class BadgeClassNetworkShareSerializerV1(serializers.ModelSerializer): + badgeclass = serializers.SerializerMethodField() + network = serializers.SerializerMethodField() + shared_by_issuer = serializers.SerializerMethodField() + awarded_count_original_issuer = serializers.SerializerMethodField() + recipient_count = serializers.SerializerMethodField() + + class Meta: + model = BadgeClassNetworkShare + fields = [ + "id", + "badgeclass", + "network", + "shared_at", + "shared_by_user", + "shared_by_issuer", + "is_active", + "awarded_count_original_issuer", + "recipient_count", + ] + read_only_fields = ["id", "shared_at", "shared_by_user"] + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_badgeclass(self, obj): + return BadgeClassSerializerV1(obj.badgeclass, context=self.context).data + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_network(self, obj): + return { + "slug": obj.network.entity_id, + "name": obj.network.name, + "image": obj.network.image_url(), + } + + @extend_schema_field(OpenApiTypes.OBJECT) + def get_shared_by_issuer(self, obj): + if obj.shared_by_issuer: + return { + "slug": obj.shared_by_issuer.entity_id, + "name": obj.shared_by_issuer.name, + "image": obj.shared_by_issuer.image_url(), + } + return None + + @extend_schema_field(OpenApiTypes.INT) + def get_awarded_count_original_issuer(self, obj): + if obj.shared_by_issuer: + return BadgeInstance.objects.filter( + revoked=False, + issuer=obj.badgeclass.cached_issuer, + badgeclass=obj.badgeclass, + ).count() + return 0 + + @extend_schema_field(OpenApiTypes.INT) + def get_recipient_count(self, obj): + """ + Count of badge instances issued after this badge was shared with the network. + """ + return BadgeInstance.objects.filter( + badgeclass=obj.badgeclass, + revoked=False, + issued_on__gte=obj.shared_at, + ).count() diff --git a/apps/issuer/serializers_v2.py b/apps/issuer/serializers_v2.py index 6bcb3bf14..8f7ee0630 100644 --- a/apps/issuer/serializers_v2.py +++ b/apps/issuer/serializers_v2.py @@ -1,4 +1,3 @@ -from collections import OrderedDict import os import pytz import uuid @@ -11,15 +10,43 @@ from badgeuser.models import BadgeUser from badgeuser.serializers_v2 import BadgeUserEmailSerializerV2 -from entity.serializers import DetailSerializerV2, EntityRelatedFieldV2, BaseSerializerV2 -from issuer.models import Issuer, IssuerStaff, BadgeClass, BadgeInstance, RECIPIENT_TYPE_EMAIL, RECIPIENT_TYPE_ID, RECIPIENT_TYPE_URL, RECIPIENT_TYPE_TELEPHONE +from entity.serializers import ( + DetailSerializerV2, + EntityRelatedFieldV2, + BaseSerializerV2, +) +from issuer.models import ( + Area, + Issuer, + IssuerStaff, + BadgeClass, + BadgeInstance, + RECIPIENT_TYPE_EMAIL, + RECIPIENT_TYPE_ID, + RECIPIENT_TYPE_URL, + RECIPIENT_TYPE_TELEPHONE, +) from issuer.permissions import IsEditor -from issuer.utils import generate_sha256_hashstring, request_authenticated_with_server_admin_token +from issuer.utils import ( + generate_sha256_hashstring, + request_authenticated_with_server_admin_token, +) from mainsite.drf_fields import ValidImageField from mainsite.models import BadgrApp -from mainsite.serializers import (CachedUrlHyperlinkedRelatedField, DateTimeWithUtcZAtEndField, StripTagsCharField, MarkdownCharField, - HumanReadableBooleanField, OriginalJsonSerializerMixin) -from mainsite.validators import ChoicesValidator, TelephoneValidator, BadgeExtensionValidator, PositiveIntegerValidator +from mainsite.serializers import ( + CachedUrlHyperlinkedRelatedField, + DateTimeWithUtcZAtEndField, + StripTagsCharField, + MarkdownCharField, + HumanReadableBooleanField, + OriginalJsonSerializerMixin, +) +from mainsite.validators import ( + ChoicesValidator, + TelephoneValidator, + BadgeExtensionValidator, + PositiveIntegerValidator, +) class IssuerAccessTokenSerializerV2(BaseSerializerV2): @@ -27,163 +54,102 @@ class IssuerAccessTokenSerializerV2(BaseSerializerV2): issuer = serializers.CharField() expires = DateTimeWithUtcZAtEndField() - class Meta(DetailSerializerV2.Meta): - apispec_definition = ('AccessToken', {}) - def to_representation(self, instance): return super(IssuerAccessTokenSerializerV2, self).to_representation(instance) class StaffUserProfileSerializerV2(DetailSerializerV2): - firstName = StripTagsCharField(source='first_name', read_only=True) - lastName = StripTagsCharField(source='last_name', read_only=True) - emails = BadgeUserEmailSerializerV2(many=True, source='email_items', read_only=True) - url = serializers.ListField(child=serializers.URLField(), - read_only=True, - source='cached_verified_urls', - max_length=100) - telephone = serializers.ListField(child=serializers.CharField(), - read_only=True, - source='cached_verified_phone_numbers', - max_length=100) - badgrDomain = serializers.CharField(read_only=True, max_length=255, source='badgrapp') + firstName = StripTagsCharField(source="first_name", read_only=True) + lastName = StripTagsCharField(source="last_name", read_only=True) + emails = BadgeUserEmailSerializerV2(many=True, source="email_items", read_only=True) + url = serializers.ListField( + child=serializers.URLField(), + read_only=True, + source="cached_verified_urls", + max_length=100, + ) + telephone = serializers.ListField( + child=serializers.CharField(), + read_only=True, + source="cached_verified_phone_numbers", + max_length=100, + ) + badgrDomain = serializers.CharField( + read_only=True, max_length=255, source="badgrapp" + ) class IssuerStaffSerializerV2(DetailSerializerV2): - userProfile = StaffUserProfileSerializerV2(source='cached_user', read_only=True) - user = EntityRelatedFieldV2(source='cached_user', queryset=BadgeUser.cached) - role = serializers.CharField(validators=[ChoicesValidator(list(dict(IssuerStaff.ROLE_CHOICES).keys()))]) - - class Meta(DetailSerializerV2.Meta): - apispec_definition = ('IssuerStaff', { - 'properties': { - 'role': { - 'type': "string", - 'enum': ["staff", "editor", "owner"] - - } - } - }) + userProfile = StaffUserProfileSerializerV2(source="cached_user", read_only=True) + user = EntityRelatedFieldV2(source="cached_user", queryset=BadgeUser.cached) + role = serializers.CharField( + validators=[ChoicesValidator(list(dict(IssuerStaff.ROLE_CHOICES).keys()))] + ) class IssuerSerializerV2(DetailSerializerV2, OriginalJsonSerializerMixin): - openBadgeId = serializers.URLField(source='jsonld_id', read_only=True) - createdAt = DateTimeWithUtcZAtEndField(source='created_at', read_only=True) - createdBy = EntityRelatedFieldV2(source='cached_creator', queryset=BadgeUser.cached, required=False) + openBadgeId = serializers.URLField(source="jsonld_id", read_only=True) + createdAt = DateTimeWithUtcZAtEndField(source="created_at", read_only=True) + createdBy = EntityRelatedFieldV2( + source="cached_creator", queryset=BadgeUser.cached, required=False + ) name = StripTagsCharField(max_length=1024) - image = ValidImageField(required=False, use_public=True, source='*') + image = ValidImageField(required=False, use_public=True, source="*") email = serializers.EmailField(max_length=255, required=True) description = StripTagsCharField(max_length=16384, required=False) url = serializers.URLField(max_length=1024, required=True) - staff = IssuerStaffSerializerV2(many=True, source='staff_items', required=False) - extensions = serializers.DictField(source='extension_items', required=False, validators=[BadgeExtensionValidator()]) + staff = IssuerStaffSerializerV2(many=True, source="staff_items", required=False) + extensions = serializers.DictField( + source="extension_items", required=False, validators=[BadgeExtensionValidator()] + ) badgrDomain = serializers.SlugRelatedField( - required=False, source='badgrapp', slug_field='cors', queryset=BadgrApp.objects + required=False, source="badgrapp", slug_field="cors", queryset=BadgrApp.objects ) class Meta(DetailSerializerV2.Meta): model = Issuer - apispec_definition = ('Issuer', { - 'properties': OrderedDict([ - ('entityId', { - 'type': "string", - 'format': "string", - 'description': "Unique identifier for this Issuer", - 'readOnly': True, - }), - ('entityType', { - 'type': "string", - 'format': "string", - 'description': "\"Issuer\"", - 'readOnly': True, - }), - ('openBadgeId', { - 'type': "string", - 'format': "url", - 'description': "URL of the OpenBadge compliant json", - 'readOnly': True, - }), - ('createdAt', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the Issuer was created", - 'readOnly': True, - }), - ('createdBy', { - 'type': 'string', - 'format': 'entityId', - 'description': "BadgeUser who created this Issuer", - 'required': False, - }), - - ('name', { - 'type': "string", - 'format': "string", - 'description': "Name of the Issuer", - 'required': True, - }), - ('image', { - 'type': "string", - 'format': "data:image/png;base64", - 'description': "Base64 encoded string of an image that represents the Issuer", - 'required': False, - }), - ('email', { - 'type': "string", - 'format': "email", - 'description': "Contact email for the Issuer", - 'required': True, - }), - ('url', { - 'type': "string", - 'format': "url", - 'description': "Homepage or website associated with the Issuer", - 'required': False, - }), - ('description', { - 'type': "string", - 'format': "text", - 'description': "Short description of the Issuer", - 'required': False, - }), - - ]) - }) def validate_image(self, image): if image is not None: img_name, img_ext = os.path.splitext(image.name) - image.name = 'issuer_logo_' + str(uuid.uuid4()) + img_ext + image.name = "issuer_logo_" + str(uuid.uuid4()) + img_ext return image def validate_createdBy(self, val): - if not request_authenticated_with_server_admin_token(self.context.get('request')): + if not request_authenticated_with_server_admin_token( + self.context.get("request") + ): return None return val def validate(self, data): - if data.get('badgrapp') and not request_authenticated_with_server_admin_token(self.context.get('request')): - data.pop('badgrapp') + if data.get("badgrapp") and not request_authenticated_with_server_admin_token( + self.context.get("request") + ): + data.pop("badgrapp") return data def create(self, validated_data): - request = self.context.get('request') + request = self.context.get("request") # If a Server Admin declares another user as creator, set it to that other user. Otherwise, use request.user - user = validated_data.pop('cached_creator', None) + user = validated_data.pop("cached_creator", None) if user: - validated_data['created_by'] = user + validated_data["created_by"] = user - potential_email = validated_data['email'] - if validated_data.get('badgrapp') is None: - validated_data['badgrapp'] = BadgrApp.objects.get_current(request) + potential_email = validated_data["email"] + if validated_data.get("badgrapp") is None: + validated_data["badgrapp"] = BadgrApp.objects.get_current(request) # Server admins are exempt from email verification requirement. They will enforce it themselves. - if not request_authenticated_with_server_admin_token(request) and not validated_data['created_by'].is_email_verified(potential_email): + if not request_authenticated_with_server_admin_token( + request + ) and not validated_data["created_by"].is_email_verified(potential_email): raise serializers.ValidationError( - "Issuer email must be one of your verified addresses. Add this email to your profile and try again.") + "Issuer email must be one of your verified addresses. Add this email to your profile and try again." + ) - staff = validated_data.pop('staff_items', []) + staff = validated_data.pop("staff_items", []) new_issuer = super(IssuerSerializerV2, self).create(validated_data) # update staff after issuer is created @@ -192,228 +158,176 @@ def create(self, validated_data): return new_issuer def update(self, instance, validated_data): - validated_data.pop('cached_creator', None) + validated_data.pop("cached_creator", None) - if 'image' in validated_data: - self.context['save_kwargs'] = dict(force_resize=True) + if "image" in validated_data: + self.context["save_kwargs"] = dict(force_resize=True) return super(IssuerSerializerV2, self).update(instance, validated_data) def to_representation(self, instance): from backpack.api import _scrub_boolean - include_staff = _scrub_boolean(self.context['request'].query_params.get('include_staff', True)) - if self.fields.get('staff') and not include_staff: - self.fields.pop('staff') + include_staff = _scrub_boolean( + self.context["request"].query_params.get("include_staff", True) + ) + if self.fields.get("staff") and not include_staff: + self.fields.pop("staff") return super(IssuerSerializerV2, self).to_representation(instance) class AlignmentItemSerializerV2(BaseSerializerV2, OriginalJsonSerializerMixin): - targetName = StripTagsCharField(source='target_name') - targetUrl = serializers.URLField(source='target_url') - targetDescription = StripTagsCharField(source='target_description', required=False, allow_null=True, allow_blank=True) - targetFramework = StripTagsCharField(source='target_framework', required=False, allow_null=True, allow_blank=True) - targetCode = StripTagsCharField(source='target_code', required=False, allow_null=True, allow_blank=True) - - class Meta: - apispec_definition = ('BadgeClassAlignment', { - 'properties': { - } - }) - - -class BadgeClassExpirationSerializerV2(serializers.Serializer): - amount = serializers.IntegerField(source='expires_amount', allow_null=True, validators=[PositiveIntegerValidator()]) - duration = serializers.ChoiceField(source='expires_duration', allow_null=True, choices=BadgeClass.EXPIRES_DURATION_CHOICES) - - class Meta: - apispec_definition = ('BadgeClassExpiration', { - 'properties': { - } - }) + targetName = StripTagsCharField(source="target_name") + targetUrl = serializers.URLField(source="target_url") + targetDescription = StripTagsCharField( + source="target_description", required=False, allow_null=True, allow_blank=True + ) + targetFramework = StripTagsCharField( + source="target_framework", required=False, allow_null=True, allow_blank=True + ) + targetCode = StripTagsCharField( + source="target_code", required=False, allow_null=True, allow_blank=True + ) class BadgeClassSerializerV2(DetailSerializerV2, OriginalJsonSerializerMixin): - openBadgeId = serializers.URLField(source='jsonld_id', read_only=True) - createdAt = DateTimeWithUtcZAtEndField(source='created_at', read_only=True) - createdBy = EntityRelatedFieldV2(source='cached_creator', read_only=True) - issuer = EntityRelatedFieldV2(source='cached_issuer', required=False, queryset=Issuer.cached) - issuerOpenBadgeId = serializers.URLField(source='issuer_jsonld_id', read_only=True) + openBadgeId = serializers.URLField(source="jsonld_id", read_only=True) + createdAt = DateTimeWithUtcZAtEndField(source="created_at", read_only=True) + createdBy = EntityRelatedFieldV2(source="cached_creator", read_only=True) + issuer = EntityRelatedFieldV2( + source="cached_issuer", required=False, queryset=Issuer.cached + ) + issuerOpenBadgeId = serializers.URLField(source="issuer_jsonld_id", read_only=True) name = StripTagsCharField(max_length=1024) - image = ValidImageField(required=False, use_public=True, source='*') + image = ValidImageField(required=False, use_public=True, source="*") description = StripTagsCharField(max_length=16384, required=True, convert_null=True) + course_url = StripTagsCharField( + required=False, allow_blank=True, allow_null=True, validators=[URLValidator()] + ) + criteriaUrl = StripTagsCharField( + source="criteria_url", + required=False, + allow_null=True, + validators=[URLValidator()], + ) + criteriaNarrative = MarkdownCharField( + source="criteria_text", required=False, allow_null=True + ) - criteriaUrl = StripTagsCharField(source='criteria_url', required=False, allow_null=True, validators=[URLValidator()]) - criteriaNarrative = MarkdownCharField(source='criteria_text', required=False, allow_null=True) - - alignments = AlignmentItemSerializerV2(source='alignment_items', many=True, required=False) - tags = serializers.ListField(child=StripTagsCharField(max_length=1024), source='tag_items', required=False) - - expires = BadgeClassExpirationSerializerV2(source='*', required=False, allow_null=True) + alignments = AlignmentItemSerializerV2( + source="alignment_items", many=True, required=False + ) + tags = serializers.ListField( + child=StripTagsCharField(max_length=254), source="tag_items", required=False + ) + areas = serializers.SlugRelatedField( + many=True, + slug_field='name', + queryset=Area.objects.all(), + required=False + ) + expiration = serializers.IntegerField( + required=False, + allow_null=True, + validators=[PositiveIntegerValidator()], + ) - extensions = serializers.DictField(source='extension_items', required=False, validators=[BadgeExtensionValidator()]) + extensions = serializers.DictField( + source="extension_items", required=False, validators=[BadgeExtensionValidator()] + ) class Meta(DetailSerializerV2.Meta): model = BadgeClass - apispec_definition = ('BadgeClass', { - 'properties': OrderedDict([ - ('entityId', { - 'type': "string", - 'format': "string", - 'description': "Unique identifier for this BadgeClass", - 'readOnly': True, - }), - ('entityType', { - 'type': "string", - 'format': "string", - 'description': "\"BadgeClass\"", - 'readOnly': True, - }), - ('openBadgeId', { - 'type': "string", - 'format': "url", - 'description': "URL of the OpenBadge compliant json", - 'readOnly': True, - }), - ('createdAt', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the BadgeClass was created", - 'readOnly': True, - }), - ('createdBy', { - 'type': 'string', - 'format': 'entityId', - 'description': "BadgeUser who created this BadgeClass", - 'readOnly': True, - }), - - ('issuer', { - 'type': 'string', - 'format': 'entityId', - 'description': "entityId of the Issuer who owns the BadgeClass", - 'required': False, - }), - - ('name', { - 'type': "string", - 'format': "string", - 'description': "Name of the BadgeClass", - 'required': True, - }), - ('description', { - 'type': "string", - 'format': "string", - 'description': "Short description of the BadgeClass", - 'required': True, - }), - ('image', { - 'type': "string", - 'format': "data:image/png;base64", - 'description': "Base64 encoded string of an image that represents the BadgeClass.", - 'required': False, - }), - ('criteriaUrl', { - 'type': "string", - 'format': "url", - 'description': "External URL that describes in a human-readable format the criteria for the BadgeClass", - 'required': False, - }), - ('criteriaNarrative', { - 'type': "string", - 'format': "markdown", - 'description': "Markdown formatted description of the criteria", - 'required': False, - }), - ('tags', { - 'type': "array", - 'items': { - 'type': "string", - 'format': "string" - }, - 'description': "List of tags that describe the BadgeClass", - 'required': False, - }), - ('alignments', { - 'type': "array", - 'items': { - '$ref': '#/definitions/BadgeClassAlignment' - }, - 'description': "List of objects describing objectives or educational standards", - 'required': False, - }), - ('expires', { - '$ref': "#/definitions/BadgeClassExpiration", - 'description': "Expiration period for Assertions awarded from this BadgeClass", - 'required': False, - }), - ]) - }) def to_internal_value(self, data): - if not isinstance(data, BadgeClass) and 'expires' in data: - if not data['expires'] or len(data['expires']) == 0: + if not isinstance(data, BadgeClass) and "expires" in data: + if not data["expires"] or len(data["expires"]) == 0: # if expires was included blank, remove it so to_internal_value() doesnt choke - del data['expires'] + del data["expires"] return super(BadgeClassSerializerV2, self).to_internal_value(data) def update(self, instance, validated_data): - if 'cached_issuer' in validated_data: - validated_data.pop('cached_issuer') # issuer is not updatable + if "cached_issuer" in validated_data: + validated_data.pop("cached_issuer") # issuer is not updatable - if 'image' in validated_data: - self.context['save_kwargs'] = dict(force_resize=True) + if "image" in validated_data: + self.context["save_kwargs"] = dict(force_resize=True) # Verify that criteria won't be empty - if 'criteria_url' in validated_data or 'criteria_text' in validated_data: - end_criteria_url = validated_data['criteria_url'] if 'criteria_url' in validated_data \ + if "criteria_url" in validated_data or "criteria_text" in validated_data: + end_criteria_url = ( + validated_data["criteria_url"] + if "criteria_url" in validated_data else instance.criteria_url - end_criteria_text = validated_data['criteria_text'] if 'criteria_text' in validated_data \ + ) + end_criteria_text = ( + validated_data["criteria_text"] + if "criteria_text" in validated_data else instance.criteria_text + ) + + if (end_criteria_url is None or not end_criteria_url.strip()) and ( + end_criteria_text is None or not end_criteria_text.strip() + ): + raise serializers.ValidationError( + "Changes cannot be made that would leave both criteria_url and " + "criteria_text blank." + ) - if ((end_criteria_url is None or not end_criteria_url.strip()) - and (end_criteria_text is None or not end_criteria_text.strip())): - raise serializers.ValidationError('Changes cannot be made that would leave both criteria_url and ' - 'criteria_text blank.') - - if not IsEditor().has_object_permission(self.context.get('request'), None, instance.issuer): + if not IsEditor().has_object_permission( + self.context.get("request"), None, instance.issuer + ): raise serializers.ValidationError( - {"issuer": "You do not have permission to edit badges on this issuer."}) + {"issuer": "You do not have permission to edit badges on this issuer."} + ) return super(BadgeClassSerializerV2, self).update(instance, validated_data) def create(self, validated_data): - if 'image' not in validated_data: - raise serializers.ValidationError({'image': 'Valid image file or data URI required.'}) - if 'cached_issuer' in validated_data: + if "image" not in validated_data: + raise serializers.ValidationError( + {"image": "Valid image file or data URI required."} + ) + if "cached_issuer" in validated_data: # included issuer in request - validated_data['issuer'] = validated_data.pop('cached_issuer') - elif 'issuer' in self.context: + validated_data["issuer"] = validated_data.pop("cached_issuer") + elif "issuer" in self.context: # issuer was passed in context - validated_data['issuer'] = self.context.get('issuer') + validated_data["issuer"] = self.context.get("issuer") else: # issuer is required on create raise serializers.ValidationError({"issuer": "This field is required"}) - if 'criteria_url' not in validated_data and 'criteria_text' not in validated_data: - raise serializers.ValidationError("A criteria_url or criteria_test is required.") + if ( + "criteria_url" not in validated_data + and "criteria_text" not in validated_data + ): + raise serializers.ValidationError( + "A criteria_url or criteria_test is required." + ) - if not IsEditor().has_object_permission(self.context.get('request'), None, validated_data['issuer']): - raise serializers.ValidationError({"issuer": "You do not have permission to edit badges on this issuer."}) + if not IsEditor().has_object_permission( + self.context.get("request"), None, validated_data["issuer"] + ): + raise serializers.ValidationError( + {"issuer": "You do not have permission to edit badges on this issuer."} + ) return super(BadgeClassSerializerV2, self).create(validated_data) class BadgeRecipientSerializerV2(BaseSerializerV2): - identity = serializers.CharField(source='recipient_identifier') - hashed = serializers.NullBooleanField(default=None, required=False) + identity = serializers.CharField(source="recipient_identifier") + hashed = serializers.BooleanField(default=None, allow_null=True, required=False) type = serializers.ChoiceField( choices=BadgeInstance.RECIPIENT_TYPE_CHOICES, default=RECIPIENT_TYPE_EMAIL, required=False, - source='recipient_type' + source="recipient_type", + ) + plaintextIdentity = serializers.CharField( + source="recipient_identifier", read_only=True, required=False ) - plaintextIdentity = serializers.CharField(source='recipient_identifier', read_only=True, required=False) VALIDATORS = { RECIPIENT_TYPE_EMAIL: EmailValidator(), @@ -426,240 +340,123 @@ class BadgeRecipientSerializerV2(BaseSerializerV2): RECIPIENT_TYPE_URL: False, RECIPIENT_TYPE_ID: False, RECIPIENT_TYPE_TELEPHONE: True, - } - class Meta: - apispec_definition = ('BadgeRecipient', { - 'properties': OrderedDict([ - ('identity', { - 'type': 'string', - 'format': 'string', - 'description': 'Either the hash of the identity or the plaintext value' - }), - ('type', { - 'type': 'string', - 'enum': [c[0] for c in BadgeInstance.RECIPIENT_TYPE_CHOICES], - 'description': "Type of identifier used to identify recipient" - }), - ('hashed', { - 'type': 'boolean', - 'description': "Whether or not the identity value is hashed." - }), - ('plaintextIdentity', { - 'type': 'string', - 'description': "The plaintext identity" - }), - ]), - }) - def validate(self, attrs): - recipient_type = attrs.get('recipient_type') - recipient_identifier = attrs.get('recipient_identifier') - hashed = attrs.get('hashed') + recipient_type = attrs.get("recipient_type") + recipient_identifier = attrs.get("recipient_identifier") + hashed = attrs.get("hashed") if recipient_type in self.VALIDATORS: try: self.VALIDATORS[recipient_type](recipient_identifier) except DjangoValidationError as e: raise serializers.ValidationError(e.message) if hashed is None: - attrs['hashed'] = self.HASHED_DEFAULTS.get(recipient_type, True) + attrs["hashed"] = self.HASHED_DEFAULTS.get(recipient_type, True) return attrs def to_representation(self, instance): - representation = super(BadgeRecipientSerializerV2, self).to_representation(instance) + representation = super(BadgeRecipientSerializerV2, self).to_representation( + instance + ) if instance.hashed: - representation['salt'] = instance.salt - representation['identity'] = generate_sha256_hashstring(instance.recipient_identifier, instance.salt) + representation["salt"] = instance.salt + representation["identity"] = generate_sha256_hashstring( + instance.recipient_identifier, instance.salt + ) return representation class EvidenceItemSerializerV2(BaseSerializerV2, OriginalJsonSerializerMixin): - url = serializers.URLField(source='evidence_url', max_length=1024, required=False) + url = serializers.URLField(source="evidence_url", max_length=1024, required=False) narrative = MarkdownCharField(required=False) - class Meta: - apispec_definition = ('AssertionEvidence', { - 'properties': OrderedDict([ - ('url', { - 'type': "string", - 'format': "url", - 'description': "URL of a webpage presenting evidence of the achievement", - }), - ('narrative', { - 'type': "string", - 'format': "markdown", - 'description': "Markdown narrative that describes the achievement", - }), - ]) - }) - def validate(self, attrs): - if not (attrs.get('evidence_url', None) or attrs.get('narrative', None)): + if not (attrs.get("evidence_url", None) or attrs.get("narrative", None)): raise serializers.ValidationError("Either url or narrative is required") return attrs class BadgeInstanceSerializerV2(DetailSerializerV2, OriginalJsonSerializerMixin): - openBadgeId = serializers.URLField(source='jsonld_id', read_only=True) - createdAt = DateTimeWithUtcZAtEndField(source='created_at', read_only=True, default_timezone=pytz.utc) - createdBy = EntityRelatedFieldV2(source='cached_creator', read_only=True) - badgeclass = EntityRelatedFieldV2(source='cached_badgeclass', required=False, queryset=BadgeClass.cached) + openBadgeId = serializers.URLField(source="jsonld_id", read_only=True) + createdAt = DateTimeWithUtcZAtEndField( + source="created_at", read_only=True, default_timezone=pytz.utc + ) + createdBy = EntityRelatedFieldV2(source="cached_creator", read_only=True) + badgeclass = EntityRelatedFieldV2( + source="cached_badgeclass", required=False, queryset=BadgeClass.cached + ) badgeclassOpenBadgeId = CachedUrlHyperlinkedRelatedField( - source='badgeclass_jsonld_id', view_name='badgeclass_json', lookup_field='entity_id', - queryset=BadgeClass.cached, required=False) + source="badgeclass_jsonld_id", + view_name="badgeclass_json", + lookup_field="entity_id", + queryset=BadgeClass.cached, + required=False, + ) badgeclassName = serializers.CharField(write_only=True, required=False) - issuer = EntityRelatedFieldV2(source='cached_issuer', required=False, queryset=Issuer.cached) - issuerOpenBadgeId = serializers.URLField(source='issuer_jsonld_id', read_only=True) + issuer = EntityRelatedFieldV2( + source="cached_issuer", required=False, queryset=Issuer.cached + ) + issuerOpenBadgeId = serializers.URLField(source="issuer_jsonld_id", read_only=True) - image = ValidImageField(read_only=True, use_public=True, source='*') - recipient = BadgeRecipientSerializerV2(source='*', required=False) + image = ValidImageField(read_only=True, use_public=True, source="*") + recipient = BadgeRecipientSerializerV2(source="*", required=False) - issuedOn = DateTimeWithUtcZAtEndField(source='issued_on', required=False, default_timezone=pytz.utc) + issuedOn = DateTimeWithUtcZAtEndField( + source="issued_on", required=False, default_timezone=pytz.utc + ) narrative = MarkdownCharField(required=False, allow_null=True) - evidence = EvidenceItemSerializerV2(source='evidence_items', many=True, required=False) + evidence = EvidenceItemSerializerV2( + source="evidence_items", many=True, required=False + ) revoked = HumanReadableBooleanField(read_only=True) - revocationReason = serializers.CharField(source='revocation_reason', read_only=True) + revocationReason = serializers.CharField(source="revocation_reason", read_only=True) acceptance = serializers.CharField(read_only=True) - expires = DateTimeWithUtcZAtEndField(source='expires_at', required=False, allow_null=True, default_timezone=pytz.utc) + expires = DateTimeWithUtcZAtEndField( + source="expires_at", required=False, allow_null=True, default_timezone=pytz.utc + ) notify = HumanReadableBooleanField(write_only=True, required=False, default=False) - allowDuplicateAwards = serializers.BooleanField(write_only=True, required=False, default=True) - - extensions = serializers.DictField(source='extension_items', required=False, validators=[BadgeExtensionValidator()]) + allowDuplicateAwards = serializers.BooleanField( + write_only=True, required=False, default=True + ) + course_url = StripTagsCharField( + required=False, allow_blank=True, allow_null=True, validators=[URLValidator()] + ) + extensions = serializers.DictField( + source="extension_items", required=False, validators=[BadgeExtensionValidator()] + ) class Meta(DetailSerializerV2.Meta): model = BadgeInstance - apispec_definition = ('Assertion', { - 'properties': OrderedDict([ - ('entityId', { - 'type': "string", - 'format': "string", - 'description': "Unique identifier for this Assertion", - 'readOnly': True, - }), - ('entityType', { - 'type': "string", - 'format': "string", - 'description': "\"Assertion\"", - 'readOnly': True, - }), - ('openBadgeId', { - 'type': "string", - 'format': "url", - 'description': "URL of the OpenBadge compliant json", - 'readOnly': True, - }), - ('createdAt', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the Assertion was created", - 'readOnly': True, - }), - ('createdBy', { - 'type': 'string', - 'format': 'entityId', - 'description': "BadgeUser who created the Assertion", - 'readOnly': True, - }), - - ('badgeclass', { - 'type': 'string', - 'format': 'entityId', - 'description': "BadgeClass that issued this Assertion", - 'required': False, - }), - ('badgeclassOpenBadgeId', { - 'type': 'string', - 'format': 'url', - 'description': "URL of the BadgeClass to award", - 'required': False, - }), - ('badgeclassName', { - 'type': 'string', - 'format': 'string', - 'description': "Name of BadgeClass to create assertion against, case insensitive", - 'required': False, - }), - ('revoked', { - 'type': 'boolean', - 'description': "True if this Assertion has been revoked", - 'readOnly': True, - }), - ('revocationReason', { - 'type': 'string', - 'format': "string", - 'description': "Short description of why the Assertion was revoked", - 'readOnly': True, - }), - ('acceptance', { - 'type': 'string', - 'description': "Recipient interaction with Assertion. One of: Unaccepted, Accepted, or Rejected", - 'readOnly': True, - }), - ('image', { - 'type': 'string', - 'format': 'url', - 'description': "URL to the baked assertion image", - 'readOnly': True, - }), - ('issuedOn', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the Assertion was issued", - 'required': False, - }), - ('narrative', { - 'type': 'string', - 'format': 'markdown', - 'description': "Markdown narrative of the achievement", - 'required': False, - }), - ('evidence', { - 'type': 'array', - 'items': { - '$ref': '#/definitions/AssertionEvidence' - }, - 'description': "List of evidence associated with the achievement", - 'required': False, - }), - ('recipient', { - 'type': 'object', - '$ref': '#/definitions/BadgeRecipient', - 'description': "Recipient that was issued the Assertion", - 'required': False, - }), - ('expires', { - 'type': 'string', - 'format': 'ISO8601 timestamp', - 'description': "Timestamp when the Assertion expires", - 'required': False, - }), - ]) - }) def validate_issuedOn(self, value): if value > timezone.now(): - raise serializers.ValidationError("Only issuedOn dates in the past are acceptable.") + raise serializers.ValidationError( + "Only issuedOn dates in the past are acceptable." + ) if value.year < 1583: - raise serializers.ValidationError("Only issuedOn dates after the introduction of the Gregorian calendar are allowed.") + raise serializers.ValidationError( + "Only issuedOn dates after the introduction of the Gregorian calendar are allowed." + ) return value def update(self, instance, validated_data): updateable_fields = [ - 'evidence_items', - 'expires_at', - 'extension_items', - 'hashed', - 'issued_on', - 'narrative', - 'recipient_identifier', - 'recipient_type' + "evidence_items", + "expires_at", + "extension_items", + "hashed", + "issued_on", + "narrative", + "recipient_identifier", + "recipient_type", + "course_url", ] for field_name in updateable_fields: @@ -670,66 +467,91 @@ def update(self, instance, validated_data): return instance def create(self, validated_data): - if 'cached_issuer' in validated_data: + if "cached_issuer" in validated_data: # ignore issuer in request - validated_data.pop('cached_issuer') + validated_data.pop("cached_issuer") return super().create(validated_data) def validate(self, data): - request = self.context.get('request', None) - expected_issuer = self.context.get('kwargs', {}).get('issuer') - badgeclass_identifiers = ['badgeclass_jsonld_id', 'badgeclassName', 'cached_badgeclass', 'badgeclass'] + request = self.context.get("request", None) + expected_issuer = self.context.get("kwargs", {}).get("issuer") + badgeclass_identifiers = [ + "badgeclass_jsonld_id", + "badgeclassName", + "cached_badgeclass", + "badgeclass", + ] badge_instance_properties = list(data.keys()) - if 'badgeclass' in self.context: - badge_instance_properties.append('badgeclass') + if "badgeclass" in self.context: + badge_instance_properties.append("badgeclass") if sum([el in badgeclass_identifiers for el in badge_instance_properties]) > 1: - raise serializers.ValidationError('Multiple badge class identifiers. Exactly one of the following badge class identifiers are allowed: badgeclass, badgeclassName, or badgeclassOpenBadgeId') + raise serializers.ValidationError( + "Multiple badge class identifiers. " + "Exactly one of the following badge class identifiers are allowed: " + "badgeclass, badgeclassName, or badgeclassOpenBadgeId" + ) - if request and request.method != 'PUT': + if request and request.method != "PUT": # recipient and badgeclass are only required on create, ignored on update - if 'recipient_identifier' not in data: - raise serializers.ValidationError({'recipient': ["This field is required"]}) + if "recipient_identifier" not in data: + raise serializers.ValidationError( + {"recipient": ["This field is required"]} + ) - if 'cached_badgeclass' in data: + if "cached_badgeclass" in data: # included badgeclass in request - data['badgeclass'] = data.pop('cached_badgeclass') - elif 'badgeclass' in self.context: + data["badgeclass"] = data.pop("cached_badgeclass") + elif "badgeclass" in self.context: # badgeclass was passed in context - data['badgeclass'] = self.context.get('badgeclass') - elif 'badgeclass_jsonld_id' in data: - data['badgeclass'] = data.pop('badgeclass_jsonld_id') - elif 'badgeclassName' in data: - name = data.pop('badgeclassName') + data["badgeclass"] = self.context.get("badgeclass") + elif "badgeclass_jsonld_id" in data: + data["badgeclass"] = data.pop("badgeclass_jsonld_id") + elif "badgeclassName" in data: + name = data.pop("badgeclassName") matches = BadgeClass.objects.filter(name=name, issuer=expected_issuer) len_matches = len(matches) if len_matches == 1: - data['badgeclass'] = matches.first() + data["badgeclass"] = matches.first() elif len_matches == 0: - raise serializers.ValidationError("No matching BadgeClass found with name {}".format(name)) + raise serializers.ValidationError( + "No matching BadgeClass found with name {}".format(name) + ) else: - raise serializers.ValidationError("Could not award; {} BadgeClasses with name {}".format(len_matches, name)) + raise serializers.ValidationError( + "Could not award; {} BadgeClasses with name {}".format( + len_matches, name + ) + ) else: - raise serializers.ValidationError({"badgeclass": ["This field is required"]}) + raise serializers.ValidationError( + {"badgeclass": ["This field is required"]} + ) - allow_duplicate_awards = data.pop('allowDuplicateAwards') + allow_duplicate_awards = data.pop("allowDuplicateAwards") if allow_duplicate_awards is False: - previous_awards = BadgeInstance.objects.filter( - recipient_identifier=data['recipient_identifier'], badgeclass=data['badgeclass'] - ).filter( - revoked=False - ).filter( - Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()) + previous_awards = ( + BadgeInstance.objects.filter( + recipient_identifier=data["recipient_identifier"], + badgeclass=data["badgeclass"], + ) + .filter(revoked=False) + .filter( + Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()) + ) ) if previous_awards.exists(): raise serializers.ValidationError( - "A previous award of this badge already exists for this recipient.") + "A previous award of this badge already exists for this recipient." + ) - if expected_issuer and data['badgeclass'].issuer_id != expected_issuer.id: - raise serializers.ValidationError({"badgeclass": ["Could not find matching badgeclass for this issuer."]}) + if expected_issuer and data["badgeclass"].issuer_id != expected_issuer.id: + raise serializers.ValidationError( + {"badgeclass": ["Could not find matching badgeclass for this issuer."]} + ) - if 'badgeclass' in data: - data['issuer'] = data['badgeclass'].issuer + if "badgeclass" in data: + data["issuer"] = data["badgeclass"].issuer return data diff --git a/apps/issuer/serializers_v3.py b/apps/issuer/serializers_v3.py new file mode 100644 index 000000000..fc7b5957d --- /dev/null +++ b/apps/issuer/serializers_v3.py @@ -0,0 +1,28 @@ +from rest_framework import serializers +from entity.serializers import DetailSerializerV2 +from issuer.models import BadgeClass + + +class BadgeClassSerializerV3(DetailSerializerV2): + class Meta(DetailSerializerV2.Meta): + model = BadgeClass + + +class BaseRequestIframeSerializer(serializers.Serializer): + """Base serializer for all iFrame request endpoints""" + + LANGUAGES = [ + ("en", "English"), + ("de", "German"), + ] + + lang = serializers.ChoiceField(choices=LANGUAGES, default="en") + + +class RequestIframeSerializer(BaseRequestIframeSerializer): + email = serializers.CharField() + + +class RequestIframeBadgeProcessSerializer(BaseRequestIframeSerializer): + issuer = serializers.CharField(required=False, default=None) + badge = serializers.CharField(required=False, default=None) diff --git a/apps/issuer/services/image_composer.py b/apps/issuer/services/image_composer.py new file mode 100644 index 000000000..4c54ba7ab --- /dev/null +++ b/apps/issuer/services/image_composer.py @@ -0,0 +1,460 @@ +from math import ceil +from PIL import Image +import cairosvg +import io +import os +import base64 +from django.contrib.staticfiles import finders +import logging + +logger = logging.getLogger(__name__) + + +class ImageComposer: + DEFAULT_CANVAS_SIZE = (600, 600) + CANVAS_SIZES = { + "participation": DEFAULT_CANVAS_SIZE, + "competency": DEFAULT_CANVAS_SIZE, + "learningpath": DEFAULT_CANVAS_SIZE, + } + FRAME_PADDING = 0.86 + MAX_IMAGE_SIZE = 2 * 1024 * 1024 # 2MB + MAX_DIMENSIONS = (512, 512) + ALLOWED_FORMATS = {"PNG", "SVG"} + + def __init__(self, category=None): + self.category = category + self.CANVAS_SIZE = self.CANVAS_SIZES.get(category, self.DEFAULT_CANVAS_SIZE) + + def get_canvas_size(self, category): + return self.CANVAS_SIZES.get(category, self.DEFAULT_CANVAS_SIZE) + + def compose_badge_from_uploaded_image( + self, image, issuerImage, networkImage, draw_frame=True + ): + """ + Compose badge image with issuer logo from image upload + """ + try: + if not draw_frame: + # return original image untouched + if isinstance(image, str) and image.startswith("data:image/"): + return image + else: + return f"data:image/png;base64,{image}" + + canvas = Image.new("RGBA", self.CANVAS_SIZE, (255, 255, 255, 0)) + + ### Frame ### + if draw_frame: + shape_image = self._get_colored_shape_svg() + if shape_image: + # Center the frame on the canvas + frame_x = (self.CANVAS_SIZE[0] - shape_image.width) // 2 + frame_y = (self.CANVAS_SIZE[1] - shape_image.height) // 2 + canvas.paste(shape_image, (frame_x, frame_y), shape_image) + + ### badge image ### + badge_img = self._prepare_uploaded_image(image) + if badge_img: + x = (self.CANVAS_SIZE[0] - badge_img.width) // 2 + y = (self.CANVAS_SIZE[1] - badge_img.height) // 2 + canvas.paste(badge_img, (x, y), badge_img) + + if draw_frame: + if issuerImage: + canvas = self._add_issuer_logo(canvas, self.category, issuerImage) + + if networkImage: + canvas = self._add_network_logo(canvas, networkImage) + + return self._get_image_as_base64(canvas) + + except Exception as error: + print(f"Error generating badge image from upload: {error}") + raise error + + def _get_image_as_base64(self, canvas): + """ + Convert PIL Image to base64 + """ + img_buffer = io.BytesIO() + canvas.save(img_buffer, format="PNG", quality=95) + img_buffer.seek(0) + + import base64 + + img_base64 = base64.b64encode(img_buffer.getvalue()).decode("utf-8") + return f"data:image/png;base64,{img_base64}" + + def _prepare_uploaded_image(self, image_data): + """ + Prepare uploaded image data (base64 string) + """ + try: + if isinstance(image_data, str): + if image_data.startswith("data:image/"): + header, data = image_data.split(",", 1) + image_bytes = base64.b64decode(data) + else: + image_bytes = base64.b64decode(image_data) + + if header.startswith("data:image/svg"): + png_bytes = cairosvg.svg2png(bytestring=image_bytes) + img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") + else: + img = Image.open(io.BytesIO(image_bytes)).convert("RGBA") + + else: + raise ValueError("Expected base64 string for image data") + + target_size = ( + int(((self.CANVAS_SIZE[0] * self.FRAME_PADDING) // 2)), + int(((self.CANVAS_SIZE[0] * self.FRAME_PADDING) // 2)), + ) + + if img.width < target_size[0] or img.height < target_size[1]: + # upscale (e.g. nounproject images are 200x200px) + img = img.resize(target_size, Image.Resampling.LANCZOS) + else: + img.thumbnail(target_size, Image.Resampling.LANCZOS) + + result = Image.new("RGBA", target_size, (0, 0, 0, 0)) + x = (target_size[0] - img.width) // 2 + y = (target_size[1] - img.height) // 2 + + result.paste(img, (x, y), img) + + return result + + except Exception as e: + logger.error(f"Error preparing uploaded image: {e}") + raise e + + def _get_colored_shape_svg(self): + """ + Load SVG and convert to PIL Image + Scale SVG to match new canvas size + """ + try: + svg_files = { + "participation": "participation.svg", + "competency": "competency.svg", + "learningpath": "learningpath.svg", + } + + svg_filename = svg_files.get(self.category, "participation.svg") + svg_path = finders.find(f"images/{svg_filename}") + + if not os.path.exists(svg_path): + raise FileNotFoundError(f"SVG file not found: {svg_path}") + + with open(svg_path, "r", encoding="utf-8") as f: + png_buf = io.BytesIO() + f.seek(0) + try: + # Scale SVG to match canvas size + cairosvg.svg2png( + file_obj=f, + write_to=png_buf, + output_width=self.CANVAS_SIZE[0] * self.FRAME_PADDING, + ) + except IOError as e: + raise (f"IO error while converting svg2png {e}") + img = Image.open(png_buf) + + return img + + except Exception as e: + raise (f"Error processing shape SVG: {e}") + + def _add_issuer_logo(self, canvas, category, issuerImage): + """ + Add issuer logo in square container + """ + try: + base_logo_height = 96 # base container height for 600px canvas + scale_factor = self.CANVAS_SIZE[0] / 600 + logo_height = int(base_logo_height * scale_factor) + logo_size = (logo_height, logo_height) + + if category == "competency": + offset_ratio = 160 / 600 + elif category == "learningpath": + offset_ratio = 135 / 600 + else: + offset_ratio = 125 / 600 + + logo_x = ( + self.CANVAS_SIZE[0] + - logo_size[0] + - int(self.CANVAS_SIZE[0] * offset_ratio) + ) + + # Vertical centering relative to the main frame + shape_image = self._get_colored_shape_svg() + frame_y = (self.CANVAS_SIZE[1] - shape_image.height) // 2 + + frame_image = self._get_logo_frame_svg("square") + if frame_image: + frame_resized = frame_image.resize(logo_size, Image.Resampling.LANCZOS) + canvas.paste(frame_resized, (logo_x, frame_y), frame_resized) + + PADDING_RATIO = ( + 12 / 96 + ) # Figma: 8px + 4px (inside the white) padding in 96px frame + PADDING = int(ceil(logo_size[0] * PADDING_RATIO)) + + inner_size = ( + logo_size[0] - PADDING * 2, + logo_size[1] - PADDING * 2, + ) + + logo_img = self._prepare_issuer_logo(issuerImage, inner_size) + if logo_img: + final_logo_x = logo_x + PADDING + final_logo_y = frame_y + PADDING + canvas.paste(logo_img, (final_logo_x, final_logo_y), logo_img) + + return canvas + + except Exception as e: + logger.error(f"Error adding issuer logo: {e}") + return canvas + + def _add_network_logo(self, canvas, networkImage): + """ + Add network image in bottom center, flush with canvas bottom + """ + try: + base_canvas_size = 600 + scale_factor = self.CANVAS_SIZE[0] / base_canvas_size + + network_image_size = ( + int(224 * scale_factor), + int(96 * scale_factor), + ) + + if self.category == "participation": + frame_bottom_ratio = ( + 0.85 # bottom frame border at approximately 85% of canvas height + ) + frame_bottom = int(self.CANVAS_SIZE[1] * frame_bottom_ratio) + bottom_y = frame_bottom - (network_image_size[1] // 2) + elif self.category == "competency": + frame_bottom_ratio = 0.89 + frame_bottom = int(self.CANVAS_SIZE[1] * frame_bottom_ratio) + bottom_y = frame_bottom - (network_image_size[1] // 2) + else: + bottom_y = self.CANVAS_SIZE[1] - network_image_size[1] + + bottom_x = (self.CANVAS_SIZE[0] - network_image_size[0]) // 2 + + frame_image = self._get_logo_frame_svg("rectangle") + if frame_image: + frame_resized = frame_image.resize( + network_image_size, Image.Resampling.LANCZOS + ) + canvas.paste(frame_resized, (bottom_x, bottom_y), frame_resized) + + border_padding = int(8 * scale_factor) + + inner_size = ( + network_image_size[0] - border_padding * 2, + network_image_size[1] - border_padding * 2, + ) + bottom_img = self._prepare_issuer_logo(networkImage, inner_size) + + if bottom_img: + composite_img = self._create_network_logo_with_text( + bottom_img, inner_size + ) + + final_bottom_x = bottom_x + border_padding + final_bottom_y = bottom_y + border_padding + canvas.paste( + composite_img, (final_bottom_x, final_bottom_y), composite_img + ) + + return canvas + + except Exception as e: + logger.error(f"Error adding network image: {e}") + return canvas + + def _create_network_logo_with_text(self, network_img, frame_inner_size): + """ + Create the 'NETWORK PARTNER' + network logo composite inside the inner rectangle frame. + """ + from PIL import Image, ImageDraw, ImageFont + + composite = Image.new("RGBA", frame_inner_size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(composite) + + figma_inner_width = 208 + figma_inner_height = 80 + figma_logo_size = 72 + figma_font_size = 26 + figma_gap = 8 + logo_padding = (figma_inner_height - figma_logo_size) // 2 + + text_color = (255, 255, 255, 255) # white + + # Use the smaller scale to ensure everything fits + scale = min( + frame_inner_size[0] / figma_inner_width, + frame_inner_size[1] / figma_inner_height, + ) + + logo_height = int(round(figma_logo_size * scale)) + font_size = max( + int(round(figma_font_size * scale)), 8 + ) # Minimum 8px for readability + gap = int(round(figma_gap * scale)) + + network_img = self._trim_transparency(network_img) + + aspect = network_img.width / network_img.height + logo_width = int(logo_height * aspect) + + network_img = network_img.resize( + (logo_width, logo_height), Image.Resampling.LANCZOS + ) + + font_path_regular = finders.find("fonts/Rubik-Regular.ttf") + font_path_bold = finders.find("fonts/Rubik-SemiBold.ttf") + try: + font = ImageFont.truetype(font_path_regular, font_size) + font_bold = ImageFont.truetype(font_path_bold, font_size) + except Exception: + font = ImageFont.load_default() + font_bold = ImageFont.load_default() + + text = "NETWORK" + text2 = "\nPARTNER" + text_bbox = draw.textbbox((0, 0), text, font=font) + text_height = text_bbox[3] - text_bbox[1] + text_offset_y = ( + -text_bbox[1] * 2 + 2 + ) # Offset to align text baseline properly + line spacing + + logo_x = int(logo_padding * scale) + logo_y = int(logo_padding * scale) + + text_x = gap + logo_width + logo_padding * 2 + text_y = (frame_inner_size[1] - text_height * 2) // 2 + text_offset_y + + draw.text((text_x, text_y), text, fill=text_color, font=font) + draw.text((text_x, text_y), text2, fill=text_color, font=font_bold) + composite.paste(network_img, (logo_x, logo_y), network_img) + + return composite + + def _get_logo_frame_svg(self, shape="square"): + """Load and convert the square frame SVG""" + if shape == "square": + frame_path = finders.find("images/square.svg") + + elif shape == "rectangle": + frame_path = finders.find("images/rectangle.svg") + + if not os.path.exists(frame_path): + raise (f"SVG frame not found: {frame_path}") + + try: + with open(frame_path, "r", encoding="utf-8") as f: + svg_content = f.read() + + png_bytes = cairosvg.svg2png(bytestring=svg_content.encode("utf-8")) + return Image.open(io.BytesIO(png_bytes)).convert("RGBA") + + except Exception as e: + logger.error(f"Error loading logo frame: {e}") + raise e + + def _prepare_issuer_logo(self, logo_field, target_size): + """Prepare issuer logo with support for SVG and PNG formats""" + try: + if not hasattr(logo_field, "name"): + raise Exception("Logo field missing name attribute") + + file_extension = logo_field.name.lower().split(".")[-1] + + if file_extension == "svg": + return self._prepare_svg_logo(logo_field, target_size) + elif file_extension in ["png", "jpg", "jpeg"]: + return self._prepare_raster_logo(logo_field, target_size) + else: + logger.error(f"Unsupported logo format: {file_extension}") + raise Exception("Logo format not supported") + + except Exception as e: + logger.error(f"Error preparing issuer logo: {e}") + raise e + + def _prepare_raster_logo(self, logo_field, target_size): + """Prepare PNG/JPEG logo with proper sizing and centering""" + try: + logo_field.seek(0) + img = Image.open(logo_field).convert("RGBA") + + if ( + img.size[0] > self.MAX_DIMENSIONS[0] + or img.size[1] > self.MAX_DIMENSIONS[1] + ): + logger.error( + f"Logo dimensions {img.size} exceed limits {self.MAX_DIMENSIONS}" + ) + raise Exception("Logo dimensions exceed limits") + + img.thumbnail(target_size, Image.Resampling.LANCZOS) + + result = Image.new("RGBA", target_size, (0, 0, 0, 0)) + x = (target_size[0] - img.width) // 2 + y = (target_size[1] - img.height) // 2 + result.paste(img, (x, y), img) + + return result + + except Exception as e: + logger.error(f"Error preparing raster logo: {e}") + raise e + + def _prepare_svg_logo(self, svg_field, target_size): + try: + svg_field.seek(0) + svg_content = svg_field.read() + if isinstance(svg_content, bytes): + svg_content = svg_content.decode("utf-8") + + png_bytes = cairosvg.svg2png( + bytestring=svg_content.encode("utf-8"), + output_width=target_size[0], + output_height=target_size[1], + ) + + img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") + + result = Image.new("RGBA", target_size, (0, 0, 0, 0)) + x = (target_size[0] - img.width) // 2 + y = (target_size[1] - img.height) // 2 + result.paste(img, (x, y), img) + + return result + + except Exception as e: + logger.error(f"Error preparing SVG logo: {e}") + raise e + + def _trim_transparency(self, img): + """ + Crop transparent whitespace from around an RGBA image. + Keeps the non-transparent content tight within its bounds. + """ + if img.mode != "RGBA": + img = img.convert("RGBA") + + bbox = img.getbbox() + if bbox: + return img.crop(bbox) + return img diff --git a/apps/issuer/tasks.py b/apps/issuer/tasks.py index f1eb68b41..9226a4339 100644 --- a/apps/issuer/tasks.py +++ b/apps/issuer/tasks.py @@ -2,133 +2,122 @@ import os import dateutil -import itertools -import requests -from celery.utils.log import get_task_logger from django.conf import settings from django.core.files.uploadedfile import InMemoryUploadedFile from django.db.models.signals import post_save -from requests import ConnectionError -import badgrlog from issuer.helpers import BadgeCheckHelper from issuer.managers import resolve_source_url_referencing_local_object from issuer.models import BadgeClass, BadgeInstance, Issuer from issuer.utils import CURRENT_OBI_VERSION from mainsite.celery import app -from mainsite.utils import OriginSetting, convert_svg_to_png, verify_svg +from mainsite.utils import convert_svg_to_png, verify_svg -logger = get_task_logger(__name__) -badgrLogger = badgrlog.BadgrLogger() - -background_task_queue_name = getattr(settings, 'BACKGROUND_TASK_QUEUE_NAME', 'default') -badgerank_task_queue_name = getattr(settings, 'BADGERANK_TASK_QUEUE_NAME', 'default') - - -@app.task(bind=True, queue=badgerank_task_queue_name, autoretry_for=(ConnectionError,), retry_backoff=True, max_retries=10) -def notify_badgerank_of_badgeclass(self, badgeclass_pk): - badgerank_enabled = getattr(settings, 'BADGERANK_NOTIFY_ENABLED', True) - if not badgerank_enabled: - return { - 'success': True, - 'message': "skipping since BADGERANK_NOTIFY_ENABLED=False" - } - - try: - badgeclass = BadgeClass.cached.get(pk=badgeclass_pk) - except BadgeClass.DoesNotExist: - return { - 'success': False, - 'error': "Unknown badgeclass pk={}".format(badgeclass_pk) - } - - badgerank_notify_url = getattr(settings, 'BADGERANK_NOTIFY_URL', 'https://api.badgerank.org/v1/badgeclass/submit') - response = requests.post(badgerank_notify_url, json=dict(url=badgeclass.public_url)) - if response.status_code != 200: - return { - 'success': False, - 'status_code': response.status_code, - 'response': response.content - } - return { - 'success': True - } +background_task_queue_name = getattr(settings, "BACKGROUND_TASK_QUEUE_NAME", "default") @app.task(bind=True, queue=background_task_queue_name) -def rebake_all_assertions(self, obi_version=CURRENT_OBI_VERSION, limit=None, offset=0, replay=False): +def rebake_all_assertions( + self, obi_version=CURRENT_OBI_VERSION, limit=None, offset=0, replay=False +): queryset = BadgeInstance.objects.filter(source_url__isnull=True).order_by("pk") if limit: - queryset = queryset[offset:offset+limit] + queryset = queryset[offset : offset + limit] else: queryset = queryset[offset:] assertions = queryset.only("entity_id") count = 0 for assertion in assertions: - rebake_assertion_image.delay(assertion_entity_id=assertion.entity_id, obi_version=obi_version) + rebake_assertion_image.delay( + assertion_entity_id=assertion.entity_id, obi_version=obi_version + ) count += 1 if limit and replay and count >= limit: - rebake_all_assertions.delay(obi_version=obi_version, limit=limit, offset=offset+limit, replay=True) + rebake_all_assertions.delay( + obi_version=obi_version, limit=limit, offset=offset + limit, replay=True + ) return { - 'success': True, - 'count': count, - 'limit': limit, - 'offset': offset, - 'message': "Enqueued {} assertions for rebaking".format(count) + "success": True, + "count": count, + "limit": limit, + "offset": offset, + "message": "Enqueued {} assertions for rebaking".format(count), } @app.task(bind=True, queue=background_task_queue_name) -def rebake_all_assertions_for_badge_class(self, badge_class_id, obi_version=CURRENT_OBI_VERSION, limit=None, offset=0, replay=False): - queryset = BadgeInstance.objects.filter(badgeclass_id=badge_class_id, source_url__isnull=True).order_by("pk") +def rebake_all_assertions_for_badge_class( + self, + badge_class_id, + obi_version=CURRENT_OBI_VERSION, + limit=None, + offset=0, + replay=False, +): + queryset = BadgeInstance.objects.filter( + badgeclass_id=badge_class_id, source_url__isnull=True + ).order_by("pk") if limit: - queryset = queryset[offset:offset+limit] + queryset = queryset[offset : offset + limit] else: queryset = queryset[offset:] assertions = queryset.only("entity_id") count = 0 for assertion in assertions: - rebake_assertion_image.delay(assertion_entity_id=assertion.entity_id, obi_version=obi_version) + rebake_assertion_image.delay( + assertion_entity_id=assertion.entity_id, obi_version=obi_version + ) count += 1 if limit and replay and count >= limit: - rebake_all_assertions_for_badge_class.delay(badge_class_id, obi_version=obi_version, limit=limit, offset=offset+limit, replay=True) + rebake_all_assertions_for_badge_class.delay( + badge_class_id, + obi_version=obi_version, + limit=limit, + offset=offset + limit, + replay=True, + ) return { - 'success': True, - 'count': count, - 'limit': limit, - 'offset': offset, - 'message': "Enqueued {} assertions for rebaking due to badge class change".format(count) + "success": True, + "count": count, + "limit": limit, + "offset": offset, + "message": "Enqueued {} assertions for rebaking due to badge class change".format( + count + ), } @app.task(bind=True, queue=background_task_queue_name) -def rebake_assertion_image(self, assertion_entity_id=None, obi_version=CURRENT_OBI_VERSION): - +def rebake_assertion_image( + self, assertion_entity_id=None, obi_version=CURRENT_OBI_VERSION +): try: assertion = BadgeInstance.cached.get(entity_id=assertion_entity_id) - except BadgeInstance.DoesNotExist as e: + except BadgeInstance.DoesNotExist: return { - 'success': False, - 'error': "Unknown assertion entity_id={}".format(assertion_entity_id) + "success": False, + "error": "Unknown assertion entity_id={}".format(assertion_entity_id), } if assertion.source_url: return { - 'success': False, - 'error': "Skipping imported assertion={} source_url={}".format(assertion_entity_id, assertion.source_url) + "success": False, + "error": "Skipping imported assertion={} source_url={}".format( + assertion_entity_id, assertion.source_url + ), } assertion.rebake(obi_version=obi_version) return { - 'success': True, - 'message': "Rebaked image for {}".format(assertion_entity_id) + "success": True, + "message": "Rebaked image for {}".format(assertion_entity_id), } @@ -149,12 +138,7 @@ def update_issuedon_all_assertions(self, start=None, end=None): end_date = dateutil.parser.parse(end) queryset = queryset.filter(created_at__lte=end_date) except ValueError: - return { - 'success': False, - 'start': start, - 'end': end, - 'message': "Invalid date" - } + return {"success": False, "start": start, "end": end, "message": "Invalid date"} count = 0 for assertion in queryset: @@ -162,41 +146,42 @@ def update_issuedon_all_assertions(self, start=None, end=None): count += 1 return { - 'success': True, - 'start_date': start_date, - 'end_date': end_date, - 'totalCount': count, + "success": True, + "start_date": start_date, + "end_date": end_date, + "totalCount": count, } @app.task(bind=True, queue=background_task_queue_name) def update_issuedon_imported_assertion(self, assertion_entityid): - try: assertion = BadgeInstance.objects.get(entity_id=assertion_entityid) except BadgeInstance.DoesNotExist: return { - 'success': False, - 'assertion': assertion_entityid, - 'message': "No such assertion." + "success": False, + "assertion": assertion_entityid, + "message": "No such assertion.", } if not assertion.source_url: return { - 'success': False, - 'assertion': assertion_entityid, - 'message': "Not an imported assertion." + "success": False, + "assertion": assertion_entityid, + "message": "Not an imported assertion.", } assertion_obo = BadgeCheckHelper.get_assertion_obo(assertion) if not assertion_obo: return { - 'success': False, - 'assertion': assertion_entityid, - 'message': "Unable to fetch assertion with source_url={}".format(assertion.source_url) + "success": False, + "assertion": assertion_entityid, + "message": "Unable to fetch assertion with source_url={}".format( + assertion.source_url + ), } - original_issuedOn_date = dateutil.parser.parse(assertion_obo['issuedOn']) + original_issuedOn_date = dateutil.parser.parse(assertion_obo["issuedOn"]) updated = False if original_issuedOn_date != assertion.issued_on: @@ -205,19 +190,20 @@ def update_issuedon_imported_assertion(self, assertion_entityid): updated = True return { - 'success': True, - 'assertion': assertion.entity_id, - 'source_url': assertion.source_url, - 'updated': updated + "success": True, + "assertion": assertion.entity_id, + "source_url": assertion.source_url, + "updated": updated, } @app.task(bind=True, queue=background_task_queue_name) -def remove_backpack_duplicates(self, limit=None, offset=0, replay=False, report_only=False): - +def remove_backpack_duplicates( + self, limit=None, offset=0, replay=False, report_only=False +): queryset = Issuer.objects.filter(source_url__isnull=False).order_by("pk") if limit: - queryset = queryset[offset:offset+limit] + queryset = queryset[offset : offset + limit] else: queryset = queryset[offset:] @@ -226,19 +212,23 @@ def remove_backpack_duplicates(self, limit=None, offset=0, replay=False, report_ count = 0 for issuer in imported_issuers: if resolve_source_url_referencing_local_object(issuer.source_url): - remove_backpack_duplicate_issuer.delay(issuer_entity_id=issuer.entity_id, report_only=report_only) + remove_backpack_duplicate_issuer.delay( + issuer_entity_id=issuer.entity_id, report_only=report_only + ) count += 1 if limit and replay and count >= limit: - remove_backpack_duplicates.delay(limit=limit, offset=offset+limit, replay=True, report_only=report_only) + remove_backpack_duplicates.delay( + limit=limit, offset=offset + limit, replay=True, report_only=report_only + ) return { - 'success': True, - 'count': count, - 'limit': limit, - 'offset': offset, - 'report_only': report_only, - 'message': "Enqueued {} duplicate issuers for removal".format(count) + "success": True, + "count": count, + "limit": limit, + "offset": offset, + "report_only": report_only, + "message": "Enqueued {} duplicate issuers for removal".format(count), } @@ -248,16 +238,16 @@ def remove_backpack_duplicate_issuer(self, issuer_entity_id=None, report_only=Fa issuer = Issuer.objects.get(entity_id=issuer_entity_id) except Issuer.DoesNotExist: return { - 'success': False, - 'message': "No such issuer", - 'issuer_entity_id': issuer_entity_id + "success": False, + "message": "No such issuer", + "issuer_entity_id": issuer_entity_id, } if not resolve_source_url_referencing_local_object(issuer.source_url): return { - 'success': False, - 'message': "Not a duplicate issuer", - 'issuer_entity_id': issuer_entity_id + "success": False, + "message": "Not a duplicate issuer", + "issuer_entity_id": issuer_entity_id, } assertions = issuer.badgeinstance_set.all() @@ -267,18 +257,18 @@ def remove_backpack_duplicate_issuer(self, issuer_entity_id=None, report_only=Fa for badgeclass in badgeclasses: if not resolve_source_url_referencing_local_object(badgeclass.source_url): return { - 'success': False, - 'message': "A non-duplicate badgeclass was found owned by this issuer.", - 'issuer_entity_id': issuer_entity_id, - 'badgeclass_entity_id': badgeclass.entity_id + "success": False, + "message": "A non-duplicate badgeclass was found owned by this issuer.", + "issuer_entity_id": issuer_entity_id, + "badgeclass_entity_id": badgeclass.entity_id, } for assertion in assertions: if not resolve_source_url_referencing_local_object(assertion.source_url): return { - 'success': False, - 'message': "A non-duplicate assertion was found owned by this issuer.", - 'issuer_entity_id': issuer_entity_id, - 'assertion_entity_id': assertion.entity_id + "success": False, + "message": "A non-duplicate assertion was found owned by this issuer.", + "issuer_entity_id": issuer_entity_id, + "assertion_entity_id": assertion.entity_id, } assertion_count = 0 @@ -297,11 +287,11 @@ def remove_backpack_duplicate_issuer(self, issuer_entity_id=None, report_only=Fa issuer.delete() return { - 'success': True, - 'message': "Duplicate Issuer Report" if report_only else "Issuer removed.", - 'issuer_entity_id': issuer_entity_id, - 'badgeclass_count': badgeclass_count, - 'assertion_count': assertion_count, + "success": True, + "message": "Duplicate Issuer Report" if report_only else "Issuer removed.", + "issuer_entity_id": issuer_entity_id, + "badgeclass_count": badgeclass_count, + "assertion_count": assertion_count, } @@ -309,16 +299,20 @@ def remove_backpack_duplicate_issuer(self, issuer_entity_id=None, report_only=Fa def resend_notifications(self, badgeinstance_entity_ids): current = 0 page = 100 - while len(badgeinstance_entity_ids[current:current+page]): - queryset = BadgeInstance.objects.filter(entity_id__in=badgeinstance_entity_ids[current:current+page]) + while len(badgeinstance_entity_ids[current : current + page]): + queryset = BadgeInstance.objects.filter( + entity_id__in=badgeinstance_entity_ids[current : current + page] + ) for bi in queryset: bi.notify_earner(renotify=True) current = current + page return { - 'success': True, - 'message': "{} notification requests processed".format(len(badgeinstance_entity_ids)), - 'entity_ids': badgeinstance_entity_ids + "success": True, + "message": "{} notification requests processed".format( + len(badgeinstance_entity_ids) + ), + "entity_ids": badgeinstance_entity_ids, } @@ -331,63 +325,65 @@ def generate_png_preview_image(self, entity_id, entity_type): entity = Issuer.objects.get(id=entity_id) else: return { - 'success': False, - 'message': 'Unknown entity type.', - 'entity_type': entity_type, - 'entity_id': entity_id, + "success": False, + "message": "Unknown entity type.", + "entity_type": entity_type, + "entity_id": entity_id, } # Check if a preview image already exists if entity.image_preview: return { - 'success': True, - 'message': 'Image preview already exists on entity.', - 'entity_type': entity_type, - 'entity_id': entity_id, + "success": True, + "message": "Image preview already exists on entity.", + "entity_type": entity_type, + "entity_id": entity_id, } # Verify that this entity's image is an SVG if not verify_svg(entity.image): return { - 'success': False, - 'message': 'Image on entity is not an SVG.', - 'entity_type': entity_type, - 'entity_id': entity_id, + "success": False, + "message": "Image on entity is not an SVG.", + "entity_type": entity_type, + "entity_id": entity_id, } - max_square = getattr(settings, 'IMAGE_FIELD_MAX_PX', 400) + max_square = getattr(settings, "IMAGE_FIELD_MAX_PX", 600) # Do the conversion call png_bytes = convert_svg_to_png(entity.image.read(), max_square, max_square) if not png_bytes: return { - 'success': False, - 'message': 'Error converting SVG to PNG', - 'entity_type': entity_type, - 'entity_id': entity_id, + "success": False, + "message": "Error converting SVG to PNG", + "entity_type": entity_type, + "entity_id": entity_id, } - png_preview_name = '%s.png' % os.path.splitext(entity.image.name)[0] - entity.image_preview = InMemoryUploadedFile(png_bytes, None, - png_preview_name, 'image/png', - png_bytes.tell(), None) + png_preview_name = "%s.png" % os.path.splitext(entity.image.name)[0] + entity.image_preview = InMemoryUploadedFile( + png_bytes, None, png_preview_name, "image/png", png_bytes.tell(), None + ) entity.save() return { - 'success': True, - 'message': 'PNG preview created from SVG', - 'entity_type': entity_type, - 'entity_id': entity_id, + "success": True, + "message": "PNG preview created from SVG", + "entity_type": entity_type, + "entity_id": entity_id, } def handle_png_preview_post_save(sender, instance, **kwargs): - if not getattr(settings, 'SVG_HTTP_CONVERSION_ENABLED', False): + if not getattr(settings, "SVG_HTTP_CONVERSION_ENABLED", False): return # If instance doesn't have an image preview and its image is an SVG, generate PNG image_preview copy. if not instance.image_preview and instance.image and verify_svg(instance.image): - generate_png_preview_image.delay(entity_id=instance.id, entity_type=type(instance).__name__) + generate_png_preview_image.delay( + entity_id=instance.id, entity_type=type(instance).__name__ + ) post_save.connect(handle_png_preview_post_save, sender=Issuer) diff --git a/apps/issuer/tests/__init__.py b/apps/issuer/tests/__init__.py deleted file mode 100644 index d4896b838..000000000 --- a/apps/issuer/tests/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# encoding: utf-8 - - - diff --git a/apps/issuer/tests/test_assertion.py b/apps/issuer/tests/test_assertion.py deleted file mode 100644 index fac1d113b..000000000 --- a/apps/issuer/tests/test_assertion.py +++ /dev/null @@ -1,1671 +0,0 @@ -# encoding: utf-8 - - -import datetime -from time import sleep - -import dateutil.parser -import json -from mock import patch -from openbadges_bakery import unbake -import png -import pytz -import re -from urllib.parse import quote_plus - -from django.core import mail -from django.urls import reverse -from django.utils import timezone -from django.test import override_settings -from oauth2_provider.models import Application - -from badgeuser.models import CachedEmailAddress, UserRecipientIdentifier -from issuer.models import BadgeInstance, EmailBlacklist, IssuerStaff, Issuer -from issuer.utils import parse_original_datetime -from mainsite.tests import BadgrTestCase, SetupIssuerHelper, SetupOAuth2ApplicationHelper -from mainsite.utils import OriginSetting, hash_for_image -from rest_framework import serializers - - -@override_settings(BADGE_ASSERTION_AUTO_REBAKE_BATCH_SIZE=1) -class AssertionTests(SetupIssuerHelper, BadgrTestCase): - def test_local_pending(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - award = test_badgeclass.issue(recipient_id="nobody@example.com") - self.assertEqual(award.pending, False) - - def test_assertion_pagination(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - total_assertion_count = 25 - per_page = 10 - - for i in range(0, total_assertion_count): - test_badgeclass.issue(recipient_id='test3@unittest.concentricsky.com') - - def _parse_link_header(link_header): - link_re = re.compile(r'<(?P[^>]+)>; rel="(?P[^"]+)"') - ret = {} - for match in link_re.findall(link_header): - url, name = match - ret[name] = url - return ret - - page_number = 0 - number_seen = 0 - more_pages_present = True - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badgeclass}/assertions?num={per_page}'.format( - issuer=test_issuer.entity_id, - badgeclass=test_badgeclass.entity_id, - per_page=per_page - )) - while more_pages_present: - self.assertEqual(response.status_code, 200) - - page = response.data - expected_page_count = min(total_assertion_count-number_seen, per_page) - self.assertEqual(len(page), expected_page_count) - number_seen += len(page) - - link_header = response.get('Link', None) - self.assertIsNotNone(link_header) - links = _parse_link_header(link_header) - if page_number != 0: - self.assertTrue('prev' in list(links.keys())) - - if number_seen < total_assertion_count: - self.assertTrue('next' in list(links.keys())) - next_url = links.get('next') - response = self.client.get(next_url) - page_number += 1 - else: - more_pages_present = False - - def test_can_rebake_assertion(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - test_assertion = test_badgeclass.issue(recipient_id='test1@email.test') - data = str(unbake(test_assertion.image)) - - self.assertIn('https://w3id.org/openbadges/v2', data) - - original_image_url = test_assertion.image_url() - test_assertion.rebake() - assertion = BadgeInstance.objects.get(entity_id=test_assertion.entity_id) - self.assertNotEqual(original_image_url, test_assertion.image_url(), - "To ensure downstream caches don't have the old image saved, a new filename is used") - - v2_datastr = unbake(assertion.image) - self.assertTrue(v2_datastr) - self.assertIn('https://w3id.org/openbadges/v2', v2_datastr) - - def test_put_rebakes_assertion(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_assertion = test_badgeclass.issue(recipient_id='test1@email.test') - - # v1 api - v1_backdate = datetime.datetime(year=2021, month=3, day=3, tzinfo=pytz.utc) - updated_data = dict( - expires=v1_backdate.isoformat() - ) - - response = self.client.put('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_assertion.cached_issuer.entity_id, - badge=test_assertion.cached_badgeclass.entity_id, - assertion=test_assertion.entity_id - ), updated_data) - self.assertEqual(response.status_code, 200) - updated_assertion = BadgeInstance.objects.get(entity_id=test_assertion.entity_id) - updated_obo = json.loads(str(unbake(updated_assertion.image))) - self.assertEqual(updated_obo.get('expires', None), updated_data.get('expires')) - - # v2 api - v2_backdate = datetime.datetime(year=2002, month=3, day=3, tzinfo=pytz.UTC) - updated_data = dict( - issuedOn=v2_backdate.isoformat() - ) - - response = self.client.put('/v2/assertions/{assertion}'.format( - assertion=test_assertion.entity_id - ), updated_data) - self.assertEqual(response.status_code, 200) - updated_assertion = BadgeInstance.objects.get(entity_id=test_assertion.entity_id) - updated_obo = json.loads(str(unbake(updated_assertion.image))) - self.assertEqual(updated_obo.get('issuedOn', None), updated_data.get('issuedOn')) - - def test_updating_badgeclass_image_rebakes_assertions(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass_v1 = self.setup_badgeclass(name="for v1 test", issuer=test_issuer) - test_badgeclass_v2 = self.setup_badgeclass(name="for v2 test", issuer=test_issuer) - test_assertion_v1 = test_badgeclass_v1.issue(recipient_id='test1@email.test') - test_assertion_v2 = test_badgeclass_v2.issue(recipient_id='test2@email.test') - test_assertion_v2_2 = test_badgeclass_v2.issue(recipient_id='test3@email.test') - self.assertEqual(test_assertion_v1.image.name.split(".").pop(), 'png') - self.assertEqual(test_assertion_v2.image.name.split(".").pop(), 'png') - self.assertEqual(test_assertion_v2_2.image.name.split(".").pop(), 'png') - - with open(self.get_test_svg_image_path(), 'rb') as image_update: - # v1 api - original_image_hash_v1 = hash_for_image(test_assertion_v1.image) - self.assertNotEqual('', original_image_hash_v1) - response = self.client.put('/v1/issuer/issuers/{issuer}/badges/{badgeclass}'.format( - issuer=test_assertion_v1.cached_issuer.entity_id, - badgeclass=test_assertion_v1.cached_badgeclass.entity_id, - ), dict( - image=image_update, - name=test_badgeclass_v1.name, - description=test_badgeclass_v1.description, - criteria_text="some criteria" - )) - self.assertEqual(response.status_code, 200) - sleep(2) - updated_assertion_v1 = BadgeInstance.objects.get(entity_id=test_assertion_v1.entity_id) - updated_image_hash_v1 = hash_for_image(updated_assertion_v1.image) - self.assertNotEqual('', updated_image_hash_v1) - self.assertNotEqual(updated_image_hash_v1, original_image_hash_v1) - self.assertGreater(updated_assertion_v1.updated_at, test_assertion_v1.updated_at) - self.assertEqual(updated_assertion_v1.image.name.split(".").pop(), 'svg') - - with open(self.get_test_svg_image_path(), 'rb') as image_update: - # v2 api - original_image_hash_v2 = hash_for_image(test_assertion_v2.image) - self.assertNotEqual('', original_image_hash_v2) - original_image_hash_v2_2 = hash_for_image(test_assertion_v2_2.image) - self.assertNotEqual('', original_image_hash_v2_2) - response = self.client.put('/v2/badgeclasses/{badge}'.format( - badge=test_assertion_v2.cached_badgeclass.entity_id - ), dict( - name=test_badgeclass_v2.name, - description=test_badgeclass_v2.description, - image=image_update - )) - self.assertEqual(response.status_code, 200) - sleep(2) - updated_assertion_v2 = BadgeInstance.objects.get(entity_id=test_assertion_v2.entity_id) - updated_image_hash_v2 = hash_for_image(updated_assertion_v2.image) - self.assertNotEqual('', updated_image_hash_v2) - self.assertNotEqual(updated_image_hash_v2, original_image_hash_v2) - self.assertGreater(updated_assertion_v2.updated_at, test_assertion_v2.updated_at) - self.assertEqual(updated_assertion_v2.image.name.split(".").pop(), 'svg') - response = self.client.get('/v2/assertions/{}'.format(updated_assertion_v2.entity_id)) - self.assertEqual(response.status_code, 200) - result = response.data['result'][0] - self.assertIn('{}/image'.format(updated_assertion_v2.entity_id), result['image']) # canonical image url - - # test batching works in task - updated_assertion_v2_2 = BadgeInstance.objects.get(entity_id=test_assertion_v2_2.entity_id) - updated_image_hash_v2_2 = hash_for_image(updated_assertion_v2_2.image) - self.assertNotEqual('', updated_image_hash_v2_2) - self.assertNotEqual(updated_image_hash_v2_2, original_image_hash_v2_2) - self.assertGreater(updated_assertion_v2_2.updated_at, test_assertion_v2_2.updated_at) - self.assertEqual(updated_assertion_v2_2.image.name.split(".").pop(), 'svg') - - def test_updating_badgeclass_non_image_does_not_rebake_assertions(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass_v1 = self.setup_badgeclass(name="for v1 test", issuer=test_issuer) - test_badgeclass_v2 = self.setup_badgeclass(name="for v2 test", issuer=test_issuer) - test_assertion_v1 = test_badgeclass_v1.issue(recipient_id='test1@email.test') - test_assertion_v2 = test_badgeclass_v2.issue(recipient_id='test2@email.test') - - # v1 api - original_image_url_v1 = test_assertion_v1.image_url() - with open(self.get_test_image_path(), 'rb') as image: - response = self.client.put('/v1/issuer/issuers/{issuer}/badges/{badge}'.format( - issuer=test_assertion_v1.cached_issuer.entity_id, - badge=test_assertion_v1.cached_badgeclass.entity_id, - ), dict( - name="some name update", - description="some description", - criteria_text="some criteria", - image=image - )) - self.assertEqual(response.status_code, 200) - sleep(2) - updated_assertion_v1 = BadgeInstance.objects.get(entity_id=test_assertion_v1.entity_id) - self.assertEqual(updated_assertion_v1.image_url(), original_image_url_v1) - - # v2 api - original_image_url_v2 = test_assertion_v2.image_url() - with open(self.get_test_image_path(), 'rb') as image: - response = self.client.put('/v2/badgeclasses/{badge}'.format( - badge=test_assertion_v2.cached_badgeclass.entity_id - ), dict( - name="some name update 2", - description="some description 2", - image=image - )) - self.assertEqual(response.status_code, 200) - sleep(2) - updated_assertion_v2 = BadgeInstance.objects.get(entity_id=test_assertion_v2.entity_id) - self.assertEqual(updated_assertion_v2.image_url(), original_image_url_v2) - - # v2 api - original_image_url_v2 = test_assertion_v2.image_url() - response = self.client.put('/v2/badgeclasses/{badge}'.format( - badge=test_assertion_v2.cached_badgeclass.entity_id - ), dict( - name="some name update 3", - description="some description 3" - )) - self.assertEqual(response.status_code, 200) - sleep(2) - updated_assertion_v2 = BadgeInstance.objects.get(entity_id=test_assertion_v2.entity_id) - self.assertEqual(updated_assertion_v2.image_url(), original_image_url_v2) - - def test_can_update_assertion(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - assertion_data = { - "email": "test@example.com", - "create_notification": False, - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion_data) - self.assertEqual(response.status_code, 201) - original_assertion = response.data - - new_assertion_data = { - "recipient_type": "email", - "recipient_identifier": "test@example.com", - "narrative": "test narrative", - "evidence_items": [{ - "narrative": "This is the evidence item narrative AGAIN!.", - "evidence_url": "" - }], - } - response = self.client.put('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=original_assertion.get('slug'), - ), json.dumps(new_assertion_data), content_type='application/json') - - self.assertEqual(response.status_code, 200) - updated_assertion = response.data - self.assertDictContainsSubset(new_assertion_data, updated_assertion) - - # verify v2 api - v2_assertion_data = { - "evidence": [ - { - "narrative": "remove and add new narrative", - } - ] - } - response = self.client.put('/v2/assertions/{assertion}'.format( - assertion=original_assertion.get('slug') - ), json.dumps(v2_assertion_data), content_type='application/json') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - v2_assertion = data.get('result', [None])[0] - self.assertEqual(len(v2_assertion_data['evidence']), 1) - self.assertEqual(v2_assertion['evidence'][0]['narrative'], v2_assertion_data['evidence'][0]['narrative']) - - instance = BadgeInstance.objects.get(entity_id=original_assertion['slug']) - image = instance.image - image_data = json.loads(unbake(image)) - - self.assertEqual(image_data.get('evidence', {})[0].get('narrative'), v2_assertion_data['evidence'][0]['narrative']) - - def test_can_update_assertion_issuer(self): - test_user = self.setup_user(authenticate=True) - email_two = CachedEmailAddress.objects.create(email='testemail2@example.com', verified=True, user=test_user) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - assertion_data = { - "email": "test@example.com", - "create_notification": False, - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion_data) - self.assertEqual(response.status_code, 201) - original_assertion = response.data - - response = self.client.put( - '/v1/issuer/issuers/{issuer}'.format(issuer=test_issuer.entity_id), - json.dumps({ - 'email': email_two.email, - 'url': test_issuer.url, - 'name': test_issuer.name - }), content_type='application/json') - - response = self.client.get('/public/assertions/{}?expand=badge&expand=badge.issuer'.format(original_assertion['slug'])) - assertion_data = response.data - self.assertEqual(assertion_data['badge']['issuer']['email'], email_two.email) - - def test_can_issue_assertion_with_expiration(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - expiration = timezone.now() - - # can issue assertion with expiration - assertion = { - "email": "test@example.com", - "create_notification": False, - "expires": expiration.isoformat() - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion) - self.assertEqual(response.status_code, 201) - assertion_json = response.data - self.assertEqual(dateutil.parser.parse(assertion_json.get('expires')), expiration) - - # v1 endpoint returns expiration - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=assertion_json.get('slug') - )) - self.assertEqual(response.status_code, 200) - v1_json = response.data - self.assertEqual(dateutil.parser.parse(v1_json.get('expires')), expiration) - - # v2 endpoint returns expiration - response = self.client.get('/v2/assertions/{assertion}'.format( - assertion=assertion_json.get('slug') - )) - self.assertEqual(response.status_code, 200) - v2_json = response.data.get('result')[0] - self.assertEqual(dateutil.parser.parse(v2_json.get('expires')), expiration) - - # public url returns expiration - response = self.client.get(assertion_json.get('public_url')) - self.assertEqual(response.status_code, 200) - public_json = response.data - self.assertEqual(dateutil.parser.parse(public_json.get('expires')), expiration) - - def test_can_issue_badge_if_authenticated(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - assertion = { - "email": "test@example.com", - "create_notification": False - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion) - self.assertEqual(response.status_code, 201) - self.assertIn('slug', response.data) - assertion_slug = response.data.get('slug') - # warm the cache - _ = test_user.verified - _ = test_issuer.cached_issuerstaff() - # assert that the BadgeInstance was published to and fetched from cache - query_count = 0 - with self.assertNumQueries(query_count): - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=assertion_slug)) - self.assertEqual(response.status_code, 200) - - def test_can_issue_badge_by_class_name_success(self): - badgeclass_name = "A Badge" - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.setup_badgeclass(issuer=test_issuer, name=badgeclass_name) - - assertion = { - "recipient": { - "identity": "test@example.com" - }, - "badgeclassName": badgeclass_name, - } - - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), assertion, format="json") - self.assertEqual(response.status_code, 201) - - def test_can_issue_badge_by_class_name_error(self): - badgeclass_name = "A Badge" - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.setup_badgeclass(issuer=test_issuer, name=badgeclass_name) - - assertion = { - "recipient": { - "identity": "test@example.com" - }, - "badgeclassName": "does not exist", - } - - self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), assertion, format="json") - self.assertRaises(serializers.ValidationError) - - def test_can_issue_badge_by_class_name_cached_issuer_error(self): - badgeclass_name = "A Badge" - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.setup_badgeclass(issuer=test_issuer, name=badgeclass_name) - - assertion = { - "recipient": { - "identity": "test@example.com" - }, - "badgeclassName": badgeclass_name, - } - - # Cause issuer to be published to cache. - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), assertion, format="json") - self.assertEqual(response.status_code, 201) - - # Update issuer without re-publishing to cache. - Issuer.objects.filter(pk=test_issuer.pk).update( - description='Using update method will not cause cache to be updated') - - # Error condition would instead produce a - # 400 "Could not find matching badgeclass for this issuer." - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), assertion, format="json") - self.assertEqual(response.status_code, 201) - - def test_can_issue_badge_by_class_ambiguity_error(self): - badgeclass_name = "A Badge" - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.setup_badgeclass(issuer=test_issuer, name=badgeclass_name) - self.setup_badgeclass(issuer=test_issuer, name=badgeclass_name) - - assertion = { - "recipient": { - "identity": "test@example.com" - }, - "badgeclassName": badgeclass_name, - } - - self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), assertion, format="json") - self.assertRaises(serializers.ValidationError) - - def test_cannot_issue_badge_to_invalid_email_error(self): - badgeclass_name = "A Badge" - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.setup_badgeclass(issuer=test_issuer, name=badgeclass_name) - self.setup_badgeclass(issuer=test_issuer, name=badgeclass_name) - - assertion = { - "recipient": { - "identity": "example.com", - "type": "email" - }, - "badgeclassName": badgeclass_name, - } - - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), assertion, format="json") - self.assertRaises(serializers.ValidationError) - self.assertEqual(response.status_code, 400) - - def test_cannot_issue_email_assertion_to_non_email(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - assertion = { - "recipient_identifier": "example.com", - "recipient_type": "email", - "create_notification": True - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion) - self.assertEqual(response.status_code, 400) - - def test_badge_recipient_notified_by_email_before_blacklisting_but_not_after(self): - # notify_earner tries: EmailBlacklist.objects.get(email=self.recipient_identifier) - # if a matching email is found an event is logged and the recipient is not sent an email - # If no matching email is found recipient is sent an email - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - user_email = 'test3@example.com' - new_assertion_props = { - 'recipient': {'identity': user_email}, - 'badgeclassOpenBadgeId': test_badgeclass.jsonld_id, - 'notify': True - } - - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 201) - instance = BadgeInstance.objects.get(entity_id=response.data['result'][0]['entityId']) - - self.assertEqual(len(mail.outbox), 1) - - # No matching email is found, no logging, recipient is sent an email - instance.notify_earner(self.badgr_app) - self.assertEqual(len(mail.outbox), 2) - - # Matching email is found, an event is logged, recipient is not sent an email - EmailBlacklist(email=user_email).save() - instance.notify_earner(self.badgr_app) - self.assertEqual(len(mail.outbox), 2) - - def test_issue_badge_with_ob1_evidence(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - evidence_url = "http://fake.evidence.url.test" - assertion = { - "email": "test@example.com", - "create_notification": False, - "evidence": evidence_url - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion) - self.assertEqual(response.status_code, 201) - - self.assertIn('slug', response.data) - assertion_slug = response.data.get('slug') - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=assertion_slug)) - self.assertEqual(response.status_code, 200) - self.assertIsNotNone(response.data.get('json')) - self.assertEqual(response.data.get('json').get('evidence'), evidence_url) - - # ob2.0 evidence_items also present - self.assertEqual(response.data.get('evidence_items'), [ - { - 'evidence_url': evidence_url, - 'narrative': None, - } - ]) - - def test_issue_badge_with_ob2_multiple_evidence(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - evidence_items = [ - { - 'evidence_url': "http://fake.evidence.url.test", - }, - { - 'evidence_url': "http://second.evidence.url.test", - "narrative": "some description of how second evidence was collected" - } - ] - assertion_args = { - "email": "test@example.com", - "create_notification": False, - "evidence_items": evidence_items - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion_args, format='json') - self.assertEqual(response.status_code, 201) - - assertion_slug = response.data.get('slug') - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=assertion_slug)) - self.assertEqual(response.status_code, 200) - assertion = response.data - - fetched_evidence_items = assertion.get('evidence_items') - self.assertEqual(len(fetched_evidence_items), len(evidence_items)) - for i in range(0,len(evidence_items)): - self.assertEqual(fetched_evidence_items[i].get('url'), evidence_items[i].get('url')) - self.assertEqual(fetched_evidence_items[i].get('narrative'), evidence_items[i].get('narrative')) - - # ob1.0 evidence url also present - self.assertIsNotNone(assertion.get('json')) - assertion_public_url = OriginSetting.HTTP+reverse('badgeinstance_json', kwargs={'entity_id': assertion_slug}) - self.assertEqual(assertion.get('json').get('evidence'), assertion_public_url) - - def test_v2_issue_with_evidence(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - evidence_items = [ - { - 'url': "http://fake.evidence.url.test", - }, - { - 'url': "http://second.evidence.url.test", - "narrative": "some description of how second evidence was collected" - } - ] - assertion_args = { - "recipient": {"identity": "test@example.com"}, - "notify": False, - "evidence": evidence_items - } - response = self.client.post('/v2/badgeclasses/{badge}/assertions'.format( - badge=test_badgeclass.entity_id - ), assertion_args, format='json') - self.assertEqual(response.status_code, 201) - - assertion_slug = response.data['result'][0]['entityId'] - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=assertion_slug)) - self.assertEqual(response.status_code, 200) - assertion = response.data - - v2_json = self.client.get('/public/assertions/{}?v=2_0'.format(assertion_slug), format='json').data - - fetched_evidence_items = assertion.get('evidence_items') - self.assertEqual(len(fetched_evidence_items), len(evidence_items)) - for i in range(0, len(evidence_items)): - self.assertEqual(v2_json['evidence'][i].get('id'), evidence_items[i].get('url')) - self.assertEqual(v2_json['evidence'][i].get('narrative'), evidence_items[i].get('narrative')) - self.assertEqual(fetched_evidence_items[i].get('evidence_url'), evidence_items[i].get('url')) - self.assertEqual(fetched_evidence_items[i].get('narrative'), evidence_items[i].get('narrative')) - - # ob1.0 evidence url also present - self.assertIsNotNone(assertion.get('json')) - assertion_public_url = OriginSetting.HTTP + reverse('badgeinstance_json', kwargs={'entity_id': assertion_slug}) - self.assertEqual(assertion.get('json').get('evidence'), assertion_public_url) - - def test_issue_badge_with_ob2_one_evidence_item(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - evidence_items = [ - { - 'narrative': "Executed some sweet skateboard tricks that made us completely forget the badge criteria" - } - ] - assertion_args = { - "email": "test@example.com", - "create_notification": False, - "evidence_items": evidence_items - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion_args, format='json') - self.assertEqual(response.status_code, 201) - - assertion_slug = response.data.get('slug') - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=assertion_slug)) - self.assertEqual(response.status_code, 200) - assertion = response.data - - v2_json = self.client.get('/public/assertions/{}?v=2_0'.format(assertion_slug), format='json').data - - fetched_evidence_items = assertion.get('evidence_items') - self.assertEqual(len(fetched_evidence_items), len(evidence_items)) - for i in range(0,len(evidence_items)): - self.assertEqual(v2_json['evidence'][i].get('id'), evidence_items[i].get('url')) - self.assertEqual(v2_json['evidence'][i].get('narrative'), evidence_items[i].get('narrative')) - self.assertEqual(fetched_evidence_items[i].get('url'), evidence_items[i].get('url')) - self.assertEqual(fetched_evidence_items[i].get('narrative'), evidence_items[i].get('narrative')) - - # ob1.0 evidence url also present - self.assertIsNotNone(assertion.get('json')) - assertion_public_url = OriginSetting.HTTP+reverse('badgeinstance_json', kwargs={'entity_id': assertion_slug}) - self.assertEqual(assertion.get('json').get('evidence'), assertion_public_url) - - def test_resized_png_image_baked_properly(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - assertion = { - "email": "test@example.com" - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion) - self.assertIn('slug', response.data) - assertion_slug = response.data.get('slug') - - instance = BadgeInstance.objects.get(entity_id=assertion_slug) - - instance.image.open() - self.assertIsNotNone(unbake(instance.image)) - instance.image.close() - instance.image.open() - - image_data_present = False - badge_data_present = False - reader = png.Reader(file=instance.image) - for chunk in reader.chunks(): - if chunk[0] == b'IDAT': - image_data_present = True - elif chunk[0] == b'iTXt' and chunk[1].startswith(b'openbadges\x00\x00\x00\x00\x00'): - badge_data_present = True - - self.assertTrue(image_data_present and badge_data_present) - - def test_authenticated_editor_can_issue_badge(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - editor_user = self.setup_user(authenticate=True) - IssuerStaff.objects.create( - issuer=test_issuer, - role=IssuerStaff.ROLE_EDITOR, - user=editor_user - ) - - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - ), {"email": "test@example.com"}) - self.assertEqual(response.status_code, 201) - - def test_authenticated_nonowner_user_cant_issue(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - non_editor_user = self.setup_user(authenticate=True) - assertion = { - "email": "test2@example.com" - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - ), assertion) - - self.assertEqual(response.status_code, 404) - - def test_unauthenticated_user_cant_issue(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - assertion = { - "email": "test2@example.com" - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - ), assertion) - self.assertIn(response.status_code, (401, 403)) - - def test_issue_assertion_with_notify(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - assertion = { - "email": "unittest@unittesting.badgr.io", - 'create_notification': True - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - ), assertion) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), 1) - - def test_first_assertion_always_notifies_recipient(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - outbox_count = len(mail.outbox) - - assertion = { - "email": "first_recipients_assertion@unittesting.badgr.io", - 'create_notification': False - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - ), assertion) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), outbox_count+1) - - # should not get notified of second assertion - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - ), assertion) - self.assertEqual(response.status_code, 201) - self.assertEqual(len(mail.outbox), outbox_count+1) - - def test_authenticated_owner_list_assertions(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_badgeclass.issue(recipient_id='new.recipient@email.test') - test_badgeclass.issue(recipient_id='second.recipient@email.test') - - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 2) - - def test_issuer_instance_list_assertions(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_badgeclass.issue(recipient_id='new.recipient@email.test') - test_badgeclass.issue(recipient_id='second.recipient@email.test') - - response = self.client.get('/v1/issuer/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 2) - - def test_issuer_instance_list_assertions_with_expired(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - expired_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - expired_assertion.expires_at = datetime.datetime.now() - datetime.timedelta(days=1) - expired_assertion.save() - test_badgeclass.issue(recipient_id='second.recipient@email.test') - - response = self.client.get('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - response = self.client.get('/v2/issuers/{issuer}/assertions?include_expired=1'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 2) - - def test_issuer_instance_list_assertions_with_revoked(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_badgeclass.issue(recipient_id='new.recipient@email.test') - revoked_assertion = test_badgeclass.issue(recipient_id='second.recipient@email.test') - revoked_assertion.revoked = True - revoked_assertion.save() - - response = self.client.get('/v2/issuers/{issuer}/assertions?include_revoked=1'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 2) - - response = self.client.get('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - - def test_issuer_instance_list_assertions_with_revoked_and_expired(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_badgeclass.issue(recipient_id='new.recipient@email.test') - revoked_assertion = test_badgeclass.issue(recipient_id='second.recipient@email.test') - revoked_assertion.revoked = True - revoked_assertion.save() - expired_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - expired_assertion.expires_at = datetime.datetime.now() - datetime.timedelta(days=1) - expired_assertion.save() - - response = self.client.get('/v2/issuers/{issuer}/assertions?include_revoked=1&include_expired=1'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 3) - - response = self.client.get('/v2/issuers/{issuer}/assertions?include_revoked=1'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 2) - - response = self.client.get('/v2/issuers/{issuer}/assertions?include_expired=1'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 2) - - response = self.client.get('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - def test_issuer_instance_list_assertions_with_id(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_badgeclass.issue(recipient_id='new.recipient@email.test') - test_badgeclass.issue(recipient_id='second.recipient@email.test') - - response = self.client.get('/v1/issuer/issuers/{issuer}/assertions?recipient=new.recipient@email.test'.format( - issuer=test_issuer.entity_id, - )) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) - - def test_can_revoke_assertion(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - revocation_reason = 'Earner kind of sucked, after all.' - - response = self.client.delete('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=test_assertion.entity_id, - ), {'revocation_reason': revocation_reason }) - self.assertEqual(response.status_code, 200) - - response = self.client.get('/public/assertions/{assertion}.json'.format(assertion=test_assertion.entity_id)) - self.assertEqual(response.status_code, 200) - assertion_obo = json.loads(response.content) - self.assertDictContainsSubset(dict( - revocationReason=revocation_reason, - revoked=True - ), assertion_obo) - - def test_can_revoke_assertion_bulk(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - revocation_data = [{ - 'entityId': test_assertion.entity_id, - 'revocationReason': 'Earner kind of sucked, after all.' - }] - - response = self.client.post(reverse('v2_api_assertion_revoke'), data=revocation_data, format='json') - self.assertEqual(response.status_code, 200) - - response = self.client.get('/public/assertions/{assertion}.json'.format(assertion=test_assertion.entity_id)) - self.assertEqual(response.status_code, 200) - assertion_obo = json.loads(response.content) - self.assertDictContainsSubset(dict( - revocationReason=revocation_data[0]['revocationReason'], - revoked=True - ), assertion_obo) - - def test_cannot_revoke_assertion_if_missing_reason(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - response = self.client.delete('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - assertion=test_assertion.entity_id, - )) - self.assertEqual(response.status_code, 400) - - def test_issue_svg_badge(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - with open(self.get_test_svg_image_path(), 'rb') as svg_badge_image: - response = self.client.post('/v1/issuer/issuers/{issuer}/badges'.format( - issuer=test_issuer.entity_id, - ), { - 'name': 'svg badge', - 'description': 'svg badge', - 'image': svg_badge_image, - 'criteria': 'http://wikipedia.org/Awesome', - }) - badgeclass_slug = response.data.get('slug') - - assertion = { - "email": "test@example.com" - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=badgeclass_slug - ), assertion) - self.assertEqual(response.status_code, 201) - - slug = response.data.get('slug') - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions/{assertion}'.format( - issuer=test_issuer.entity_id, - badge=badgeclass_slug, - assertion=slug - )) - self.assertEqual(response.status_code, 200) - - def test_new_assertion_updates_cached_user_badgeclasses(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - original_recipient_count = test_badgeclass.badgeinstances.filter(revoked=False).count() - - new_assertion_props = { - 'email': 'test3@example.com', - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - ), new_assertion_props) - self.assertEqual(response.status_code, 201) - - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id, - )) - badgeclass_data = response.data - self.assertEqual(test_badgeclass.badgeinstances.filter(revoked=False).count(), original_recipient_count+1) - - def test_batch_assertions_throws_400(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - invalid_batch_assertion_props = [ - { - "recipient": { - "identity": "foo@bar.com" - } - } - ] - response = self.client.post('/v2/badgeclasses/{badge}/issue'.format( - badge=test_badgeclass.entity_id - ), invalid_batch_assertion_props, format='json') - self.assertEqual(response.status_code, 400) - - def test_batch_assertions_with_invalid_issuedon(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - invalid_batch_assertion_props = { - "assertions": [ - { - 'recipient': { - "identity": "foo@bar.com", - "type": "email" - } - }, - { - 'recipient': { - "identity": "bar@baz.com", - "type": "email" - }, - 'issuedOn': 1512151153620 - }, - ] - } - response = self.client.post('/v2/badgeclasses/{badge}/issue'.format( - badge=test_badgeclass.entity_id - ), invalid_batch_assertion_props, format='json') - self.assertEqual(response.status_code, 400) - - def test_issue_assertion_with_unacceptable_issuedOn(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - issue_time = timezone.now() + timezone.timedelta(days=1) - assertion_data = { - 'recipient': { - "identity": "bar@baz.com", - "type": "email" - }, - 'issuedOn': issue_time.isoformat() - } - - response = self.client.post('/v2/badgeclasses/{badge}/assertions'.format( - badge=test_badgeclass.entity_id - ), assertion_data, format='json') - self.assertEqual(response.status_code, 400) - self.assertEqual(response.data['fieldErrors']['issuedOn'][0], 'Only issuedOn dates in the past are acceptable.') - - assertion_data['issuedOn'] = '1492-01-01T13:00:00Z' # A time prior to introduction of the Gregorian calendar. - response = self.client.post('/v2/badgeclasses/{badge}/assertions'.format( - badge=test_badgeclass.entity_id - ), assertion_data, format='json') - self.assertEqual(response.status_code, 400) - self.assertEqual(response.data['fieldErrors']['issuedOn'][0], 'Only issuedOn dates after the introduction of the Gregorian calendar are allowed.') - - def test_batch_assertions_with_evidence(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - batch_assertion_props = { - 'assertions': [{ - "recipient": { - "identity": "foo@bar.com", - "type": "email", - "hashed": True, - }, - "narrative": "foo@bar's test narrative", - "evidence": [ - { - "url": "http://example.com?evidence=foo.bar", - }, - { - "url": "http://example.com?evidence=bar.baz", - "narrative": "barbaz" - } - ] - }], - 'create_notification': True - } - response = self.client.post('/v2/badgeclasses/{badge}/issue'.format( - badge=test_badgeclass.entity_id - ), batch_assertion_props, format='json') - self.assertEqual(response.status_code, 201) - - result = json.loads(response.content) - returned_assertions = result.get('result') - - # verify results contain same evidence that was provided - for i in range(0, len(returned_assertions)): - expected = batch_assertion_props['assertions'][i] - self.assertListOfDictsContainsSubset(expected.get('evidence'), returned_assertions[i].get('evidence')) - - # verify OBO returns same results - assertion_entity_id = returned_assertions[0].get('entityId') - expected = batch_assertion_props['assertions'][0] - - response = self.client.get('/public/assertions/{assertion}.json?v=2_0'.format( - assertion=assertion_entity_id - ), format='json') - self.assertEqual(response.status_code, 200) - - assertion_obo = json.loads(response.content) - - expected = expected.get('evidence') - evidence = assertion_obo.get('evidence') - for i in range(0, len(expected)): - self.assertEqual(evidence[i].get('id'), expected[i].get('url')) - self.assertEqual(evidence[i].get('narrative', None), expected[i].get('narrative', None)) - - def assertListOfDictsContainsSubset(self, expected, actual): - for i in range(0, len(expected)): - a = expected[i] - b = actual[i] - self.assertDictContainsSubset(a, b) - - def test_get_share_url(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - test_assertion2 = test_badgeclass.issue(recipient_id='+15035555555', recipient_type='telephone') - test_assertion3 = test_badgeclass.issue(recipient_id='test.example.com/foo?bar=1', recipient_type='url') - - url = test_assertion.get_share_url() - self.assertEqual(test_assertion.jsonld_id, url) - url = test_assertion.get_share_url(include_identifier=True) - self.assertEqual(test_assertion.jsonld_id + '?identity__email=new.recipient%40email.test', url) - url = test_assertion2.get_share_url(include_identifier=True) - self.assertEqual(test_assertion2.jsonld_id + '?identity__telephone=%2B15035555555', url) - url = test_assertion3.get_share_url(include_identifier=True) - self.assertEqual(test_assertion3.jsonld_id + '?identity__url=test.example.com/foo%3Fbar%3D1', url) - - def test_parse_original_datetime(self): - result = parse_original_datetime('1577232000') - self.assertEqual(result, '2019-12-25T00:00:00Z') - result = parse_original_datetime('2018-12-23') - self.assertEqual(result, '2018-12-23T00:00:00Z') - result = parse_original_datetime('2018-12-23T00:00:00') - self.assertEqual(result, '2018-12-23T00:00:00Z') - result = parse_original_datetime('2018-12-23T00:00:00Z') - self.assertEqual(result, '2018-12-23T00:00:00Z') - result = parse_original_datetime('2018-12-23T00:00:00-05:00') - self.assertEqual(result, '2018-12-23T05:00:00Z') - result = parse_original_datetime('2018-12-23T00:00:00+05:00') - self.assertEqual(result, '2018-12-22T19:00:00Z') - result = parse_original_datetime('2018-12-23T00:00:00+00:00') - self.assertEqual(result, '2018-12-23T00:00:00Z') - result = parse_original_datetime('2018-12-23T00:00:00+12:34') - self.assertEqual(result, '2018-12-22T11:26:00Z') - result = parse_original_datetime('2018-12-23T13:37:00+12:34') - self.assertEqual(result, '2018-12-23T01:03:00Z') - - -class V2ApiAssertionTests(SetupIssuerHelper, BadgrTestCase): - def test_v2_issue_by_badgeclassOpenBadgeId(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - new_assertion_props = { - 'recipient': { - 'identity': 'test3@example.com' - }, - 'badgeclassOpenBadgeId': test_badgeclass.jsonld_id - } - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 201) - result = response.data['result'][0] - self.assertIn('{}/image'.format(result['entityId']), result['image']) # canonical image url - - def test_v2_issue_uppercase_email(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - new_assertion_props = { - 'recipient': { - 'identity': 'TEST3@example.com' - }, - 'badgeclassOpenBadgeId': test_badgeclass.jsonld_id - } - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 201) - assertion_data = response.data['result'][0] - recipient_id = assertion_data['recipient']['plaintextIdentity'] - self.assertEqual( - recipient_id, new_assertion_props['recipient']['identity'].lower(), - "Reported recipient ID is lowercase") - assertion_from_db = BadgeInstance.objects.get(entity_id=assertion_data['entityId']) - self.assertEqual( - assertion_from_db.recipient_identifier, new_assertion_props['recipient']['identity'].lower(), - "Stored recipient ID is lowercase") - - def test_v2_issue_uppercase_url(self): - """ - Unlike emails, URLs are presumed to be case sensitive as reported to us. - """ - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - new_assertion_props = { - 'recipient': { - 'identity': 'https://eXaMpLe.CoM/UPPERCASEPATH', - 'type': 'url' - }, - 'badgeclassOpenBadgeId': test_badgeclass.jsonld_id - } - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 201) - - assertion_data = response.data['result'][0] - recipient_id = assertion_data['recipient']['plaintextIdentity'] - self.assertEqual( - recipient_id, 'https://example.com/UPPERCASEPATH', - "Reported recipient ID URL is not automatically lowercased") - assertion_from_db = BadgeInstance.objects.get(entity_id=assertion_data['entityId']) - self.assertEqual( - assertion_from_db.recipient_identifier, 'https://example.com/UPPERCASEPATH', - "Stored recipient ID is not automatically lowercased") - - new_assertion_props['recipient']['identity'] = 'NOTAURL/NOTAVALIDONE/butlookslikeone?sorta=True' - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()['fieldErrors']['recipient']['non_field_errors'][0], 'Enter a valid URL.') - # TODO this might be nicer if it were just a fieldError for recipient.identity or just was a non_field_error:[str] to begin with - - def test_v2_issue_by_badgeclassOpenBadgeId_permissions(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - other_user = self.setup_user(authenticate=False) - other_issuer = self.setup_issuer(owner=other_user) - other_badgeclass = self.setup_badgeclass(issuer=other_issuer) - - new_assertion_props = { - 'recipient': { - 'identity': 'test3@example.com' - }, - 'badgeclassOpenBadgeId': other_badgeclass.jsonld_id - } - response = self.client.post('/v2/issuers/{issuer}/assertions'.format( - issuer=test_issuer.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 400) - - def test_v2_issue_entity_id_in_path(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - new_assertion_props = { - 'recipient': { - 'identity': 'test3@example.com' - } - } - response = self.client.post('/v2/badgeclasses/{badgeclass}/assertions'.format( - badgeclass=test_badgeclass.entity_id), new_assertion_props, format='json') - self.assertEqual(response.status_code, 201) - - other_user = self.setup_user(authenticate=False) - other_issuer = self.setup_issuer(owner=other_user) - other_badgeclass = self.setup_badgeclass(issuer=other_issuer) - - response = self.client.post('/v2/badgeclasses/{badgeclass}/assertions'.format( - badgeclass=other_badgeclass.entity_id), new_assertion_props, format='json') - self.assertEqual(response.status_code, 404) - - def test_can_revoke_assertion(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - revocation_reason = "I take it all back. I don't mean what I said when I was hungry." - - response = self.client.delete('/v2/assertions/{assertion}'.format( - assertion=test_assertion.entity_id, - ), {'revocation_reason': revocation_reason}) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['result'][0]['revocationReason'], revocation_reason) - - response = self.client.get('/public/assertions/{assertion}.json'.format(assertion=test_assertion.entity_id)) - self.assertEqual(response.status_code, 200) - assertion_obo = json.loads(response.content) - self.assertDictContainsSubset(dict( - revocationReason=revocation_reason, - revoked=True - ), assertion_obo) - - response = self.client.delete('/v2/assertions/{assertion}'.format( - assertion=test_assertion.entity_id, - ), {'revocation_reason': revocation_reason}) - self.assertEqual(response.status_code, 400) - - -class AssertionsChangedApplicationTests(SetupOAuth2ApplicationHelper, SetupIssuerHelper, BadgrTestCase): - def test_application_can_get_changed_assertions(self): - application_user = self.setup_user( - authenticate=False, first_name='app', last_name='user', email='app@example.test', verified=True) - issuer_user = self.setup_user(authenticate=False, verified=True) - test_issuer = self.setup_issuer(owner=issuer_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - IssuerStaff.objects.create( - issuer=test_issuer, - role=IssuerStaff.ROLE_STAFF, - user=application_user - ) - - application = self.setup_oauth2_application( - user=application_user, - allowed_scopes="rw:issuer rw:backpack rw:profile r:assertions", - trust_email=True, - authorization_grant_type=Application.GRANT_PASSWORD - ) - - # retrieve a token for the issuer owner user - response = self.client.post('/o/token', data=dict( - grant_type=application.authorization_grant_type.replace('-','_'), - client_id=application.client_id, - scope="rw:issuer", - username=issuer_user.email, - password='secret' - )) - self.assertEqual(response.status_code, 200, "Can get a token for the issuer user") - - # retrieve a token for the application user - response = self.client.post('/o/token', data=dict( - grant_type=application.authorization_grant_type.replace('-', '_'), - client_id=application.client_id, - scope="rw:issuer", - username=application_user.email, - password='secret' - )) - self.assertEqual(response.status_code, 200, "Can get a token for the application user") - - test_badgeclass.issue(recipient_id='test@example.com') - - self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + response.json()['access_token']) - response = self.client.get('/v2/assertions/changed') - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - timestamp = response.data['timestamp'] - # Get it again to assert no new results - response = self.client.get('/v2/assertions/changed?since={}'.format(quote_plus(timestamp))) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 0) - -@override_settings( - CELERY_ALWAYS_EAGER=True -) -class AssertionWithUserTests(SetupIssuerHelper, BadgrTestCase): - def setUp(self): - super(AssertionWithUserTests, self).setUp() - self.issuer = self.setup_issuer(owner=self.setup_user(email='staff@example.com')) - - def test_award_to_missing_email(self): - email = 'idonotexistyet@example.com' - badgeclass = self.setup_badgeclass(issuer=self.issuer) - award = badgeclass.issue(recipient_id=email) - self.assertEqual(award.user, None) - recipient = self.setup_user(email=email, authenticate=False) - award2 = BadgeInstance.objects.get(recipient_identifier=email) - self.assertEqual(award2.user, recipient) - - def test_verification_change_owns_badge(self): - recipient = self.setup_user(email='recipient@example.com', authenticate=False, verified=False) - badgeclass = self.setup_badgeclass(issuer=self.issuer) - award = badgeclass.issue(recipient_id=recipient.email) - self.assertEqual(award.user, None) - - email = recipient.cached_emails()[0] - email.verified = True - email.save() - award2 = BadgeInstance.objects.get(recipient_identifier=recipient.email) - self.assertEqual(award2.user, recipient) - - my_id = UserRecipientIdentifier.objects.create(type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, - identifier='http://example123.com', - user=recipient, verified=False) - badgeclass.issue(recipient_id=my_id.identifier, recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - award2 = BadgeInstance.objects.get(recipient_identifier=my_id.identifier) - self.assertEqual(award2.user, None) - my_id.verified = True - my_id.save() - award3 = BadgeInstance.objects.get(recipient_identifier=my_id.identifier) - self.assertEqual(award3.user, recipient) - - badgeclass.issue(recipient_id='+15555555555', recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE) - award = BadgeInstance.objects.get(recipient_identifier='+15555555555') - self.assertEqual(award.user, None) - my_id = UserRecipientIdentifier.objects.create(type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, - identifier='+15555555555', - user=recipient, verified=True) - award = BadgeInstance.objects.get(recipient_identifier=my_id.identifier) - self.assertEqual(award.user, recipient) - - - def test_verification_change_disowns_badge(self): - recipient = self.setup_user(email='recipient@example.com', authenticate=False) - badgeclass = self.setup_badgeclass(issuer=self.issuer) - award = badgeclass.issue(recipient_id=recipient.email) - self.assertEqual(award.user.pk, recipient.pk) - - my_id = UserRecipientIdentifier.objects.create(type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, - identifier='http://example.com', - user=recipient, verified=True) - badgeclass.issue(recipient_id='http://example.com', recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - award = BadgeInstance.objects.get(recipient_identifier='http://example.com') - self.assertEqual(award.user, recipient) - my_id.verified = False - my_id.save() - award = BadgeInstance.objects.get(recipient_identifier=my_id.identifier) - self.assertEqual(award.user, None) - - email = recipient.cached_emails()[0] - email.verified = False - email.save() - award2 = badgeclass.issue(recipient_id=recipient.email) - self.assertEqual(award2.user, None) - - - def test_assertion_has_user_post_issue(self): - recipient = self.setup_user(email='recipient@example.com', authenticate=False) - badgeclass = self.setup_badgeclass(issuer=self.issuer) - award = badgeclass.issue(recipient_id=recipient.email) - self.assertEqual(award.user.pk, recipient.pk) - - def test_assertion_user_with_verification(self): - badgeclass = self.setup_badgeclass(issuer=self.issuer) - recipient = self.setup_user(email='recipient@example.com', authenticate=False, verified=False) - award = badgeclass.issue(recipient_id=recipient.email) - self.assertEqual(award.user, None) - recipient2 = self.setup_user(email='recipient2example.com', authenticate=False) - award2 = badgeclass.issue(recipient_id=recipient2.email) - self.assertEqual(award2.user.pk, recipient2.pk) - - def test_assertion_user_none_post_email_or_identifier_delete(self): - recipient = self.setup_user(email='recipient@example.com', authenticate=False) - badgeclass = self.setup_badgeclass(issuer=self.issuer) - badgeclass.issue(recipient_id=recipient.email) - award = BadgeInstance.objects.get(recipient_identifier=recipient.email) - self.assertEqual(award.user, recipient) - CachedEmailAddress.objects.get(email='recipient@example.com').delete() - award = BadgeInstance.objects.get(recipient_identifier=recipient.email) - self.assertEqual(award.user, None) - my_id = UserRecipientIdentifier.objects.create(type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, - identifier='http://example.com', - user=recipient, verified=True) - badgeclass.issue(recipient_id='http://example.com', recipient_type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL) - award = BadgeInstance.objects.get(recipient_identifier='http://example.com') - self.assertEqual(award.user, recipient) - my_id.delete() - award = BadgeInstance.objects.get(recipient_identifier=recipient.email) - self.assertEqual(award.user, None) - - -class AllowDuplicatesAPITests(SetupIssuerHelper, BadgrTestCase): - def test_single_award_allow_duplicates(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - existing_assertion = test_badgeclass.issue('test3@example.com') - - new_assertion_props = { - 'recipient': { - 'identity': 'test3@example.com' - }, - 'allowDuplicateAwards': False - } - response = self.client.post('/v2/badgeclasses/{}/assertions'.format( - test_badgeclass.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 400) - - # can issue assertion with expiration - new_assertion_props_v1 = { - "email": 'test3@example.com', - "create_notification": False, - "allow_duplicate_awards": False - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), new_assertion_props_v1) - self.assertEqual(response.status_code, 400) - - existing_assertion.revoked = True - existing_assertion.save() - response = self.client.post('/v2/badgeclasses/{}/assertions'.format( - test_badgeclass.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 201, "Assertion should be allowed if existing award is revoked") - - def test_single_award_allow_duplicates_against_not_expired(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - existing_assertion = test_badgeclass.issue( - 'test3@example.com', expires_at=timezone.now() + timezone.timedelta(days=1) - ) - - new_assertion_props = { - 'recipient': { - 'identity': 'test3@example.com' - }, - 'allowDuplicateAwards': False - } - response = self.client.post('/v2/badgeclasses/{}/assertions'.format( - test_badgeclass.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 400, "The badge should not award, given a unexpired existing award") - - def test_single_award_allow_duplicates_against_expired(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - existing_assertion = test_badgeclass.issue( - 'test3@example.com', expires_at=timezone.now() - timezone.timedelta(days=1) - ) - - new_assertion_props = { - 'recipient': { - 'identity': 'test3@example.com' - }, - 'allowDuplicateAwards': False - } - response = self.client.post('/v2/badgeclasses/{}/assertions'.format( - test_badgeclass.entity_id - ), new_assertion_props, format='json') - self.assertEqual(response.status_code, 201, "The badge should award, given an expired prior award.") - - def test_badgeclass_and_issuer_not_in_assertion_cache_record(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue( - 'test3@example.com', expires_at=timezone.now() - timezone.timedelta(days=1) - ) - _ = assertion.badgeclass # call the foreign key attribute to ensure the related object is cached - self.assertIsNotNone(assertion._state.fields_cache.get('badgeclass')) - - cached_assertion = BadgeInstance.cached.get(entity_id=assertion.entity_id) - self.assertIsNone(cached_assertion._state.fields_cache.get('badgeclass')) - self.assertIsNone(cached_assertion._state.fields_cache.get('issuer')) diff --git a/apps/issuer/tests/test_badgeclass.py b/apps/issuer/tests/test_badgeclass.py deleted file mode 100644 index 0ba86d1b7..000000000 --- a/apps/issuer/tests/test_badgeclass.py +++ /dev/null @@ -1,1417 +0,0 @@ -# encoding: utf-8 - - -import base64 -import json -from urllib.parse import quote_plus - -from django.core.files.images import get_image_dimensions -from django.urls import reverse -from django.utils import timezone - -from issuer.models import BadgeClass, IssuerStaff -from mainsite.tests import BadgrTestCase, SetupIssuerHelper -from mainsite.utils import OriginSetting - - -class BadgeClassTests(SetupIssuerHelper, BadgrTestCase): - def _create_badgeclass_with_v2(self, image_path=None, **kwargs): - if image_path is None: - image_path = self.get_test_image_path() - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - with open(image_path, 'rb') as badge_image: - badgeclass_props = { - 'name': 'Badge of Slugs', - 'description': "Recognizes slimy learners with a penchant for lettuce", - 'image': self._base64_data_uri_encode(badge_image, 'image/png'), - 'criteriaNarrative': 'Eat lettuce. Grow big.' - } - - badgeclass_props.update(kwargs) - - response = self.client.post( - '/v2/issuers/{}/badgeclasses'.format(test_issuer.entity_id), - badgeclass_props, format='json' - ) - self.assertEqual(response.status_code, 201) - return response.data['result'][0] - - def _create_badgeclass_for_issuer_authenticated(self, image_path, **kwargs): - with open(image_path, 'rb') as badge_image: - - image_str = self._base64_data_uri_encode(badge_image, kwargs.get("image_mimetype", "image/png")) - example_badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': "An awesome badge only awarded to awesome people or non-existent test entities", - 'image': image_str, - 'criteria': 'http://wikipedia.org/Awesome', - } - example_badgeclass_props.update(kwargs) - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - response = self.client.post('/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - data=example_badgeclass_props, - format="json" - ) - self.assertEqual(response.status_code, 201) - self.assertIn('slug', response.data) - new_badgeclass_slug = response.data.get('slug') - BadgeClass.cached.get(entity_id=new_badgeclass_slug) - - # assert that the BadgeClass was published to and fetched from the cache - with self.assertNumQueries(1): # the v1 badgeclass GET API now relies on a query for recipient_count - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badgeclass}'.format( - issuer=test_issuer.entity_id, - badgeclass=new_badgeclass_slug)) - self.assertEqual(response.status_code, 200) - return json.loads(response.content) - - def get_test_image_base64(self, image_path=None): - if not image_path: - image_path = self.get_test_image_path() - with open(image_path, 'rb') as badge_image: - image_str = self._base64_data_uri_encode(badge_image, "image/png") - return image_str - - def test_can_create_badgeclass(self): - self._create_badgeclass_for_issuer_authenticated(self.get_test_image_path()) - - def test_cannot_create_badgeclass_only_with_invalid_image_data_uri(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - badgeclass_props = { - 'name': 'Badge of Slugs', - 'description': "Recognizes slimy learners with a penchant for lettuce", - 'image': 'http://placekitten.com/400/400', - 'criteriaNarrative': 'Eat lettuce. Grow big.' - } - - response = self.client.post( - '/v2/issuers/{}/badgeclasses'.format(test_issuer.entity_id), - badgeclass_props, format='json' - ) - self.assertEqual(response.status_code, 400) - - def test_staff_cannot_create_badgeclass(self): - with open(self.get_test_image_path(), 'rb') as badge_image: - - image_str = self._base64_data_uri_encode(badge_image, "image/png") - example_badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': "An awesome badge only awarded to awesome people or non-existent test entities", - 'image': image_str, - 'criteria': 'http://wikipedia.org/Awesome', - } - - test_owner = self.setup_user(authenticate=False) - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_owner) - IssuerStaff.objects.create(issuer=test_issuer, user=test_user, role=IssuerStaff.ROLE_STAFF) - self.issuer = test_issuer - response = self.client.post('/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - data=example_badgeclass_props, - format="json" - ) - self.assertEqual(response.status_code, 404) - - def test_v2_post_put_badgeclasses_permissions(self): - test_owner = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_owner) - test_user = self.setup_user(authenticate=True, token_scope='rw:issuer') - - badgeclass_data = { - 'name': 'Test Badge', - 'description': "A testing badge", - 'image': self.get_test_image_base64(), - 'criteriaUrl': 'http://wikipedia.org/Awesome', - 'issuer': test_issuer.entity_id, - } - - response = self.client.post('/v2/badgeclasses', data=badgeclass_data, format="json") - self.assertEqual(response.status_code, 400) - - staff_record = IssuerStaff.objects.create(issuer=test_issuer, user=test_user, role=IssuerStaff.ROLE_STAFF) - response = self.client.post('/v2/badgeclasses', data=badgeclass_data, format="json") - self.assertEqual(response.status_code, 400) - - staff_record.role = IssuerStaff.ROLE_EDITOR - staff_record.save() - response = self.client.post('/v2/badgeclasses', data=badgeclass_data, format="json") - self.assertEqual(response.status_code, 201) - entity_id = response.data['result'][0]['entityId'] - - badgeclass_data['name'] = 'Edited Badge' - staff_record.role = IssuerStaff.ROLE_STAFF - staff_record.save() - response = self.client.put('/v2/badgeclasses/{}'.format(entity_id), data=badgeclass_data, format="json") - self.assertEqual(response.status_code, 404) - - staff_record.role = IssuerStaff.ROLE_EDITOR - staff_record.save() - response = self.client.put('/v2/badgeclasses/{}'.format(entity_id), data=badgeclass_data, format="json") - self.assertEqual(response.status_code, 200) - - def test_v2_badgeclasses_reasonable_404_error(self): - test_owner = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_owner) - test_user = self.setup_user(authenticate=True, token_scope='rw:issuer') - - badgeclass_data = { - 'name': 'Test Badge', - 'description': "A testing badge", - 'image': self.get_test_image_base64(), - 'criteria': 'http://wikipedia.org/Awesome', - 'issuer': 'abc123', - } - - # Also test whether I can create a badgeclass for an issuer that does not exist. - response = self.client.post('/v2/badgeclasses', data=badgeclass_data, format="json") - self.assertEqual(response.status_code, 400) - - def test_v2_badgeclasses_can_paginate(self): - NUM_BADGE_CLASSES = 5 - PAGINATE = 2 - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclasses = list(self.setup_badgeclasses(issuer=test_issuer, how_many=NUM_BADGE_CLASSES)) - - test_user2 = self.setup_user(authenticate=True) - test_issuer2 = self.setup_issuer(owner=test_user2) - test_badgeclass2 = list(self.setup_badgeclasses(issuer=test_issuer2, how_many=NUM_BADGE_CLASSES)) - - response = self.client.get('/v2/badgeclasses?num={num}'.format(num=PAGINATE)) - - for badge_class in test_badgeclass2: - for staff_record in badge_class.cached_issuer.cached_issuerstaff(): - self.assertTrue(staff_record.user_id == test_user2.id) - self.assertTrue(staff_record.user_id != test_user.id) - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(test_badgeclass2), NUM_BADGE_CLASSES) - self.assertEqual(len(response.data.get('result')), PAGINATE) - - - def test_badgeclass_with_expires_in_days_v1(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - base_badgeclass_data = { - 'name': 'Expiring Badge', - 'description': "A testing badge that expires", - 'image': self.get_test_image_base64(), - 'criteria': 'http://wikipedia.org/Awesome', - } - - # can create a badgeclass with valid expires_in_days - v1_data = base_badgeclass_data.copy() - v1_data.update(dict( - expires=dict( - amount=10, - duration="days" - ), - )) - response = self.client.post('/v1/issuer/issuers/{issuer}/badges'.format(issuer=test_issuer.entity_id), data=v1_data, format="json") - self.assertEqual(response.status_code, 201) - self.assertDictEqual(response.data.get('expires'), v1_data.get('expires')) - - badgeclass_entity_id = response.data.get('slug') - - def _update_badgeclass(data): - return self.client.put('/v1/issuer/issuers/{issuer}/badges/{badge}'.format( - issuer=test_issuer.entity_id, - badge=badgeclass_entity_id - ), data=data, format="json") - - # can update a badgeclass with valid expires_in_days - good_expires_values = [ - {"amount": 25, "duration": "days"}, - {"amount": 1000000, "duration": "weeks"}, - {"amount": 3, "duration": "months"}, - {"amount": 1, "duration": "years"}, - ] - for good_value in good_expires_values: - v1_data['expires'] = good_value - response = _update_badgeclass(v1_data) - self.assertEqual(response.status_code, 200) - self.assertDictEqual(response.data.get('expires'), good_value) - - # can't use invalid expires_in_days - bad_expires_values = [ - {"amount": 0, "duration": "days"}, - {"amount": -1, "duration": "weeks"}, - {"duration": "years"}, - {"amount": 0.5, "duration": "years"}, - {"amount": 5, "duration": "fortnights"} - ] - for bad_value in bad_expires_values: - v1_data['expires'] = bad_value - response = _update_badgeclass(v1_data) - self.assertEqual(response.status_code, 400) - - def test_badgeclass_with_expires_in_days_v2(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - base_badgeclass_data = { - 'name': 'Expiring Badge', - 'description': "A testing badge that expires", - 'image': self.get_test_image_base64(), - 'criteriaUrl': 'http://wikipedia.org/Awesome', - 'issuer': test_issuer.entity_id, - } - - # can create a badgeclass with valid expires_in_days - v2_data = base_badgeclass_data.copy() - v2_data.update(dict( - expires=dict( - amount=10, - duration="days" - ) - )) - response = self.client.post('/v2/badgeclasses', data=v2_data, format="json") - self.assertEqual(response.status_code, 201) - new_badgeclass = response.data.get('result', [None])[0] - self.assertEqual(new_badgeclass.get('expires'), v2_data.get('expires')) - - # can update a badgeclass expires_in_days - def _update_badgeclass(data): - return self.client.put('/v2/badgeclasses/{badge}'.format( - badge=new_badgeclass.get('entityId') - ), data=data, format="json") - - good_expires_values = [ - {"amount": 25, "duration": "days"}, - {"amount": 1000000, "duration": "weeks"}, - {"amount": 3, "duration": "months"}, - {"amount": 1, "duration": "years"}, - ] - for good_data in good_expires_values: - v2_data['expires'] = good_data - response = _update_badgeclass(v2_data) - self.assertEqual(response.status_code, 200) - updated_badgeclass = response.data.get('result', [None])[0] - self.assertDictEqual(updated_badgeclass.get('expires'), v2_data.get('expires')) - - # can't use invalid expiration - bad_expires_values = [ - {"amount": 0, "duration": "days"}, - {"amount": -1, "duration": "weeks"}, - {"duration": "years"}, - {"amount": 0.5, "duration": "years"}, - {"amount": 5, "duration": "fortnights"} - ] - for bad_value in bad_expires_values: - v2_data['expires'] = bad_value - response = _update_badgeclass(v2_data) - self.assertEqual(response.status_code, 400) - - def test_badgeclass_relative_expire_date_generation(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - badgeclass = BadgeClass.objects.create(issuer=test_issuer) - - badgeclass.expires_duration = BadgeClass.EXPIRES_DURATION_MONTHS - badgeclass.expires_amount = 6 - - date = badgeclass.generate_expires_at(issued_on=timezone.datetime(year=2018, month=8, day=29, hour=12, tzinfo=timezone.utc)) - self.assertEqual(date.year, 2019) - self.assertEqual(date.month, 2) - self.assertEqual(date.day, 28) - - badgeclass.expires_duration = BadgeClass.EXPIRES_DURATION_YEARS - date = badgeclass.generate_expires_at( - issued_on=timezone.datetime(year=2020, month=2, day=29, hour=12, tzinfo=timezone.utc)) - self.assertEqual(date.year, 2026) - self.assertEqual(date.month, 2) - self.assertEqual(date.day, 28) - - badgeclass.expires_duration = BadgeClass.EXPIRES_DURATION_DAYS - date = badgeclass.generate_expires_at( - issued_on=timezone.datetime(year=2020, month=2, day=29, hour=12, tzinfo=timezone.utc)) - self.assertEqual(date.year, 2020) - self.assertEqual(date.month, 3) - self.assertEqual(date.day, 6) - - def test_can_create_badgeclass_with_svg(self): - self._create_badgeclass_for_issuer_authenticated(self.get_test_svg_image_path(), image_mimetype='image/svg+xml') - - def test_can_get_png_preview_for_svg_badgeclass(self): - badgeclass_data = self._create_badgeclass_for_issuer_authenticated(self.get_test_svg_image_path(), image_mimetype='image/svg+xml') - - response = self.client.get('/public/badges/{}/image?type=png'.format(badgeclass_data.get('slug'))) - self.assertEqual(response.status_code, 302) - self.assertTrue(response._headers.get('location')[1].endswith('.png')) - - def test_create_badgeclass_scrubs_svg(self): - with open(self.get_testfiles_path('hacked-svg-with-embedded-script-tags.svg'), 'rb') as attack_badge_image: - - badgeclass_props = { - 'name': 'javascript SVG badge', - 'description': 'badge whose svg source attempts to execute code', - 'image': attack_badge_image, - 'criteria': 'http://svgs.should.not.be.user.input' - } - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - response = self.client.post('/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), badgeclass_props) - self.assertEqual(response.status_code, 201) - self.assertIn('slug', response.data) - - # make sure code was stripped - bc = BadgeClass.objects.get(entity_id=response.data.get('slug')) - image_content = bc.image.file.readlines() - for ic in image_content: - self.assertNotIn(b'onload', ic) - self.assertNotIn(b'' - response = self.client.post('/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - badgeclass_props - ) - self.assertEqual(response.status_code, 201) - self.assertIsNotNone(response.data) - new_badgeclass = response.data - self.assertEqual(new_badgeclass.get('criteria_text', None), 'This is *valid* markdown mixed with raw document.write("and abusive html")') - self.assertIn('slug', new_badgeclass) - - def test_can_create_badgeclass_with_alignment(self): - with open(self.get_test_image_path(), 'rb') as badge_image: - num_badgeclasses = BadgeClass.objects.count() - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - badgeclass_props = { - 'name': 'Badge of Slugs', - 'description': "Recognizes slimy learners with a penchant for lettuce", - 'image': self._base64_data_uri_encode(badge_image, 'image/png'), - 'criteriaNarrative': 'Eat lettuce. Grow big.' - } - - # valid markdown should be saved but html tags stripped - badgeclass_props['alignments'] = [ - { - 'targetName': 'Align1', - 'targetUrl': 'http://examp.e.org/frmwrk/1' - }, - { - 'targetName': 'Align2', - 'targetUrl': 'http://examp.e.org/frmwrk/2' - } - ] - # badgeclass_props['alignment_items'] = badgeclass_props['alignments'] - response = self.client.post( - '/v2/issuers/{}/badgeclasses'.format(test_issuer.entity_id), - badgeclass_props, format='json' - ) - self.assertEqual(response.status_code, 201) - self.assertIsNotNone(response.data) - new_badgeclass = response.data['result'][0] - self.assertIn('alignments', list(new_badgeclass.keys())) - self.assertEqual(len(new_badgeclass['alignments']), 2) - self.assertEqual( - new_badgeclass['alignments'][0]['targetName'], badgeclass_props['alignments'][0]['targetName']) - - # verify that public page renders markdown as html - response = self.client.get('/public/badges/{}?v=2_0'.format(new_badgeclass.get('entityId'))) - self.assertIn('alignment', list(response.data.keys())) - self.assertEqual(len(response.data['alignment']), 2) - self.assertEqual( - response.data['alignment'][0]['targetName'], badgeclass_props['alignments'][0]['targetName']) - - self.assertEqual(num_badgeclasses + 1, BadgeClass.objects.count()) - - def test_new_badgeclass_updates_cached_issuer(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.setup_badgeclasses(issuer=test_issuer) - number_of_badgeclasses = len(list(test_user.cached_badgeclasses())) - - with open(self.get_test_image_path(), 'rb') as badge_image: - example_badgeclass_props = { - 'name': 'Badge of Freshness', - 'description': "Fresh Badge", - 'image': badge_image, - 'criteria': 'http://wikipedia.org/Freshness', - } - - response = self.client.post('/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - example_badgeclass_props) - self.assertEqual(response.status_code, 201) - - self.assertEqual(len(list(test_user.cached_badgeclasses())), number_of_badgeclasses + 1) - - def test_issuer_edits_reflected_in_badgeclass(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user, name='1') - - badgeclass = self.setup_badgeclass(test_issuer, name='test badgeclass 1') - - response = self.client.get('/v2/issuers/{}/badgeclasses'.format(test_issuer.entity_id)) # populate cache - response = self.client.get('/public/badges/{}?expand=issuer'.format(badgeclass.entity_id)) - - issuer_data = { - 'name': '2', - 'description': test_issuer.description, - 'email': test_user.email, - 'url': 'http://example.com' - } - response = self.client.put('/v2/issuers/{}'.format(test_issuer.entity_id), data=issuer_data) - self.assertEqual(response.status_code, 200) - - response = self.client.get('/public/badges/{}?expand=issuer'.format(badgeclass.entity_id)) - issuer_name = response.data['issuer']['name'] - self.assertEqual(issuer_name, '2') - - def test_new_badgeclass_updates_cached_user_badgeclasses(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.setup_badgeclasses(issuer=test_issuer) - badgelist = self.client.get('/v1/issuer/all-badges') - - with open(self.get_test_image_path(), 'rb') as badge_image: - example_badgeclass_props = { - 'name': 'Badge of Freshness', - 'description': "Fresh Badge", - 'image': badge_image, - 'criteria': 'http://wikipedia.org/Freshness', - } - - response = self.client.post('/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - example_badgeclass_props - ) - self.assertEqual(response.status_code, 201) - - new_badgelist = self.client.get('/v1/issuer/all-badges') - - self.assertEqual(len(new_badgelist.data), len(badgelist.data) + 1) - - def _base64_data_uri_encode(self, file, mime): - encoded = base64.b64encode(file.read()).decode() - return "data:{};base64,{}".format(mime, encoded) - - def test_v2_badgeclass_put_image_data_uri_resized_from_450_to_400(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - with open(self.get_test_image_path(), 'rb') as badge_image: - badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': 'An awesome badge only awarded to awesome people or non-existent test entities', - 'criteriaNarrative': 'http://wikipedia.org/Awesome', - } - - response = self.client.post( - '/v2/issuers/{slug}/badgeclasses'.format(slug=test_issuer.entity_id), - dict(badgeclass_props, image=badge_image), - ) - self.assertEqual(response.status_code, 201) - badgeclass_slug = response.data['result'][0]['entityId'] - - with open(self.get_testfiles_path('450x450.png'), 'rb') as new_badge_image: - put_response = self.client.put( - '/v2/badgeclasses/{badge}'.format(badge=badgeclass_slug), - dict(badgeclass_props, image=self._base64_data_uri_encode(new_badge_image, 'image/png')) - ) - self.assertEqual(put_response.status_code, 200) - - new_badgeclass = BadgeClass.objects.get(entity_id=badgeclass_slug) - image_width, image_height = get_image_dimensions(new_badgeclass.image.file) - - # 450x450 images should be resized to 400x400 - self.assertEqual(image_width, 400) - self.assertEqual(image_height, 400) - - def test_v1_badgeclass_put_image_data_uri_resized_from_450_to_400(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - with open(self.get_test_image_path(), 'rb') as badge_image: - badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': 'An awesome badge only awarded to awesome people or non-existent test entities', - 'criteria': 'http://wikipedia.org/Awesome', - } - - response = self.client.post('/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - dict(badgeclass_props, image=badge_image), - ) - self.assertEqual(response.status_code, 201) - self.assertIn('slug', response.data) - badgeclass_slug = response.data.get('slug') - - with open(self.get_testfiles_path('450x450.png'), 'rb') as new_badge_image: - put_response = self.client.put( - '/v1/issuer/issuers/{issuer}/badges/{badge}'.format(issuer=test_issuer.entity_id, badge=badgeclass_slug), - dict(badgeclass_props, image=self._base64_data_uri_encode(new_badge_image, 'image/png')) - ) - self.assertEqual(put_response.status_code, 200) - - new_badgeclass = BadgeClass.objects.get(entity_id=badgeclass_slug) - image_width, image_height = get_image_dimensions(new_badgeclass.image.file) - - # 450x450 images should be resized to 400x400 - self.assertEqual(image_width, 400) - self.assertEqual(image_height, 400) - - def test_badgeclass_image_url_is_canonical(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - - with open(self.get_test_image_path(), 'rb') as badge_image: - example_badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': "An awesome badge only awarded to awesome people or non-existent test entities", - 'image': self._base64_data_uri_encode(badge_image, "image/png"), - 'criteriaNarrative': 'http://wikipedia.org/Awesome', - 'issuer': self.issuer.entity_id - } - - response = self.client.post('/v2/badgeclasses', data=example_badgeclass_props, format="json") - self.assertEqual(response.status_code, 201) - bc = response.data['result'][0] - self.assertIn('{}/image'.format(bc['entityId']), bc['image']) - - response = self.client.get('/v2/issuers/{}/badgeclasses'.format(test_issuer.entity_id)) - self.assertEqual(response.status_code, 200) - bc_get = response.data['result'][0] - self.assertIn('{}/image'.format(bc_get['entityId']), bc_get['image']) - - def test_badgeclass_put_image_data_uri(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - with open(self.get_test_image_path(), 'rb') as badge_image: - badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': 'An awesome badge only awarded to awesome people or non-existent test entities', - 'criteria': 'http://wikipedia.org/Awesome', - } - - response = self.client.post('/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - dict(badgeclass_props, image=badge_image), - ) - self.assertEqual(response.status_code, 201) - self.assertIn('slug', response.data) - badgeclass_slug = response.data.get('slug') - - with open(self.get_testfiles_path('400x400.png'), 'rb') as new_badge_image: - put_response = self.client.put( - '/v1/issuer/issuers/{issuer}/badges/{badge}'.format(issuer=test_issuer.entity_id, badge=badgeclass_slug), - dict(badgeclass_props, image=self._base64_data_uri_encode(new_badge_image, 'image/png')) - ) - self.assertEqual(put_response.status_code, 200) - - new_badgeclass = BadgeClass.objects.get(entity_id=badgeclass_slug) - image_width, image_height = get_image_dimensions(new_badgeclass.image.file) - - # File should be changed to new 400x400 image - self.assertEqual(image_width, 400) - self.assertEqual(image_height, 400) - - def test_badgeclass_put_image_non_data_uri(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': 'An awesome badge only awarded to awesome people or non-existent test entities', - 'criteria': 'http://wikipedia.org/Awesome', - } - - with open(self.get_testfiles_path('300x300.png'), 'rb') as badge_image: - post_response = self.client.post('/v1/issuer/issuers/{issuer}/badges'.format(issuer=test_issuer.entity_id), - dict(badgeclass_props, image=badge_image), - ) - self.assertEqual(post_response.status_code, 201) - slug = post_response.data.get('slug') - - put_response = self.client.put('/v1/issuer/issuers/{issuer}/badges/{badge}'.format(issuer=test_issuer.entity_id, badge=slug), - dict(badgeclass_props, image='http://example.com/example.png') - ) - self.assertEqual(put_response.status_code, 200) - - new_badgeclass = BadgeClass.objects.get(entity_id=slug) - image_width, image_height = get_image_dimensions(new_badgeclass.image.file) - - # File should be original 300x300 image - self.assertEqual(image_width, 300) - self.assertEqual(image_height, 300) - - def test_badgeclass_put_image_multipart(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': 'An awesome badge only awarded to awesome people or non-existent test entities', - 'criteria': 'http://wikipedia.org/Awesome', - } - - with open(self.get_testfiles_path('300x300.png'), 'rb') as badge_image: - post_response = self.client.post('/v1/issuer/issuers/{issuer}/badges'.format(issuer=test_issuer.entity_id), - dict(badgeclass_props, image=badge_image), - ) - self.assertEqual(post_response.status_code, 201) - slug = post_response.data.get('slug') - - with open(self.get_testfiles_path('400x400.png'), 'rb') as new_badge_image: - put_response = self.client.put('/v1/issuer/issuers/{issuer}/badges/{badge}'.format(issuer=test_issuer.entity_id, badge=slug), - dict(badgeclass_props, image=new_badge_image), - format='multipart' - ) - self.assertEqual(put_response.status_code, 200) - - new_badgeclass = BadgeClass.objects.get(entity_id=slug) - image_width, image_height = get_image_dimensions(new_badgeclass.image.file) - - # File should be changed to new 400 X 400 image - self.assertEqual(image_width, 400) - self.assertEqual(image_height, 400) - - def test_badgeclass_post_get_put_roundtrip(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - with open(self.get_test_image_path(), 'rb') as badge_image: - example_badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': "An awesome badge only awarded to awesome people or non-existent test entities", - 'image': badge_image, - 'criteria': 'http://wikipedia.org/Awesome', - } - - post_response = self.client.post('/v1/issuer/issuers/{issuer}/badges'.format(issuer=test_issuer.entity_id), - example_badgeclass_props, - format='multipart' - ) - self.assertEqual(post_response.status_code, 201) - - self.assertIn('slug', post_response.data) - slug = post_response.data.get('slug') - get_response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badge}'.format(issuer=test_issuer.entity_id, badge=slug)) - self.assertEqual(get_response.status_code, 200) - - put_response = self.client.put('/v1/issuer/issuers/{issuer}/badges/{badge}'.format(issuer=test_issuer.entity_id, badge=slug), - get_response.data, format='json') - self.assertEqual(put_response.status_code, 200) - - self.assertEqual(get_response.data, put_response.data) - - def test_can_create_and_update_badgeclass_with_alignments_v1(self): - # create a badgeclass with alignments - alignments = [ - { - 'target_name': "Alignment the first", - 'target_url': "http://align.ment/1", - 'target_framework': None, - 'target_code': None, - 'target_description': None, - }, - { - 'target_name': "Second Alignment", - 'target_url': "http://align.ment/2", - 'target_framework': None, - 'target_code': None, - 'target_description': None, - }, - { - 'target_name': "Third Alignment", - 'target_url': "http://align.ment/3", - 'target_framework': None, - 'target_code': None, - 'target_description': None, - }, - ] - new_badgeclass = self._create_badgeclass_for_issuer_authenticated(self.get_test_image_path(), alignment=alignments) - self.assertEqual(alignments, new_badgeclass.get('alignment', None)) - - new_badgeclass_url = '/v1/issuer/issuers/{issuer}/badges/{badgeclass}'.format( - issuer=self.issuer.entity_id, - badgeclass=new_badgeclass['slug']) - - # update alignments -- addition and deletion - reordered_alignments = [ - alignments[0], - alignments[1], - { - 'target_name': "added alignment", - 'target_url': "http://align.ment/4", - 'target_framework': None, - 'target_code': None, - 'target_description': None, - } - ] - new_badgeclass['alignment'] = reordered_alignments - - response = self.client.put(new_badgeclass_url, new_badgeclass, format="json") - updated_badgeclass = json.loads(response.content) - self.assertEqual(response.status_code, 200) - self.assertEqual(updated_badgeclass.get('alignment', None), reordered_alignments) - - # make sure response we got from PUT matches what we get from GET - response = self.client.get(new_badgeclass_url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, updated_badgeclass) - - def test_can_create_and_update_badgeclass_with_alignments_v2(self): - # create a badgeclass with alignments - alignments = [ - { - 'targetName': "Alignment the first", - 'targetUrl': "http://align.ment/1", - 'targetFramework': None, - 'targetCode': None, - 'targetDescription': None, - }, - { - 'targetName': "Second Alignment", - 'targetUrl': "http://align.ment/2", - 'targetFramework': None, - 'targetCode': None, - 'targetDescription': None, - }, - { - 'targetName': "Third Alignment", - 'targetUrl': "http://align.ment/3", - 'targetFramework': None, - 'targetCode': None, - 'targetDescription': None, - }, - ] - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - with open(self.get_test_image_path(), 'rb') as badge_image: - example_badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': "An awesome badge only awarded to awesome people or non-existent test entities", - 'image': self._base64_data_uri_encode(badge_image, "image/png"), - 'criteriaUrl': 'http://wikipedia.org/Awesome', - 'alignments': alignments, - 'issuer': self.issuer.entity_id - } - response = self.client.post('/v2/badgeclasses', data=example_badgeclass_props, format="json") - new_badgeclass = json.loads(response.content).get('result')[0] - self.assertEqual(alignments, new_badgeclass.get('alignments', None)) - - new_badgeclass_url = '/v2/badgeclasses/{badgeclass}'.format( - badgeclass=new_badgeclass['entityId']) - - # update alignments -- addition and deletion - reordered_alignments = [ - alignments[0], - alignments[1], - { - 'targetName': "added alignment", - 'targetUrl': "http://align.ment/4", - 'targetFramework': None, - 'targetCode': None, - 'targetDescription': None, - } - ] - new_badgeclass['alignments'] = reordered_alignments - new_badgeclass['description'] = 'refreshed description' - - response = self.client.put(new_badgeclass_url, new_badgeclass, format="json") - updated_badgeclass = json.loads(response.content).get('result')[0] - self.assertEqual(response.status_code, 200) - self.assertEqual(updated_badgeclass.get('alignments', None), reordered_alignments) - self.assertEqual(updated_badgeclass.get('description', None), 'refreshed description') - - # make sure response we got from PUT matches what we get from GET - response = self.client.get(new_badgeclass_url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.get('result')[0], updated_badgeclass) - - def test_can_create_and_update_badgeclass_with_tags_v1(self): - # create a badgeclass with tags - tags = ["first", "second", "third"] - new_badgeclass = self._create_badgeclass_for_issuer_authenticated(self.get_test_image_path(), tags=tags) - self.assertEqual(tags, new_badgeclass.get('tags', None)) - - new_badgeclass_url = '/v1/issuer/issuers/{issuer}/badges/{badgeclass}'.format( - issuer=self.issuer.entity_id, - badgeclass=new_badgeclass['slug'] - ) - - # update tags -- addition and deletion - reordered_tags = ["second", "third", "fourth"] - new_badgeclass['tags'] = reordered_tags - new_badgeclass['description'] = "new description" - - response = self.client.put(new_badgeclass_url, new_badgeclass, format="json") - updated_badgeclass = json.loads(response.content) - self.assertEqual(response.status_code, 200) - self.assertEqual(updated_badgeclass.get('tags', None), reordered_tags) - self.assertEqual(updated_badgeclass.get('description', None), "new description") - - # make sure response we got from PUT matches what we get from GET - response = self.client.get(new_badgeclass_url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, updated_badgeclass) - - def test_can_create_and_update_badgeclass_with_tags_v2(self): - # create a badgeclass with tags - tags = ["first", "second", "third"] - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - with open(self.get_test_image_path(), 'rb') as badge_image: - example_badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': "An awesome badge only awarded to awesome people or non-existent test entities", - 'image': self._base64_data_uri_encode(badge_image, "image/png"), - 'criteriaUrl': 'http://wikipedia.org/Awesome', - 'issuer': self.issuer.entity_id, - 'tags': tags, - } - response = self.client.post('/v2/badgeclasses', data=example_badgeclass_props, format="json") - new_badgeclass = response.data.get('result')[0] - self.assertEqual(tags, new_badgeclass.get('tags', None)) - - new_badgeclass_url = '/v2/badgeclasses/{badgeclass}'.format( - badgeclass=new_badgeclass['entityId'] - ) - - # update tags -- addition and deletion - reordered_tags = ["second", "third", "fourth"] - new_badgeclass['tags'] = reordered_tags - - response = self.client.put(new_badgeclass_url, new_badgeclass, format="json") - updated_badgeclass = response.data.get('result')[0] - self.assertEqual(response.status_code, 200) - self.assertEqual(updated_badgeclass.get('tags', None), reordered_tags) - - # make sure response we got from PUT matches what we get from GET - response = self.client.get(new_badgeclass_url) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.get('result')[0], updated_badgeclass) - - def test_can_create_badgeclass_with_extensions(self): - example_extensions = { - "extensions:originalCreator": { - "@context": "https://openbadgespec.org/extensions/originalCreatorExtension/context.json", - "type": ["Extension", "extensions:originalCreator"], - "url": "https://example.org/creator-organisation.json" - } - } - - badgeclass = self._create_badgeclass_with_v2(extensions=example_extensions) - self.verify_badgeclass_extensions(badgeclass, example_extensions) - - def test_can_update_badgeclass_with_extensions(self): - example_extensions = { - "extensions:originalCreator": { - "@context": "https://openbadgespec.org/extensions/originalCreatorExtension/context.json", - "type": ["Extension", "extensions:originalCreator"], - "url": "https://example.org/creator-organisation.json" - } - } - - # create a badgeclass with a single extension - badgeclass = self._create_badgeclass_with_v2(extensions=example_extensions) - self.verify_badgeclass_extensions(badgeclass, example_extensions) - - example_extensions['extensions:ApplyLink'] = { - "@context":"https://openbadgespec.org/extensions/applyLinkExtension/context.json", - "type": ["Extension", "extensions:ApplyLink"], - "url": "http://website.test/apply" - } - # update badgeclass and add an extension - badgeclass['extensions'] = example_extensions - response = self.client.put("/v2/badgeclasses/{badge}".format(badge=badgeclass.get('entityId')), data=badgeclass, format="json") - self.assertEqual(response.status_code, 200) - updated_badgeclass = response.data['result'][0] - - self.verify_badgeclass_extensions(updated_badgeclass, example_extensions) - - def verify_badgeclass_extensions(self, badgeclass, example_extensions): - self.assertDictEqual(badgeclass.get('extensions'), example_extensions) - - # extensions appear when GET from api - response = self.client.get("/v2/badgeclasses/{badge}".format(badge=badgeclass.get('entityId'))) - self.assertEqual(response.status_code, 200) - self.assertGreater(len(response.data.get('result', [])), 0) - fetched_badgeclass = response.data['result'][0] - self.assertDictEqual(fetched_badgeclass.get('extensions'), example_extensions) - - # extensions appear in public object - response = self.client.get("/public/badges/{badge}".format(badge=badgeclass.get('entityId'))) - self.assertEqual(response.status_code, 200) - public_json = json.loads(response.content) - for extension_name, extension_data in list(example_extensions.items()): - self.assertDictEqual(public_json.get(extension_name), extension_data) - - def test_null_description_not_serialized(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer, description=None) - self.assertIsNone(test_badgeclass.description) - - response = self.client.get('/v1/issuer/issuers/{issuer}/badges/{badgeclass}'.format( - issuer=test_issuer.entity_id, - badgeclass=test_badgeclass.entity_id)) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.get('description', None), "") - - response = self.client.get('/v2/badgeclasses/{badgeclass}'.format( - badgeclass=test_badgeclass.entity_id)) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.get('result', [])[0].get('description', None), "") - - response = self.client.get('/public/badges/{badgeclass}'.format( - badgeclass=test_badgeclass.entity_id)) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.get('description', None), "") - - def test_updating_issuer_cache(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user, name='Issuer 1') - test_badgeclass = self.setup_badgeclass(issuer=test_issuer, name='Badge 1', description='test') - - assertion_data = { - "email": "test@example.com", - "create_notification": False, - } - response = self.client.post('/v1/issuer/issuers/{issuer}/badges/{badge}/assertions'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), assertion_data) - self.assertEqual(response.status_code, 201) - assertion_slug = response.data.get('slug') - - updated_issuer_props = { - 'name': 'Issuer 1 updated', - 'description': 'test', - 'url': 'http://example.com/', - 'email': 'example@example.org' - } - response = self.client.put('/v1/issuer/issuers/{}'.format(test_issuer.entity_id), updated_issuer_props) - self.assertEqual(response.status_code, 200) - - badgeclass_props = { - 'name': 'Badge 1 updated', - 'description': 'test', - 'criteria': 'http://example.com', - } - - response = self.client.put( - '/v1/issuer/issuers/{issuer}/badges/{badge}'.format( - issuer=test_issuer.entity_id, - badge=test_badgeclass.entity_id - ), - dict(badgeclass_props, image='http://example.com/example.png') - ) - self.assertEqual(response.status_code, 200) - - response = self.client.get('/public/assertions/{}.json?expand=badge&expand=badge.issuer'.format(assertion_slug)) - self.assertEqual(response.data['badge']['issuer']['name'], 'Issuer 1 updated') - - def test_can_create_badgeclass_with_serverAdmin_token(self): - issuer_owner = self.setup_user(authenticate=False) - admin_user = self.setup_user(authenticate=True, verified=True, token_scope='rw:serverAdmin') - test_issuer = self.setup_issuer(owner=issuer_owner) - - badgeclass_data = { - 'name': 'Test Badge', - 'description': "A testing badge", - 'image': self.get_test_image_base64(), - 'criteriaUrl': 'http://wikipedia.org/Awesome', - 'issuer': test_issuer.entity_id, - } - - response = self.client.post('/v2/badgeclasses', data=badgeclass_data, format="json") - self.assertEqual(response.status_code, 201) - - entity_id = response.data['result'][0]['entityId'] - badgeclass_data['name'] = 'Test Badge Version 2 Electric Badge-a-loo' - response = self.client.put('/v2/badgeclasses/{}'.format(entity_id), data=badgeclass_data, format="json") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['result'][0]['name'], badgeclass_data['name']) - - def test_create_badgeclass_without_criteria_v2_bad_request(self): - image_path = self.get_test_image_path() - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - with open(image_path, 'rb') as badge_image: - badgeclass_props = { - 'name': 'Badge of Slugs', - 'description': "Recognizes slimy learners with a penchant for lettuce", - 'image': self._base64_data_uri_encode(badge_image, 'image/png') - } - response = self.client.post( - '/v2/issuers/{}/badgeclasses'.format(test_issuer.entity_id), - badgeclass_props, format='json' - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.data["validationErrors"][0], "A criteria_url or criteria_test is required.") - - def test_create_badgeclass_without_criteria_v1_bad_request(self): - image_path = self.get_test_image_path() - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - with open(image_path, 'rb') as badge_image: - example_badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': "An awesome badge only awarded to awesome people or non-existent test entities", - 'image': self._base64_data_uri_encode(badge_image, 'image/png') - } - response = self.client.post( - '/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - data=example_badgeclass_props, - format="json" - ) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.data[0], - "One or both of the criteria_text and criteria_url fields must be provided") - - def test_update_badgeclass_with_null_criteria_v2_bad_request(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - - badgeclass_data = { - 'name': 'Test Badge', - 'description': "A testing badge", - 'image': self.get_test_image_base64(), - 'criteriaUrl': 'http://wikipedia.org/Awesome', - 'issuer': test_issuer.entity_id, - } - - # Create Badge without narrative - response = self.client.post('/v2/badgeclasses', data=badgeclass_data, format="json") - modifyData = response.data['result'][0] - modifyData["criteriaUrl"] = None - - # Try to remove url - response1 = self.client.put("/v2/badgeclasses/{badge}".format(badge=modifyData.get('entityId')), - data=modifyData, - format="json") - self.assertEqual(response1.status_code, 400) - self.assertEqual(response1.data['validationErrors'][0], - 'Changes cannot be made that would leave both criteria_url and criteria_text blank.') - - # Verify that setting one will allow removing the other. - modifyData['criteriaNarrative'] = 'Description' - response2 = self.client.put("/v2/badgeclasses/{badge}".format(badge=modifyData.get('entityId')), - data=modifyData, - format="json") - self.assertEqual(response2.status_code, 200) - - # Try to remove narrative - modifyData['criteriaNarrative'] = '' - response3 = self.client.put("/v2/badgeclasses/{badge}".format(badge=modifyData.get('entityId')), - data=modifyData, - format="json") - self.assertEqual(response3.status_code, 400) - self.assertEqual(response3.data["fieldErrors"]['criteriaNarrative'][0], 'This field may not be blank.') - - # Verify that not sending either won't prevent the changes sent - del modifyData["criteriaNarrative"] - del modifyData["criteriaUrl"] - - response4 = self.client.put("/v2/badgeclasses/{badge}".format(badge=modifyData.get('entityId')), - data=modifyData, - format="json") - - self.assertEqual(response4.status_code, 200) - returnedBadge4 = response4.data["result"][0] - self.assertEqual(returnedBadge4.get("criteriaNarrative", None), 'Description') - self.assertEqual(returnedBadge4.get("criteriaUrl", None), None) - - def test_update_badgeclass_with_null_criteria_v1_bad_request(self): - image_path = self.get_test_image_path() - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.issuer = test_issuer - with open(image_path, 'rb') as badge_image: - example_badgeclass_props = { - 'name': 'Badge of Awesome', - 'description': "An awesome badge only awarded to awesome people or non-existent test entities", - 'image': self._base64_data_uri_encode(badge_image, 'image/png'), - 'criteria': 'http://wikipedia.org/Awesome', - } - - # Create Badge without narrative - response = self.client.post( - '/v1/issuer/issuers/{slug}/badges'.format(slug=test_issuer.entity_id), - data=example_badgeclass_props, - format="json") - - modifyData = response.data - modifyData["criteria_url"] = None - - response1 = self.client.put( - '/v1/issuer/issuers/{issuer}/badges/{badge}'.format( - issuer=test_issuer.entity_id, - badge=modifyData['slug'] - ), - data=modifyData, - format="json", - ) - - self.assertEqual(response1.status_code, 400) - self.assertEqual(response1.data[0], - 'Changes cannot be made that would leave both criteria_url and criteria_text blank.') - - # Verify that setting one will allow removing the other. - modifyData['criteria_text'] = 'Description' - response2 = self.client.put( - '/v1/issuer/issuers/{issuer}/badges/{badge}'.format( - issuer=test_issuer.entity_id, - badge=modifyData['slug'] - ), - data=modifyData, - format="json", - ) - - self.assertEqual(response2.status_code, 200) - - # Try to remove narrative - modifyData['criteria_text'] = '' - response3 = self.client.put( - '/v1/issuer/issuers/{issuer}/badges/{badge}'.format( - issuer=test_issuer.entity_id, - badge=modifyData['slug'] - ), - data=modifyData, - format="json", - ) - - self.assertEqual(response3.status_code, 400) - self.assertEqual(response3.data[0], - 'Changes cannot be made that would leave both criteria_url and criteria_text blank.') - - # Verify that not sending either won't prevent the changes sent - del modifyData["criteria_text"] - del modifyData["criteria_url"] - - response4 = self.client.put( - '/v1/issuer/issuers/{issuer}/badges/{badge}'.format( - issuer=test_issuer.entity_id, - badge=modifyData['slug'] - ), - data=modifyData, - format="json", - ) - - self.assertEqual(response4.status_code, 200) - self.assertEqual(response4.data.get("criteria_text", None), 'Description') - self.assertEqual(response4.data.get("criteria_url", None), None) - - -class BadgeClassesChangedApplicationTests(SetupIssuerHelper, BadgrTestCase): - def test_application_can_get_changed_badgeclasses(self): - issuer_user = self.setup_user(authenticate=True, verified=True, token_scope='rw:serverAdmin') - test_issuer = self.setup_issuer(owner=issuer_user) - test_badgeclass = self.setup_badgeclass( - issuer=test_issuer, name='Badge Class 1', description='test') - test_badgeclass2 = self.setup_badgeclass( - issuer=test_issuer, name='Badge Class 2', description='test') - test_badgeclass3 = self.setup_badgeclass( - issuer=test_issuer, name='Badge Class 3', description='test') - - other_user = self.setup_user(authenticate=False, verified=True) - other_issuer = self.setup_issuer(owner=other_user) - - other_badgeclass = self.setup_badgeclass( - issuer=other_issuer, name='Badge Class 1', description='test') - other_badgeclass2 = self.setup_badgeclass( - issuer=other_issuer, name='Badge Class 2', description='test') - other_badgeclass3 = self.setup_badgeclass( - issuer=other_issuer, name='Badge Class 3', description='test') - - response = self.client.get('/v2/badgeclasses/changed') - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 3) - timestamp = response.data['timestamp'] - - response = self.client.get('/v2/badgeclasses/changed?since={}'.format(quote_plus(timestamp))) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 0) - - test_badgeclass.name = 'Badge Class 1 updated' - test_badgeclass.save() - - response = self.client.get('/v2/badgeclasses/changed?since={}'.format(quote_plus(timestamp))) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) diff --git a/apps/issuer/tests/test_blacklist.py b/apps/issuer/tests/test_blacklist.py deleted file mode 100644 index 5a4430222..000000000 --- a/apps/issuer/tests/test_blacklist.py +++ /dev/null @@ -1,48 +0,0 @@ -# encoding: utf-8 - - -import json -import responses - -from django.core.exceptions import ValidationError -from django.test import override_settings - -from mainsite.blacklist import generate_hash -from mainsite.tests import BadgrTestCase, SetupIssuerHelper - - -SETTINGS_OVERRIDE = { - 'BADGR_BLACKLIST_API_KEY': 'blacklistkeyexample123', - 'BADGR_BLACKLIST_QUERY_ENDPOINT': 'https://example_blacklist.com/query' -} - - -def _generate_blacklist_url(identifier, id_type='email'): - hashed_identifier = generate_hash(id_type, identifier) - return "{endpoint}?id={recipient_id_hash}".format( - endpoint=SETTINGS_OVERRIDE['BADGR_BLACKLIST_QUERY_ENDPOINT'], - recipient_id_hash=hashed_identifier - ) - - -def _generate_blacklist_response_body(identifier, id_type='email'): - return [{"id": generate_hash(id_type, identifier)}] - - -class AssertionBlacklistTests(SetupIssuerHelper, BadgrTestCase): - def setUp(self): - super(AssertionBlacklistTests, self).setUp() - self.issuer_owner = self.setup_user() - self.issuer = self.setup_issuer(owner=self.issuer_owner) - self.badgeclass = self.setup_badgeclass(issuer=self.issuer) - - @override_settings(**SETTINGS_OVERRIDE) - @responses.activate - def test_blacklist_presence_blocks_award(self): - email = 'testblacklisteduser@example.com' - responses.add( - responses.GET, _generate_blacklist_url(email), json=_generate_blacklist_response_body(email) - ) - - with self.assertRaises(ValidationError): - self.badgeclass.issue(email) diff --git a/apps/issuer/tests/test_issuer.py b/apps/issuer/tests/test_issuer.py deleted file mode 100644 index 1b9f0cbd4..000000000 --- a/apps/issuer/tests/test_issuer.py +++ /dev/null @@ -1,921 +0,0 @@ -# encoding: utf-8 - - -import os.path -from urllib.parse import quote_plus - -import os -import base64 - -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group, Permission -from django.core.cache import cache -from django.core.exceptions import ValidationError -from django.core.files.images import get_image_dimensions -from django.test import override_settings -from django.urls import reverse -from django.utils import timezone -from oauth2_provider.models import Application - -from badgeuser.models import CachedEmailAddress, UserRecipientIdentifier -from issuer.models import Issuer, BadgeClass, IssuerStaff -from mainsite.models import ApplicationInfo, AccessTokenProxy, BadgrApp -from mainsite.tests import SetupOAuth2ApplicationHelper -from mainsite.tests.base import BadgrTestCase, SetupIssuerHelper -from mainsite.utils import set_url_query_params - - -@override_settings(TOKEN_BACKOFF_MAXIMUM_SECONDS=0) # disable token backoff -class IssuerTests(SetupOAuth2ApplicationHelper, SetupIssuerHelper, BadgrTestCase): - example_issuer_props = { - 'name': 'Awesome Issuer', - 'description': 'An issuer of awe-inspiring credentials', - 'url': 'http://example.com', - 'email': 'contact@example.org' - } - - def setUp(self): - cache.clear() - super(IssuerTests, self).setUp() - - def test_cant_create_issuer_if_unauthenticated(self): - response = self.client.post('/v1/issuer/issuers', self.example_issuer_props) - self.assertIn(response.status_code, (401, 403)) - - def test_v2_issuers_badgeclasses_can_paginate(self): - NUM_BADGE_CLASSES = 5 - PAGINATE = 2 - - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclasses = list(self.setup_badgeclasses(issuer=test_issuer, how_many=NUM_BADGE_CLASSES)) - - response = self.client.get('/v2/issuers/{slug}/badgeclasses?num={num}'.format( - slug=test_issuer.entity_id, - num=PAGINATE) - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(test_badgeclasses), NUM_BADGE_CLASSES) - self.assertEqual(len(response.data.get('result')), PAGINATE) - - def test_create_issuer_if_authenticated(self): - test_user = self.setup_user(authenticate=True) - issuer_email = CachedEmailAddress.objects.create( - user=test_user, email=self.example_issuer_props['email'], verified=True) - - response = self.client.post('/v1/issuer/issuers', self.example_issuer_props) - self.assertEqual(response.status_code, 201) - - # assert that name, description, url, etc are set properly in response badge object - badge_object = response.data.get('json') - self.assertEqual(badge_object['url'], self.example_issuer_props['url']) - self.assertEqual(badge_object['name'], self.example_issuer_props['name']) - self.assertEqual(badge_object['description'], self.example_issuer_props['description']) - self.assertEqual(badge_object['email'], self.example_issuer_props['email']) - self.assertIsNotNone(badge_object.get('id')) - self.assertIsNotNone(badge_object.get('@context')) - slug = response.data.get('slug') - - issuer = Issuer.cached.get(entity_id=slug) - badgrapp = issuer.cached_badgrapp # warm the cache - # assert that the issuer was published to and fetched from the cache - with self.assertNumQueries(0): - response = self.client.get('/v1/issuer/issuers/{}'.format(slug)) - self.assertEqual(response.status_code, 200) - - def test_cant_create_issuer_if_authenticated_with_unconfirmed_email(self): - self.setup_user(authenticate=True, verified=False) - - response = self.client.post('/v1/issuer/issuers', self.example_issuer_props) - self.assertEqual(response.status_code, 403) - - def _create_issuer_with_image_and_test_resizing(self, image_path, desired_width=400, desired_height=400): - test_user = self.setup_user(authenticate=True) - issuer_email = CachedEmailAddress.objects.create( - user=test_user, email=self.example_issuer_props['email'], verified=True) - - with open(image_path, 'rb') as badge_image: - issuer_fields_with_image = self.example_issuer_props.copy() - issuer_fields_with_image['image'] = badge_image - - response = self.client.post('/v1/issuer/issuers', issuer_fields_with_image, format='multipart') - self.assertEqual(response.status_code, 201) - - self.assertIn('slug', response.data) - issuer_slug = response.data.get('slug') - new_issuer = Issuer.objects.get(entity_id=issuer_slug) - - image_width, image_height = get_image_dimensions(new_issuer.image.file) - self.assertEqual(image_width, desired_width) - self.assertEqual(image_height, desired_height) - - def test_create_issuer_image_500x300_resizes_to_400x400(self): - image_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'testfiles', '500x300.png') - self._create_issuer_with_image_and_test_resizing(image_path) - - def test_create_issuer_image_450x450_resizes_to_400x400(self): - image_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'testfiles', '450x450.png') - self._create_issuer_with_image_and_test_resizing(image_path) - - def test_create_issuer_image_300x300_stays_300x300(self): - image_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'testfiles', '300x300.png') - self._create_issuer_with_image_and_test_resizing(image_path, 300, 300) - - def test_get_issuer_detail_unauthenticated_fails(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_issuer.created_by = None - test_issuer.source_url = 'https://example.com/issuer1' - test_issuer.save() - IssuerStaff.objects.last().delete() - - response = self.client.get('/v2/issuers/{}'.format(test_issuer.entity_id), headers={ - 'Accept': 'text/html' - }) - self.assertEqual(response.status_code, 401) - - def test_issuer_update_resizes_image(self): - desired_width = desired_height = 400 - - test_user = self.setup_user(authenticate=True) - image_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'testfiles', '500x300.png') - image = open(image_path, 'rb') - encoded = 'data:image/png;base64,' + base64.b64encode(image.read()).decode() - - issuer_email = CachedEmailAddress.objects.create( - user=test_user, email=self.example_issuer_props['email'], verified=True) - - issuer_fields_with_image = self.example_issuer_props.copy() - issuer_fields_with_image['image'] = encoded - - response = self.client.post('/v1/issuer/issuers', issuer_fields_with_image) - self.assertEqual(response.status_code, 201) - response_slug = response.data.get('slug') - new_issuer = Issuer.objects.get(entity_id=response_slug) - - image_width, image_height = get_image_dimensions(new_issuer.image.file) - self.assertEqual(image_width, desired_width) - self.assertEqual(image_height, desired_height) - - # Update the issuer with the original 500x300 image - issuer_fields_with_image['image'] = encoded - - update_response = self.client.put('/v1/issuer/issuers/{}'.format(response_slug), issuer_fields_with_image) - self.assertEqual(update_response.status_code, 200) - update_response_slug = update_response.data.get('slug') - updated_issuer = Issuer.objects.get(entity_id=update_response_slug) - - update_image_width, update_image_height = get_image_dimensions(updated_issuer.image.file) - self.assertEqual(update_image_width, desired_width) - self.assertEqual(update_image_height, desired_height) - - def test_update_issuer_does_not_clear_badgrDomain(self): - badgr_app_two = BadgrApp.objects.create(cors='somethingelse.example.com', name='two') - test_user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=test_user) - issuer.badgrapp = self.badgr_app - issuer.save() - - first_response = self.client.get('/v2/issuers/{}'.format(issuer.entity_id)) - self.assertEqual(first_response.status_code, 200) - issuer_data = first_response.data['result'][0] - put_data = { - 'name': 'Test Issuer Updated', - 'url': issuer_data['url'], - 'email': issuer_data['email'], - 'badgrDomain': issuer_data['badgrDomain'] - } - response = self.client.put('/v2/issuers/{}'.format(issuer.entity_id), put_data) - self.assertEqual(response.status_code, 200) - self.assertEqual( - put_data['badgrDomain'], response.data['result'][0]['badgrDomain'], "badgrDomain has not been harmed" - ) - put_data['badgrDomain'] = 'somethingelse.example.com' - response = self.client.put('/v2/issuers/{}'.format(issuer.entity_id), put_data) - self.assertEqual(response.status_code, 200) - self.assertEqual( - issuer_data['badgrDomain'], response.data['result'][0]['badgrDomain'], "badgrDomain has not been changed" - ) - - - def test_put_issuer_detail_with_the_option_to_exclude_staff(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user, name='1') - issuer_data = { - 'name': 'Testy MctestAlot', - 'description': test_issuer.description, - 'email': test_user.email, - 'url': 'http://example.com' - } - - staff_get_url = '/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id) - url_and_include_staff_false = set_url_query_params('/v2/issuers/{}'.format(test_issuer.entity_id), include_staff='false') - url_and_include_staff_true = set_url_query_params('/v2/issuers/{}'.format(test_issuer.entity_id), include_staff='true') - url_no_query_param = '/v2/issuers/{}'.format(test_issuer.entity_id) - - staff_response = self.client.get(staff_get_url) - intial_staff = staff_response.data[0]['user'] - - # put - response1 = self.client.put(url_and_include_staff_false, data=issuer_data) - self.assertEqual(response1.status_code, 200) - self.assertFalse(response1.data['result'][0].get('staff')) - - response2 = self.client.put(url_no_query_param, data=issuer_data) - self.assertEqual(response2.status_code, 200) - self.assertTrue(response2.data['result'][0].get('staff')) - - response3 = self.client.put(url_and_include_staff_true, data=issuer_data) - self.assertEqual(response3.status_code, 200) - self.assertTrue(response3.data['result'][0].get('staff')) - - # get - get_response1 = self.client.get(url_and_include_staff_false) - self.assertEqual(get_response1.status_code, 200) - self.assertFalse(get_response1.data['result'][0].get('staff')) - - get_response2 = self.client.get(url_no_query_param) - self.assertEqual(get_response2.status_code, 200) - self.assertTrue(get_response2.data['result'][0].get('staff')) - - get_response3 = self.client.get(url_and_include_staff_true) - self.assertEqual(get_response3.status_code, 200) - self.assertTrue(get_response3.data['result'][0].get('staff')) - - # test that staff hasn't been modified - staff_response2 = self.client.get(staff_get_url) - final_staff = staff_response2.data[0]['user'] - self.assertTrue(intial_staff == final_staff) - - - def test_can_update_issuer_if_authenticated(self): - test_user = self.setup_user(authenticate=True) - - original_issuer_props = { - 'name': 'Test Issuer Name', - 'description': 'Test issuer description', - 'url': 'http://example.com/1', - 'email': 'example1@example.org' - } - - issuer_email_1 = CachedEmailAddress.objects.create( - user=test_user, email=original_issuer_props['email'], verified=True) - - response = self.client.post('/v1/issuer/issuers', original_issuer_props) - response_slug = response.data.get('slug') - - updated_issuer_props = { - 'name': 'Test Issuer Name 2', - 'description': 'Test issuer description 2', - 'url': 'http://example.com/2', - 'email': 'example2@example.org' - } - - issuer_email_2 = CachedEmailAddress.objects.create( - user=test_user, email=updated_issuer_props['email'], verified=True) - - response = self.client.put('/v1/issuer/issuers/{}'.format(response_slug), updated_issuer_props) - self.assertEqual(response.status_code, 200) - - self.assertEqual(response.data['url'], updated_issuer_props['url']) - self.assertEqual(response.data['name'], updated_issuer_props['name']) - self.assertEqual(response.data['description'], updated_issuer_props['description']) - self.assertEqual(response.data['email'], updated_issuer_props['email']) - - # test that subsequent GETs include the updated data - response = self.client.get('/v2/issuers') - response_issuers = response.data['result'] - self.assertEqual(len(response_issuers), 1) - self.assertEqual(response_issuers[0]['url'], updated_issuer_props['url']) - self.assertEqual(response_issuers[0]['name'], updated_issuer_props['name']) - self.assertEqual(response_issuers[0]['description'], updated_issuer_props['description']) - self.assertEqual(response_issuers[0]['email'], updated_issuer_props['email']) - - def test_get_empty_issuer_editors_set(self): - test_user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=test_user) - - response = self.client.get('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id)) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 1) # Assert that there is just a single owner - - def test_add_user_to_issuer_editors_set_by_email(self): - test_user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=test_user) - - other_user = self.setup_user(authenticate=False) - - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id), { - 'action': 'add', - 'email': other_user.primary_email, - 'role': 'editor' - }) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 2) # Assert that there is now one editor - - def test_add_user_to_issuer_editors_set_by_email_with_issueradmin_scope(self): - test_user = self.setup_user(authenticate=True, token_scope='rw:serverAdmin') - test_owner = self.setup_user(authenticate=False) - issuer = self.setup_issuer(owner=test_owner) - other_user = self.setup_user(authenticate=False) - - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id), { - 'action': 'add', - 'email': other_user.primary_email, - 'role': 'editor' - }) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 2) # Assert that there is now one editor - - def test_cannot_add_user_by_unverified_email(self): - test_user = self.setup_user(authenticate=True) - self.client.force_authenticate(user=test_user) - - user_to_update = self.setup_user() - new_email = CachedEmailAddress.objects.create( - user=user_to_update, verified=False, email='newemailsonew@example.com') - - post_response = self.client.post( - '/v1/issuer/issuers/test-issuer/staff', - {'action': 'add', 'email': new_email.email, 'role': 'editor'} - ) - - self.assertEqual(post_response.status_code, 404) - - def test_add_user_to_issuer_editors_set_too_many_methods(self): - """ - Enter a username or email. Both are not allowed. - """ - test_user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=test_user) - - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id), { - 'action': 'add', - 'email': 'test3@example.com', - 'username': 'test3', - 'role': 'editor' - }) - self.assertEqual(response.status_code, 400) - - def test_add_user_to_issuer_editors_set_missing_identifier(self): - test_user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=test_user) - - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id), { - 'action': 'add', - 'role': 'editor' - }) - self.assertEqual(response.status_code, 404) - self.assertEqual(response.data, 'User not found. please provide a valid email address, username, url or telephone identifier.') - - def test_bad_action_issuer_editors_set(self): - test_user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=test_user) - - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id), { - 'action': 'DO THE HOKEY POKEY', - 'username': 'test2', - 'role': 'editor' - }) - self.assertEqual(response.status_code, 400) - - def test_add_nonexistent_user_to_issuer_editors_set(self): - test_user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=test_user) - - erroneous_username = 'wronguser' - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id), { - 'action': 'add', - 'username': erroneous_username, - 'role': 'editor' - }) - self.assertContains(response, "User not found.".format(erroneous_username), status_code=404) - - def test_add_user_to_nonexistent_issuer_editors_set(self): - test_user = self.setup_user(authenticate=True) - erroneous_issuer_slug = 'wrongissuer' - response = self.client.post( - '/v1/issuer/issuers/{slug}/staff'.format(slug=erroneous_issuer_slug), - {'action': 'add', 'username': 'test2', 'role': 'editor'} - ) - self.assertEqual(response.status_code, 404) - - def test_add_remove_user_with_issuer_staff_set(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - other_user = self.setup_user(authenticate=False) - - self.assertEqual(len(test_issuer.staff.all()), 1) - - first_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'add', - 'email': other_user.primary_email - }) - self.assertEqual(first_response.status_code, 200) - self.assertEqual(len(test_issuer.staff.all()), 2) - - second_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'remove', - 'email': other_user.primary_email - }) - self.assertEqual(second_response.status_code, 200) - self.assertEqual(len(test_issuer.staff.all()), 1) - - def test_modify_staff_user_role(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.assertEqual(test_issuer.staff.count(), 1) - - other_user = self.setup_user(authenticate=False) - - first_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'add', - 'email': other_user.primary_email - }) - self.assertEqual(first_response.status_code, 200) - self.assertEqual(len(test_issuer.staff.all()), 2) - self.assertEqual(test_issuer.editors.count(), 1) - self.assertEqual(test_issuer.staff.count(), 2) - - second_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'email': other_user.primary_email, - 'role': 'editor' - }) - self.assertEqual(second_response.status_code, 200) - staff = test_issuer.staff.all() - self.assertEqual(test_issuer.editors.count(), 2) - - def test_modify_the_staff_role_of_a_user_by_url_recipient_identifier(self): - owner = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=owner) - self.assertEqual(test_issuer.staff.count(), 1) - - # Create a user who has only a url identifier: - recipient_url = "http://example.com" - user = self.setup_user(first_name='user_first', last_name='user_last',) - UserRecipientIdentifier.objects.create(identifier=recipient_url, user=user, verified=True) - IssuerStaff.objects.get_or_create(user=user, issuer=test_issuer, defaults={'role': IssuerStaff.ROLE_STAFF}) - - # Attempting to modify the staff role of a user with good recipient identifier succeeds - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'url': recipient_url, - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(response.status_code, 200) - - # Attempting to modify the staff role of a user with bad recipient identifier fails - second_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'url': "http://badIdentifier.com", - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(second_response.status_code, 404) - - # Attempting to modify the staff role of an unverified user fails - user_unverified = UserRecipientIdentifier.objects.get(user_id=user.id) - user_unverified.verified = False - user_unverified.save() - unverified_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'url': recipient_url, - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(unverified_response.status_code, 404) - - - def test_add_a_user_staff_role_by_url_recipient_identifier(self): - owner = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=owner) - self.assertEqual(test_issuer.staff.count(), 1) - - recipient_url = "http://example.com" - user = self.setup_user(first_name='user_first', last_name='user_last',) - UserRecipientIdentifier.objects.create(identifier=recipient_url, user=user, verified=True) - - # Attempting to add a staff role of a user with good recipient identifier succeeds - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'add', - 'url': recipient_url, - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(response.status_code, 200) - - # Attempting to add a staff user role that is not verified fails - recipient_url = "http://example2.com" - user_unverified = self.setup_user(first_name='user_unverified_first', last_name='user_unverified_last',) - UserRecipientIdentifier.objects.create(identifier=recipient_url, user=user_unverified, verified=False) - - unverified_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'url': recipient_url, - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(unverified_response.status_code, 404) - - def test_add_a_user_staff_role_by_telephone_recipient_identifier(self): - owner = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=owner) - self.assertEqual(test_issuer.staff.count(), 1) - - recipient_phone = "+15415551111" - user = self.setup_user(first_name='user_first', last_name='user_last',) - UserRecipientIdentifier.objects.create( - identifier=recipient_phone, - user=user, - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, - verified=True) - - # Attempting to add a staff role of a user with good recipient identifier succeeds - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'add', - 'telephone': recipient_phone, - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(response.status_code, 200) - - # Attempting to add a staff user role that is not verified fails - recipient_phone = "+15415552222" - user_unverified = self.setup_user(first_name='user_unverified_first', last_name='user_unverified_last',) - UserRecipientIdentifier.objects.create( - identifier=recipient_phone, - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, - user=user_unverified, - verified=False) - - unverified_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'telephone': recipient_phone, - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(unverified_response.status_code, 404) - - - def test_modify_the_staff_role_of_a_user_by_telephone_recipient_identifier(self): - owner = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=owner) - self.assertEqual(test_issuer.staff.count(), 1) - - # Create a user who has only a telephone identifier: - recipient_phone = "15415551111" - user = self.setup_user(first_name='user_first', last_name='user_last',) - with self.assertRaises(ValidationError): - UserRecipientIdentifier.objects.create( - identifier=recipient_phone, - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, - user=user, - verified=True - ) - - # Now with more feeling, and the right phone number format - recipient_phone = "+15415551111" - telephone_id = UserRecipientIdentifier.objects.create( - identifier=recipient_phone, - type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, - user=user, - verified=True - ) - IssuerStaff.objects.get_or_create(user=user, issuer=test_issuer, defaults={'role': IssuerStaff.ROLE_STAFF}) - - # Attempting to modify the staff role of a user with good recipient identifier succeeds - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'telephone': recipient_phone, - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(response.status_code, 200) - - # Attempting to modify the staff role of a user with bad recipient identifier fails - second_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'telephone': "+12225554444", - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(second_response.status_code, 404) - - # Attempting to modify the staff role of an unverified user fails - user_unverified = UserRecipientIdentifier.objects.get(user_id=user.id) - user_unverified.verified = False - user_unverified.save() - unverified_response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'modify', - 'telephone': recipient_phone, - 'role': IssuerStaff.ROLE_EDITOR - }) - self.assertEqual(unverified_response.status_code, 404) - - - def test_cannot_modify_or_remove_self(self): - """ - The authenticated issuer owner cannot modify their own role or remove themself from the list. - """ - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - self.client.force_authenticate(user=test_user) - post_response = self.client.post( - '/v1/issuer/issuers/{}/staff'.format(test_issuer.entity_id), - {'action': 'remove', 'email': test_user.email} - ) - - self.assertEqual(post_response.status_code, 400) - - post_response = self.client.post( - '/v1/issuer/issuers/{}/staff'.format(test_issuer.entity_id), - {'action': 'modify', 'email': test_user.email, 'role': 'staff'} - ) - - self.assertEqual(post_response.status_code, 400) - - def test_delete_issuer_successfully(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - response = self.client.delete('/v1/issuer/issuers/{slug}'.format(slug=test_issuer.entity_id), {}) - self.assertEqual(response.status_code, 204) - - def test_editor_cannot_delete_issuer(self): - test_user = self.setup_user(authenticate=True) - test_owner = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_owner) - IssuerStaff.objects.create(user=test_user, issuer=test_issuer, role=IssuerStaff.ROLE_EDITOR) - - response = self.client.delete('/v1/issuer/issuers/{slug}'.format(slug=test_issuer.entity_id), {}) - self.assertEqual(response.status_code, 404) - - def test_delete_issuer_with_unissued_badgeclass_successfully(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - test_badgeclass = BadgeClass(name="Deletable Badge", issuer=test_issuer) - test_badgeclass.save() - - response = self.client.delete('/v1/issuer/issuers/{slug}'.format(slug=test_issuer.entity_id), {}) - self.assertEqual(response.status_code, 204) - - def test_cant_delete_issuer_with_issued_badge(self): - test_user = self.setup_user(authenticate=True) - test_issuer = self.setup_issuer(owner=test_user) - - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - test_badgeclass.issue(recipient_id='new-bage-recipient@email.test') - - response = self.client.delete('/v1/issuer/issuers/{slug}'.format(slug=test_issuer.entity_id), {}) - self.assertEqual(response.status_code, 400) - - def test_cant_create_issuer_with_unverified_email_v1(self): - test_user = self.setup_user(authenticate=True) - new_issuer_props = { - 'name': 'Test Issuer Name', - 'description': 'Test issuer description', - 'url': 'http://example.com/1', - 'email': 'example1@example.org' - } - - response = self.client.post('/v1/issuer/issuers', new_issuer_props) - self.assertEqual(response.status_code, 400) - self.assertEqual( - response.data[0], - 'Issuer email must be one of your verified addresses. Add this email to your profile and try again.') - - def test_cant_create_issuer_with_unverified_email_v2(self): - test_user = self.setup_user(authenticate=True) - new_issuer_props = { - 'name': 'Test Issuer Name', - 'description': 'Test issuer description', - 'url': 'http://example.com/1', - 'email': 'example1@example.org' - } - - response = self.client.post('/v2/issuers', new_issuer_props) - self.assertEqual(response.status_code, 400) - self.assertEqual( - response.data['validationErrors'][0], - 'Issuer email must be one of your verified addresses. Add this email to your profile and try again.') - - def test_trusted_user_can_create_issuer_with_unverified_email(self): - test_user = self.setup_user(authenticate=True) - application = Application.objects.create(user=test_user) - app_info = ApplicationInfo.objects.create(application=application, trust_email_verification=True) - - new_issuer_props = { - 'name': 'Test Issuer Name', - 'description': 'Test issuer description', - 'url': 'http://example.com/1', - 'email': 'an+unknown+email@badgr.test' - } - - response = self.client.post('/v2/issuers', new_issuer_props) - self.assertEqual(response.status_code, 201) - - response = self.client.post('/v1/issuer/issuers', new_issuer_props) - self.assertEqual(response.status_code, 201) - - def test_issuer_staff_serialization(self): - test_user = self.setup_user(authenticate=True) - - # issuer_email = CachedEmailAddress.objects.create( - # user=test_user, email=self.example_issuer_props['email'], verified=True) - - email_staff= self.setup_user() - - url_staff = self.setup_user(email="", create_email_address=False) - url_for_staff = UserRecipientIdentifier.objects.create(type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, - identifier='http://example.com', - user=url_staff, verified=True) - url_for_staff2 = UserRecipientIdentifier.objects.create(type=UserRecipientIdentifier.IDENTIFIER_TYPE_URL, - identifier='http://example2.com', - user=url_staff, verified=False) - - phone_staff = self.setup_user(email="", create_email_address=False) - phone_for_staff = UserRecipientIdentifier.objects.create(type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, - identifier='+5555555555', - user=phone_staff, verified=True) - phone_for_staff2 = UserRecipientIdentifier.objects.create(type=UserRecipientIdentifier.IDENTIFIER_TYPE_TELEPHONE, - identifier='+5555555556', - user=phone_staff, verified=False) - - issuer = self.setup_issuer(owner=test_user) - - #add url user as staff - response1 = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id), { - 'action': 'add', - 'username': url_staff.username, - 'role': 'staff' - }) - self.assertEqual(response1.status_code, 200) - - #add phone user as editor - response2 = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=issuer.entity_id), { - 'action': 'add', - 'username': phone_staff.username, - 'role': 'editor' - }) - self.assertEqual(response2.status_code, 200) - - #get issuer object and check staff serialization - response = self.client.get('/v2/issuers') - response_issuers = response.data['result'] - self.assertEqual(len(response_issuers), 1) - our_issuer = response_issuers[0] - self.assertEqual(len(our_issuer['staff']), 3) - for staff_user in our_issuer['staff']: - if (staff_user['role'] == IssuerStaff.ROLE_OWNER): - #check emails - self.assertEqual(len(staff_user['userProfile']['url']), 0) - self.assertEqual(len(staff_user['userProfile']['telephone']), 0) - self.assertEqual(len(staff_user['userProfile']['emails']), 1) - elif (staff_user['role'] == IssuerStaff.ROLE_EDITOR): - #check phone - self.assertEqual(len(staff_user['userProfile']['url']), 0) - self.assertEqual(len(staff_user['userProfile']['telephone']), 1) - self.assertEqual(staff_user['userProfile']['telephone'][0], phone_for_staff.identifier) - self.assertFalse(phone_for_staff2.identifier in staff_user['userProfile']['telephone']) - self.assertEqual(len(staff_user['userProfile']['emails']), 0) - else: - self.assertEqual(len(staff_user['userProfile']['url']), 1) - self.assertEqual(staff_user['userProfile']['url'][0], url_for_staff.identifier) - self.assertFalse(url_for_staff2.identifier in staff_user['userProfile']['url']) - self.assertEqual(len(staff_user['userProfile']['telephone']), 0) - self.assertEqual(len(staff_user['userProfile']['emails']), 0) - - def test_can_edit_staff_with_oauth(self): - issuer_owner = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=issuer_owner) - - user = self.setup_user(email='lilstudent@example.com') - client_app_user = self.setup_user(email='clientApp@example.com', token_scope='rw:issuerOwner:*') - app = Application.objects.create( - client_id='clientApp-authcode', client_secret='testsecret', authorization_grant_type='authorization-code', - user=client_app_user) - ApplicationInfo.objects.create(application=app, allowed_scopes='rw:issuerOwner*') - t = AccessTokenProxy.objects.create( - user=client_app_user, scope='rw:issuerOwner:' + test_issuer.entity_id, - expires=timezone.now() + timezone.timedelta(hours=1), - token='123', application=app - ) - self.client.credentials(HTTP_AUTHORIZATION="Bearer 123") - badgr_user_email = 'user@email.test' - badgr_user = self.setup_user(email=badgr_user_email, authenticate=False) - - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=test_issuer.entity_id), { - 'action': 'add', - 'email': badgr_user_email, - 'role': 'staff' - }) - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(IssuerStaff.objects.filter(user=badgr_user)), 1) - - # Verify token cannot be used to modify some other issuer - other_issuer = self.setup_issuer(owner=issuer_owner) - - response = self.client.post('/v1/issuer/issuers/{slug}/staff'.format(slug=other_issuer.entity_id), { - 'action': 'add', - 'email': badgr_user_email, - 'role': 'staff' - }) - self.assertEqual(response.status_code, 404) - - def test_issuer_no_image_returns_null(self): - test_user = self.setup_user(authenticate=True) - CachedEmailAddress.objects.create( - user=test_user, email=self.example_issuer_props['email'], verified=True) - response = self.client.post('/v1/issuer/issuers', self.example_issuer_props) - self.assertEqual(response.data['image'], None) - response2 = self.client.get('/v1/issuer/issuers') - self.assertEqual(response2.data[0]['image'], None) - - response3 = self.client.post('/v2/issuers', self.example_issuer_props) - self.assertEqual(response3.data['result'][0]['image'], None) - response3a = self.client.get('/v1/issuer/issuers') - self.assertEqual(response3a.data[0]['image'], None) - self.assertEqual(response3a.data[1]['image'], None) - response4 = self.client.get('/v2/issuers') - self.assertEqual(response4.data['result'][0]['image'], None) - self.assertEqual(response4.data['result'][1]['image'], None) - - -class IssuersChangedApplicationTests(SetupIssuerHelper, BadgrTestCase): - def test_application_can_get_changed_issuers(self): - issuer_user = self.setup_user(authenticate=True, verified=True, token_scope='rw:serverAdmin') - test_issuer = self.setup_issuer(owner=issuer_user) - test_issuer2 = self.setup_issuer(owner=issuer_user) - test_issuer3 = self.setup_issuer(owner=issuer_user) - - other_user = self.setup_user(authenticate=False, verified=True) - other_issuer = self.setup_issuer(owner=other_user) - other_issuer2 = self.setup_issuer(owner=other_user) - other_issuer3 = self.setup_issuer(owner=other_user) - - response = self.client.get('/v2/issuers/changed') - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 3) - timestamp = response.data['timestamp'] - - response = self.client.get('/v2/issuers/changed?since={}'.format(quote_plus(timestamp))) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 0) - - test_issuer.name = 'Issuer updated' - test_issuer.save() - - response = self.client.get('/v2/issuers/changed?since={}'.format(quote_plus(timestamp))) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - -class ApprovedIssuersOnlyTests(SetupIssuerHelper, BadgrTestCase): - example_issuer_props = { - 'name': 'Awesome Issuer', - 'description': 'An issuer of awe-inspiring credentials', - 'url': 'http://example.com', - 'email': 'contact@example.org' - } - - @override_settings(BADGR_APPROVED_ISSUERS_ONLY=True) - def test_unapproved_user_cannot_create_issuer(self): - test_user = self.setup_user(authenticate=True) - issuer_email = CachedEmailAddress.objects.create( - user=test_user, email=self.example_issuer_props['email'], verified=True) - - response = self.client.post('/v2/issuers', self.example_issuer_props) - self.assertEqual(response.status_code, 404) - - @override_settings(BADGR_APPROVED_ISSUERS_ONLY=True) - def test_approved_user_can_create_issuer(self): - test_user = self.setup_user(authenticate=True) - issuer_email = CachedEmailAddress.objects.create( - user=test_user, email=self.example_issuer_props['email'], verified=True) - - permission = Permission.objects.get_by_natural_key('add_issuer', 'issuer', 'issuer') - group = Group.objects.create(name='test issuers') - group.permissions.add(permission) - group.user_set.add(test_user) - - response = self.client.post('/v2/issuers', self.example_issuer_props) - self.assertEqual(response.status_code, 201) - - -class UserDeletionTests(SetupOAuth2ApplicationHelper, SetupIssuerHelper, BadgrTestCase): - example_issuer_props = { - 'name': 'Awesome Issuer', - 'description': 'An issuer of awe-inspiring credentials', - 'url': 'http://example.com', - 'email': 'contact@example.org' - } - - def setUp(self): - cache.clear() - super(UserDeletionTests, self).setUp() - - def test_created_issuer_not_deleted_on_user_delete(self): - test_user = self.setup_user('first@example.com') - second_user = self.setup_user(email='second@example.com') - issuer = self.setup_issuer(owner=test_user) - entity_id = issuer.entity_id - IssuerStaff.objects.create(issuer=issuer, user=second_user, role=IssuerStaff.ROLE_OWNER) - IssuerStaff.objects.filter(issuer=issuer).first().delete() - test_user.delete() - self.assertTrue(Issuer.objects.filter(entity_id=entity_id).exists()) diff --git a/apps/issuer/tests/test_issuer_admin_api.py b/apps/issuer/tests/test_issuer_admin_api.py deleted file mode 100644 index 758486716..000000000 --- a/apps/issuer/tests/test_issuer_admin_api.py +++ /dev/null @@ -1,148 +0,0 @@ -# encoding: utf-8 - - - -import os -from django.core.files.images import get_image_dimensions -from django.urls import reverse -from django.utils import timezone -from oauth2_provider.models import Application - -from badgeuser.models import TermsVersion -from issuer.models import Issuer, BadgeClass, IssuerStaff -from mainsite.models import ApplicationInfo, AccessTokenProxy, BadgrApp -from mainsite.tests import SetupOAuth2ApplicationHelper -from mainsite.tests.base import BadgrTestCase, SetupIssuerHelper - - -class IssuerAdminTests(BadgrTestCase, SetupIssuerHelper, SetupOAuth2ApplicationHelper): - def setUp(self): - super(IssuerAdminTests, self).setUp() - - self.client_app_user = self.setup_user(first_name='app', email='app@example.com', token_scope='rw:serverAdmin') - self.app = self.setup_oauth2_application( - client_id='clientApp-authcode', client_secret='testsecret', authorization_grant_type='authorization-code', - user=self.client_app_user, allowed_scopes='rw:serverAdmin' - ) - - self.t = AccessTokenProxy.objects.create( - user=self.client_app_user, scope='rw:serverAdmin', expires=timezone.now() + timezone.timedelta(hours=1), - token='123', application=self.app - ) - - self.client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(self.t.token)) - - self.latest_terms = TermsVersion.objects.create(version=3, short_description="test terms") - - self.issuer_owner_user = self.setup_user(email='some_cool_user@example.com', verified=True) - self.issuer = self.setup_issuer(owner=self.issuer_owner_user) - - def test_can_post_and_put_issuer_with_badgrDomain(self): - badgrapp = BadgrApp.objects.create( - is_default=False, - name='test 2', - cors='example.com', - signup_redirect='https://example.com/signup' - ) - owner_user = self.setup_user(first_name='owner', email='contact@example.org', authenticate=False) - - issuer_data = { - 'name': 'Awesome Issuer', - 'description': 'An issuer of awe-inspiring credentials', - 'url': 'http://example.com', - 'email': 'contact@example.org', - 'badgrDomain': 'example.com', - 'createdBy': owner_user.entity_id - } - response = self.client.post('/v2/issuers', issuer_data) - self.assertEqual(response.status_code, 201) - - issuer = Issuer.objects.get(entity_id=response.data['result'][0]['entityId']) - self.assertEqual(response.data['result'][0]['badgrDomain'], issuer_data['badgrDomain']) - self.assertEqual(issuer.badgrapp_id, badgrapp.id) - self.assertEqual(issuer.created_by_id, owner_user.id) - - issuer_data['badgrDomain'] = 'localhost:8000' # The other BadgrApp on the system - response = self.client.put('/v2/issuers/{}'.format(issuer.entity_id), issuer_data) - self.assertEqual(response.status_code, 200) - issuer = Issuer.objects.get(entity_id=response.data['result'][0]['entityId']) - self.assertEqual(response.data['result'][0]['badgrDomain'], issuer_data['badgrDomain']) - self.assertEqual(issuer.badgrapp_id, self.badgr_app.id) # The BadgrApp has changed. - - def test_cannot_post_issuer_with_invalid_badgrDomain(self): - issuer_data = { - 'name': 'Awesome Issuer', - 'description': 'An issuer of awe-inspiring credentials', - 'url': 'http://example.com', - 'email': 'contact@example.org', - 'badgrDomain': 'example.com' # does not exist - } - response = self.client.post('/v2/issuers', issuer_data) - self.assertEqual(response.status_code, 400) - - def test_cannot_post_issuer_with_invalid_createdBy(self): - issuer_data = { - 'name': 'Awesome Issuer', - 'description': 'An issuer of awe-inspiring credentials', - 'url': 'http://example.com', - 'email': 'contact@example.org', - 'createdBy': 'DOESNOTEXISTMAN' - } - response = self.client.post('/v2/issuers', issuer_data) - self.assertEqual(response.status_code, 400) - - def test_can_get_issuer_detail(self): - response = self.client.get('/v2/issuers/{}'.format(self.issuer.entity_id)) - self.assertEqual(response.status_code, 200) - - def test_can_get_issuer_badgeclasses_list(self): - response = self.client.get('/v2/issuers/{}/badgeclasses'.format(self.issuer.entity_id)) - self.assertEqual(response.status_code, 200) - - def test_can_get_badgeclass_detail(self): - badgeclass = self.setup_badgeclass(issuer=self.issuer, name='Example', criteria_text='Just earn it') - response = self.client.get('/v2/badgeclasses/{}'.format(badgeclass.entity_id)) - self.assertEqual(response.status_code, 200) - - def test_can_get_assertion_lists(self): - badgeclass = self.setup_badgeclass(issuer=self.issuer, name='Example', criteria_text='Just earn it') - assertion = badgeclass.issue(recipient_id='someone@somewhere.com') - - # can get badgeclass-specific assertion list - response = self.client.get('/v2/badgeclasses/{}/assertions'.format(badgeclass.entity_id)) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - # can get issuer assertion list - response = self.client.get('/v2/issuers/{}/assertions'.format(self.issuer.entity_id)) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - def can_post_new_assertion(self): - badgeclass = self.setup_badgeclass(issuer=self.issuer, name='Example', criteria_text='Just earn it') - award_data = { - 'recipient': {'identity': 'test@example.com'} - } - response = self.client.post( - '/v2/badgeclasses/{}/assertions'.format(badgeclass.entity_id), award_data, format='json' - ) - self.assertEqual(response.status_code, 201) - - def can_post_staff_v1(self): - staffer_user = self.setup_user(email='some_cool_staffer@example.com', verified=True) - staff_action = { - 'action': 'add', - 'email': 'some_cool_staffer@example.com', - 'role': 'editor' - } - staff_url = '/v1/issuer/issuers/{}/staff'.format(self.issuer.entity_id) - response = self.client.post(staff_url, staff_action, format='json') - self.assertEqual(response.status_code, 200) - - response = self.client.get(staff_url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data), 2) - - response = self.client.get('/v2/issuers/{}'.format(self.issuer.entity_id)) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result'][0]['staff']), 2) \ No newline at end of file diff --git a/apps/issuer/tests/test_managers.py b/apps/issuer/tests/test_managers.py deleted file mode 100644 index 39196f073..000000000 --- a/apps/issuer/tests/test_managers.py +++ /dev/null @@ -1,76 +0,0 @@ -# encoding: utf-8 - -import os -import responses -import mock - -from backpack.tests.utils import CURRENT_DIRECTORY as BACKPACK_TESTS_DIRECTORY -from issuer.models import Issuer, BadgeClass, BadgeInstance, BadgeInstanceEvidence - -from mainsite.tests import BadgrTestCase, Ob2Generators, SetupIssuerHelper - - -def _register_image_mock(url): - responses.add( - responses.GET, url, - body=open(os.path.join(BACKPACK_TESTS_DIRECTORY, 'testfiles/unbaked_image.png'), 'rb').read(), - status=200, content_type='image/png' - ) - - -class BadgeInstanceAndEvidenceManagerTests(SetupIssuerHelper, BadgrTestCase, Ob2Generators): - def setUp(self): - super(BadgeInstanceAndEvidenceManagerTests, self).setUp() - self.local_owner_user = self.setup_user(authenticate=False) - self.local_issuer = self.setup_issuer(owner=self.local_owner_user) - random_unrelated_badgeclass = self.setup_badgeclass(issuer=self.local_issuer) - - @responses.activate - def test_update_from_ob2_basic(self): - recipient = self.setup_user(email='recipient1@example.org') - - issuer_ob2 = self.generate_issuer_obo2() - badgeclass_ob2 = self.generate_badgeclass_ob2() - assertion_ob2 = self.generate_assertion_ob2() - _register_image_mock(badgeclass_ob2['image']) - - issuer_image = Issuer.objects.image_from_ob2(issuer_ob2) - badgeclass_image = BadgeClass.objects.image_from_ob2(badgeclass_ob2) - badgeinstance_image = BadgeInstance.objects.image_from_ob2(badgeclass_image, assertion_ob2) - - issuer, _ = Issuer.objects.get_or_create_from_ob2(issuer_ob2, image=issuer_image) - badgeclass, _ = BadgeClass.objects.get_or_create_from_ob2(issuer, badgeclass_ob2, image=badgeclass_image) - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - badgeinstance, _ = BadgeInstance.objects.get_or_create_from_ob2( - badgeclass, assertion_ob2, recipient_identifier='test@example.com', image=badgeinstance_image - ) - self.assertTrue(badgeinstance.badgeclass, badgeclass) - - # Add evidence item that didn't exist at initial import - assertion_ob2['evidence'] = {'id': 'https://example.com/evidence/1'} - updated, _ = BadgeInstance.objects.update_from_ob2( - badgeclass, assertion_ob2, recipient_identifier=badgeinstance.recipient_identifier - ) - self.assertEqual(updated.pk, badgeinstance.pk) - self.assertEqual(BadgeInstanceEvidence.objects.count(), 1) - self.assertEqual(updated.cached_evidence().count(), 1) - - # That evidence item has now been deleted, make sure we stay up to date there. - del assertion_ob2['evidence'] - updated, _ = BadgeInstance.objects.update_from_ob2( - badgeclass, assertion_ob2, recipient_identifier=badgeinstance.recipient_identifier - ) - self.assertEqual(BadgeInstanceEvidence.objects.count(), 0) - - # An evidence url gets added as a string in Open Badges 1.x style - assertion_ob2['evidence'] = 'https://example.com/evidence/2' - updated, _ = BadgeInstance.objects.update_from_ob2( - badgeclass, assertion_ob2, recipient_identifier=badgeinstance.recipient_identifier - ) - self.assertEqual(BadgeInstanceEvidence.objects.count(), 1) - evidence_item = BadgeInstanceEvidence.objects.first() - self.assertEqual(evidence_item.badgeinstance_id, badgeinstance.pk) - self.assertEqual(evidence_item.evidence_url, assertion_ob2['evidence']) - self.assertIsNone(evidence_item.narrative) - diff --git a/apps/issuer/tests/test_oauth2_tokens.py b/apps/issuer/tests/test_oauth2_tokens.py deleted file mode 100644 index 4721440cf..000000000 --- a/apps/issuer/tests/test_oauth2_tokens.py +++ /dev/null @@ -1,105 +0,0 @@ -# encoding: utf-8 - - -import json - -from django.urls import reverse - -from mainsite.models import AccessTokenProxy -from mainsite.tests import SetupIssuerHelper, BadgrTestCase, SetupOAuth2ApplicationHelper - - -class PublicAPITests(SetupOAuth2ApplicationHelper, SetupIssuerHelper, BadgrTestCase): - def test_client_credentials_token(self): - # define oauth application - application_user = self.setup_user(authenticate=False) - application = self.setup_oauth2_application( - user=application_user, - allowed_scopes="rw:issuer rw:backpack rw:profile", - trust_email=True) - - # retrieve an rw:issuer token - response = self.client.post('/o/token', data=dict( - grant_type="client_credentials", - client_id=application.client_id, - client_secret=application.client_secret, - scope="rw:issuer" - )) - self.assertEqual(response.status_code, 200) - - - def test_can_get_issuer_scoped_token(self): - # create an oauth2 application - application_user = self.setup_user(authenticate=False) - application = self.setup_oauth2_application(user=application_user, allowed_scopes="rw:issuer rw:issuer:*") - - badgr_user = self.setup_user(authenticate=False) - issuer = self.setup_issuer(owner=badgr_user) - - # application can retrieve a token - response = self.client.post(reverse('oauth2_provider_token'), data=dict( - grant_type=application.authorization_grant_type.replace('-','_'), - client_id=application.client_id, - client_secret=application.client_secret, - scope='rw:issuer:{}'.format(issuer.entity_id) - )) - self.assertEqual(response.status_code, 200) - - def test_can_get_batch_issuer_tokens(self): - - # create an oauth2 application - application_user = self.setup_user(email='service@email.test', authenticate=False) - application = self.setup_oauth2_application(user=application_user, allowed_scopes='rw:issuer') - - # application can retrieve a token - response = self.client.post(reverse('oauth2_provider_token'), data=dict( - grant_type=application.authorization_grant_type.replace('-','_'), - client_id=application.client_id, - client_secret=application.client_secret, - scope='rw:issuer' - )) - self.assertEqual(response.status_code, 200) - result = response.json() - # result should contain token_type and access_token - self.assertIn('token_type', result) - self.assertIn('access_token', result) - auth_headers = { - 'Authorization': "{type} {token}".format(type=result.get("token_type"), token=result.get("access_token")) - } - - # create a badgr user who owns several issuers - badgr_user = self.setup_user(email='user@email.test', authenticate=False) - issuers = [self.setup_issuer(owner=badgr_user, name="issuer #{}".format(i)) for i in range(1, 4)] - issuer_ids = [i.entity_id for i in issuers] - - # get rw:issuer:* tokens for the issuers - response = self.client.post(reverse('v2_api_tokens_list'), data=dict( - issuers=issuer_ids - ), format="json", **auth_headers) - self.assertEqual(response.status_code, 200) - result = json.loads(response.content) - self.assertDictEqual(result.get('status'), dict(success=True, description="ok")) - - # we should receive tokens for each issuer - issuer_tokens = {r.get('issuer'): r.get('token') for r in result.get('result')} - self.assertEqual(set(issuer_tokens.keys()), set(issuer_ids)) - - access_tokens = [AccessTokenProxy.objects.get(token=t) for t in list(issuer_tokens.values())] - self.assertEqual(len(access_tokens), len(issuer_tokens)) - - # we should be able to use tokens to access the issuer - for issuer_id, issuer_token in list(issuer_tokens.items()): - response = self.client.get(reverse('v2_api_issuer_detail', kwargs=dict(entity_id=issuer_id)), - format="json", - Authorization="Bearer {}".format(issuer_token) - ) - self.assertEqual(response.status_code, 200) - - # ensure that issuer tokens didnt change and still have same expiration - for access_token in access_tokens: - updated_access_token = AccessTokenProxy.objects.get(pk=access_token.pk) - self.assertEqual(updated_access_token.token, access_token.token) - self.assertEqual(updated_access_token.expires, access_token.expires) - - - diff --git a/apps/issuer/tests/test_public.py b/apps/issuer/tests/test_public.py deleted file mode 100644 index c5d1a2d3d..000000000 --- a/apps/issuer/tests/test_public.py +++ /dev/null @@ -1,512 +0,0 @@ -# encoding: utf-8 - -import io -import json -import urllib.request, urllib.parse, urllib.error -import mock -import os -from PIL import Image -import responses - -from django.core.files.base import ContentFile -from django.urls import reverse -from openbadges.verifier.openbadges_context import OPENBADGES_CONTEXT_V1_URI, OPENBADGES_CONTEXT_V2_URI, \ - OPENBADGES_CONTEXT_V2_DICT -from openbadges_bakery import unbake - -from backpack.models import BackpackCollection, BackpackCollectionBadgeInstance -from backpack.tests.utils import setup_resources, setup_basic_1_0, CURRENT_DIRECTORY -from badgeuser.models import CachedEmailAddress -from issuer.models import BadgeClass, BadgeInstance, Issuer -from issuer.utils import OBI_VERSION_CONTEXT_IRIS, UNVERSIONED_BAKED_VERSION -from mainsite.models import BadgrApp -from mainsite.tests import BadgrTestCase, Ob2Generators, SetupIssuerHelper -from mainsite.utils import OriginSetting - - -class PublicAPITests(SetupIssuerHelper, BadgrTestCase): - """ - Tests the ability of an anonymous user to GET one public badge object - """ - def test_get_issuer_object(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - - with self.assertNumQueries(0): - response = self.client.get('/public/issuers/{}'.format(test_issuer.entity_id)) - self.assertEqual(response.status_code, 200) - - def test_get_issuer_object_that_doesnt_exist(self): - fake_entity_id = 'imaginary-issuer' - with self.assertRaises(Issuer.DoesNotExist): - Issuer.objects.get(entity_id=fake_entity_id) - - # a db miss will generate 2 queries, lookup by entity_id and lookup by slug - with self.assertNumQueries(2): - response = self.client.get('/public/issuers/imaginary-issuer') - self.assertEqual(response.status_code, 404) - - def test_get_badgeclass_image_with_redirect(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - with self.assertNumQueries(0): - response = self.client.get('/public/badges/{}/image'.format(test_badgeclass.entity_id)) - self.assertEqual(response.status_code, 302) - - def test_get_badgeclass_image_wide(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - - # Get badgeclass public page as a bot - headers = {'HTTP_USER_AGENT': 'Twitterbot/1.0'} - response = self.client.get('/public/badges/{}'.format(test_badgeclass.entity_id), **headers) - # should have received an html stub with og meta tags - self.assertTrue(response.get('content-type').startswith('text/html')) - self.assertContains(response, 'fmt=wide') # ensure the image is linked properly with the wide format - - with self.assertNumQueries(0): - response = self.client.get('/public/badges/{}/image?type=png&fmt=wide'.format(test_badgeclass.entity_id)) - self.assertEqual(response.status_code, 302) - - response = self.client.get(response.url) # Get the actual image URL from media storage - self.assertEqual(response.status_code, 200) - self.assertTrue(response.get('content-type').startswith('image/png')) - imagefile = ContentFile(b''.join(response.streaming_content)) - image = Image.open(imagefile) - self.assertEqual(image.width, 764) - self.assertEqual(image.height, 400) - - def test_get_assertion_image_with_redirect(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - with self.assertNumQueries(0): - response = self.client.get('/public/assertions/{}/image'.format(assertion.entity_id), follow=False) - self.assertEqual(response.status_code, 302) - - def test_get_assertion_json_explicit(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - with self.assertNumQueries(1): - response = self.client.get('/public/assertions/{}'.format(assertion.entity_id), - **{'HTTP_ACCEPT': 'application/json'}) - self.assertEqual(response.status_code, 200) - - # Will raise error if response is not JSON. - content = json.loads(response.content) - - self.assertEqual(content['type'], 'Assertion') - - def test_get_assertion_json_implicit(self): - """ Make sure we serve JSON by default if there is a missing Accept header. """ - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - with self.assertNumQueries(1): - response = self.client.get('/public/assertions/{}'.format(assertion.entity_id)) - self.assertEqual(response.status_code, 200) - - # Will raise error if response is not JSON. - content = json.loads(response.content) - - self.assertEqual(content['type'], 'Assertion') - - def test_get_for_revoked_assertion_and_deleted_badge_class_returns_404(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - response = self.client.get('/public/assertions/{}?action=download'.format(assertion.entity_id)) - self.assertEqual(response.status_code, 200) - - assertion.revoked = True - assertion.revocation_reason = 'For testing' - assertion.save() - - response2 = self.client.get('/public/assertions/{}?action=download'.format(assertion.entity_id)) - self.assertEqual(response2.status_code, 200) - - test_badgeclass.delete() - response3 = self.client.get('/public/assertions/{}?action=download'.format(assertion.entity_id)) - self.assertEqual(response3.status_code, 404) - - - - def test_scrapers_get_html_stub(self): - test_user_email = 'test.user@email.test' - - test_user = self.setup_user(authenticate=False, email=test_user_email) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id=test_user_email) - assertion.pending # prepopulate cache - - # create a shared collection - test_collection = BackpackCollection.objects.create(created_by=test_user, name='Test Collection', description="testing") - BackpackCollectionBadgeInstance.objects.create(collection=test_collection, badgeinstance=assertion, badgeuser=test_user) # add assertion to collection - test_collection.published = True - test_collection.save() - self.assertIsNotNone(test_collection.share_url) - - testcase_headers = [ - # bots/scrapers should get an html stub with opengraph tags - {'HTTP_USER_AGENT': 'LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/3.1 +http://www.linkedin.com)'}, - {'HTTP_USER_AGENT': 'Twitterbot/1.0'}, - {'HTTP_USER_AGENT': 'facebook'}, - {'HTTP_USER_AGENT': 'Facebot'}, - {'HTTP_USER_AGENT': 'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)'}, - ] - - # check that public assertion pages get og stubs served to bots - for headers in testcase_headers: - with self.assertNumQueries(0): - response = self.client.get('/public/assertions/{}'.format(assertion.entity_id), **headers) - self.assertEqual(response.status_code, 200) - - # should have received an html stub with og meta tags - self.assertTrue(response.get('content-type').startswith('text/html')) - self.assertContains(response, ''.format(assertion.public_url), html=True) - png_image_url = "{}{}?type=png".format( - OriginSetting.HTTP, - reverse('badgeclass_image', kwargs={'entity_id': assertion.cached_badgeclass.entity_id}) - ) - self.assertContains(response, ''.format(test_collection.share_url), html=True) - - def test_scraping_empty_backpack_share_returns_html_with_no_image_based_tags(self): - test_user_email = 'test.user@email.test' - test_user = self.setup_user(authenticate=False, email=test_user_email) - # empty backpack - test_collection = BackpackCollection.objects.create(created_by=test_user, name='Test Collection', description="testing") - test_collection.published = True - test_collection.save() - - testcase_headers = [ - # bots/scrapers should get an html stub with opengraph tags - {'HTTP_USER_AGENT': 'LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/3.1 +http://www.linkedin.com)'}, - {'HTTP_USER_AGENT': 'Twitterbot/1.0'}, - {'HTTP_USER_AGENT': 'facebook'}, - {'HTTP_USER_AGENT': 'Facebot'}, - {'HTTP_USER_AGENT': 'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)'}, - ] - - for headers in testcase_headers: - with self.assertNumQueries(0): - response = self.client.get(test_collection.share_url, **headers) - self.assertEqual(response.status_code, 200) - self.assertNotContains(response, 'og:image', html=True) - self.assertNotContains(response, '', html=True) - - def test_public_collection_json(self): - test_user_email = 'test.user@email.test' - - test_user = self.setup_user(authenticate=False, email=test_user_email) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id=test_user_email) - assertion.pending # prepopulate cache - - # create a shared collection - test_collection = BackpackCollection.objects.create(created_by=test_user, name='Test Collection', - description="testing") - BackpackCollectionBadgeInstance.objects.create(collection=test_collection, badgeinstance=assertion, - badgeuser=test_user) # add assertion to collection - test_collection.published = True - test_collection.save() - self.assertIsNotNone(test_collection.share_url) - - response = self.client.get( - '/public/collections/{}'.format(test_collection.share_hash), header={'Accept': 'application/json'} - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['entityId'], test_collection.entity_id) - - def test_get_assertion_html_redirects_to_frontend(self): - badgr_app = BadgrApp( - cors='frontend.ui', is_default=True, signup_redirect='http://frontend.ui/signup', public_pages_redirect='http://frontend.ui/public' - ) - badgr_app.save() - - badgr_app_two = BadgrApp(cors='stuff.com', is_default=False, signup_redirect='http://stuff.com/signup', public_pages_redirect='http://stuff.com/public') - badgr_app_two.save() - - redirect_accepts = [ - {'HTTP_ACCEPT': 'application/xml,application/xhtml+xml,text/html;q=0.9, text/plain;q=0.8,image/png,*/*;q=0.5'}, # safari/chrome - {'HTTP_ACCEPT': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'}, # firefox - {'HTTP_ACCEPT': 'text/html, application/xhtml+xml, image/jxr, */*'}, # edge - ] - json_accepts = [ - {'HTTP_ACCEPT': '*/*'}, # curl - {}, # no accept header - ] - - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_issuer.badgrapp = badgr_app_two - test_issuer.save() - test_issuer.cached_badgrapp # publish badgrapp to cache - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - for headers in redirect_accepts: - with self.assertNumQueries(1): - response = self.client.get('/public/assertions/{}'.format(assertion.entity_id), **headers) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.get('Location'), 'http://stuff.com/public/assertions/{}'.format(assertion.entity_id)) - - for headers in json_accepts: - with self.assertNumQueries(1): - response = self.client.get('/public/assertions/{}'.format(assertion.entity_id), **headers) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.get('Content-Type'), "application/ld+json") - - @responses.activate - def test_uploaded_badge_returns_coerced_json(self): - setup_basic_1_0() - setup_resources([ - {'url': OPENBADGES_CONTEXT_V1_URI, 'filename': 'v1_context.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)} - ]) - self.setup_user(email='test@example.com', authenticate=True) - - post_input = { - 'url': 'http://a.com/instance' - } - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - response = self.client.post( - '/v1/earner/badges', post_input - ) - self.assertEqual(response.status_code, 201) - uploaded_badge = response.data - assertion_entityid = uploaded_badge.get('id') - assertion_url = '/public/assertions/{}?v=2_0'.format(assertion_entityid) - response = self.client.get(assertion_url) - self.assertEqual(response.status_code, 200) - coerced_assertion = response.data - assertion = BadgeInstance.objects.get(entity_id=assertion_entityid) - self.assertDictEqual(coerced_assertion, assertion.get_json(obi_version="2_0")) - # We should not change the declared jsonld ID of the requested object - self.assertEqual(coerced_assertion.get('id'), 'http://a.com/instance') - - def verify_baked_image_response(self, assertion, response, obi_version, **kwargs): - self.assertEqual(response.status_code, 200) - baked_image = io.BytesIO(b"".join(response.streaming_content)) - baked_json = unbake(baked_image) - baked_metadata = json.loads(baked_json) - assertion_metadata = assertion.get_json(obi_version=obi_version, **kwargs) - self.assertDictEqual(baked_metadata, assertion_metadata) - - def test_get_versioned_baked_images(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - response = self.client.get('/public/assertions/{}/image'.format(assertion.entity_id), follow=True) - self.verify_baked_image_response(assertion, response, obi_version=UNVERSIONED_BAKED_VERSION) - - for obi_version in list(OBI_VERSION_CONTEXT_IRIS.keys()): - response = self.client.get('/public/assertions/{assertion}/baked?v={version}'.format( - assertion=assertion.entity_id, - version=obi_version - ), follow=True) - - if obi_version == UNVERSIONED_BAKED_VERSION: - # current_obi_versions aren't re-baked expanded - self.verify_baked_image_response(assertion, response, obi_version=obi_version) - else: - self.verify_baked_image_response( - assertion, - response, - obi_version=obi_version, - expand_badgeclass=True, - expand_issuer=True, - include_extra=True - ) - - def test_cache_updated_on_issuer_update(self): - original_badgeclass_name = 'Original Badgeclass Name' - new_badgeclass_name = 'new badgeclass name' - - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer, name=original_badgeclass_name) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - response = self.client.get('/public/assertions/{}?expand=badge'.format(assertion.entity_id), Accept='application/json') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.get('badge', {}).get('name', None), original_badgeclass_name) - - test_badgeclass.name = new_badgeclass_name - test_badgeclass.save() - - response = self.client.get('/public/assertions/{}?expand=badge'.format(assertion.entity_id), Accept='application/json') - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data.get('badge', {}).get('name', None), new_badgeclass_name) - - -class PendingAssertionsPublicAPITests(SetupIssuerHelper, BadgrTestCase): - @responses.activate - def test_pending_assertion_returns_404(self): - setup_resources([ - {'url': 'http://a.com/assertion-embedded1', 'filename': '2_0_assertion_embedded_badgeclass.json'}, - {'url': OPENBADGES_CONTEXT_V2_URI, 'response_body': json.dumps(OPENBADGES_CONTEXT_V2_DICT)}, - {'url': 'http://a.com/badgeclass_image', 'filename': "unbaked_image.png", 'mode': 'rb'}, - ]) - unverified_email = 'test@example.com' - test_user = self.setup_user(email='verified@example.com', authenticate=True) - CachedEmailAddress.objects.add_email(test_user, unverified_email) - post_input = {"url": "http://a.com/assertion-embedded1"} - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', - new=lambda a, b: False): - post_resp = self.client.post('/v2/backpack/import', post_input, - format='json') - assertion = BadgeInstance.objects.first() - - self.client.logout() - get_resp = self.client.get('/public/assertions/{}'.format(assertion.entity_id)) - self.assertEqual(get_resp.status_code, 404) - - -class OEmbedTests(SetupIssuerHelper, BadgrTestCase): - """ - oEmbed url schemes: - - {HTTP_ORIGIN}/public/assertions/{entity_id}/embed - - oEmbed API endpoint: - - {HTTP_ORIGIN}/public/oembed?format=json&url={HTTP_ORIGIN}/public/assertions/{entity_id} - - - """ - - def test_get_oembed_json(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - # with self.assertNumQueries(0): - response = self.client.get('/public/oembed?format=json&url={}'.format(urllib.parse.quote(assertion.jsonld_id))) - self.assertEqual(response.status_code, 200) - - def test_endpoint_handles_malformed_urls(self): - response = self.client.get('/public/oembed?format=json&url={}'.format(urllib.parse.quote('ralph the dog'))) - self.assertEqual(response.status_code, 404) - - def test_auto_discovery_of_api_endpoint(self): - test_user = self.setup_user(authenticate=False) - test_issuer = self.setup_issuer(owner=test_user) - test_badgeclass = self.setup_badgeclass(issuer=test_issuer) - assertion = test_badgeclass.issue(recipient_id='new.recipient@email.test') - - response = self.client.get( - '/public/assertions/{}'.format(assertion.entity_id), - HTTP_USER_AGENT='Mozilla/5.0 (compatible; Embedly/0.2; +http://support.embed.ly/)', - HTTP_ACCEPT='text/html,application/xml,application/xhtml+xml;q=0.9,text//plain;q0.8,image/png,*/*;q=0.5' - - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'oembed') - - -class PublicReverificationTests(SetupIssuerHelper, BadgrTestCase, Ob2Generators): - - @responses.activate - @mock.patch('issuer.public_api.openbadges.verify') - def test_can_reverify_basic(self, mock_verify): - issuer_ob2 = self.generate_issuer_obo2() - badgeclass_ob2 = self.generate_badgeclass_ob2() - assertion_ob2 = self.generate_assertion_ob2(source_url='https://example.com/assertion/1') - - responses.add(responses.GET, - badgeclass_ob2['image'], - body=open(os.path.join(CURRENT_DIRECTORY, 'testfiles/unbaked_image.png'), 'rb').read(), - status=200, content_type='image/png') - - issuer_image = Issuer.objects.image_from_ob2(issuer_ob2) - badgeclass_image = BadgeClass.objects.image_from_ob2(badgeclass_ob2) - badgeinstance_image = BadgeInstance.objects.image_from_ob2(badgeclass_image, assertion_ob2) - - issuer, _ = Issuer.objects.get_or_create_from_ob2(issuer_ob2, image=issuer_image) - badgeclass, _ = BadgeClass.objects.get_or_create_from_ob2(issuer, badgeclass_ob2, image=badgeclass_image) - - revocation_reason = "Manually revoked by Issuer" - - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', new=lambda a, b: False): - assertion, _ = BadgeInstance.objects.get_or_create_from_ob2( - badgeclass, - assertion_ob2, - recipient_identifier='test@example.com', - image=badgeinstance_image - ) - - mock_verify.return_value = { - 'report': self.generate_ob2_report(validationSubject=assertion_ob2['id']), - 'graph': [assertion_ob2, badgeclass_ob2, issuer_ob2], - 'input': self.generate_ob2_input(input_type='url', value=assertion_ob2['id']) - } - - # openbadges.verify response (Not Revoked) - verify_response = self.client.post('/public/verify', data={'entity_id': assertion.entity_id}) - self.assertFalse('revoked' in verify_response.data['result'][0]) - self.assertFalse('revocationReason' in verify_response.data['result'][0]) - # badge instance is not revoked - self.assertFalse(BadgeInstance.objects.last().revoked) - - # openbadges.verify response (Revoked) - mock_verify.return_value = { - 'graph': [ - {**assertion_ob2, "revocationReason": revocation_reason, "revoked": True}, badgeclass_ob2, issuer_ob2 - ] - } - - # call badge check with this assertion (revoked) - revoked_response = self.client.post('/public/verify', data={'entity_id': assertion.entity_id}) - # response contains revocation flag and revocation reason - self.assertTrue(revoked_response.data['result'][0]['revoked']) - self.assertEqual(revoked_response.data['result'][0]['revocationReason'], revocation_reason) - # badge instance is revoked, revocation_reason has not changed - self.assertTrue(BadgeInstance.objects.last().revoked) - self.assertEqual(BadgeInstance.objects.last().revocation_reason, revocation_reason) - - # attempt to revalidate a revoked badge. - second_revoked_response = self.client.post('/public/verify', data={'entity_id': assertion.entity_id}) - # still revoked - self.assertTrue(second_revoked_response.data['result'][0]['revoked']) - # returns original revoked response - self.assertEqual(second_revoked_response.data['result'][0]['revocationReason'], revocation_reason) - # badge instance is revoked, revocation_reason has not changed - self.assertTrue(BadgeInstance.objects.last().revoked) - self.assertEqual(BadgeInstance.objects.last().revocation_reason, revocation_reason) - - # attempting to revalidate a revoked badge with a new revocation reason does not update the original reason. - mock_verify.return_value["graph"][0].update({'revocationReason': 'New reason should not replace original reason'}) - third_revoked_response = self.client.post('/public/verify', data={'entity_id': assertion.entity_id}) - # still revoked - self.assertTrue(third_revoked_response.data['result'][0]['revoked']) - # returns original revoked response, revocation_reason has not changed - self.assertEqual(third_revoked_response.data['result'][0]['revocationReason'], revocation_reason) - # badge instance is revoked, revocation_reason has not changed - self.assertTrue(BadgeInstance.objects.last().revoked) - self.assertEqual(BadgeInstance.objects.last().revocation_reason, revocation_reason) - diff --git a/apps/issuer/tests/test_v1_api.py b/apps/issuer/tests/test_v1_api.py deleted file mode 100644 index 3d7167cb3..000000000 --- a/apps/issuer/tests/test_v1_api.py +++ /dev/null @@ -1,60 +0,0 @@ -# encoding: utf-8 - - -from django.urls import reverse -from mainsite.tests import SetupIssuerHelper, BadgrTestCase - - -class FindBadgeClassTests(SetupIssuerHelper, BadgrTestCase): - - def test_can_find_imported_badge_by_id(self): - user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=user) - badgeclass = self.setup_badgeclass(issuer=issuer) - - source_url = 'https://imported.fake/badge/url' - badgeclass.source_url = source_url - badgeclass.save() - - url = "{url}?identifier={id}".format( - url=reverse('v1_api_find_badgeclass_by_identifier'), - id=source_url - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertIn('slug', response.data) - self.assertEqual(response.data['slug'], badgeclass.entity_id) - - def test_can_find_issuer_badge_by_id(self): - user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=user) - badgeclass = self.setup_badgeclass(issuer=issuer) - - url = "{url}?identifier={id}".format( - url=reverse('v1_api_find_badgeclass_by_identifier'), - id=badgeclass.jsonld_id - ) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertIn('slug', response.data) - self.assertEqual(response.data['slug'], badgeclass.entity_id) - - def test_can_find_issuer_badge_by_slug(self): - user = self.setup_user(authenticate=True) - issuer = self.setup_issuer(owner=user) - badgeclass = self.setup_badgeclass(issuer=issuer) - badgeclass.slug = 'legacy-slug' - badgeclass.save() - - url = "{url}?identifier={slug}".format( - url=reverse('v1_api_find_badgeclass_by_identifier'), - slug=badgeclass.slug - ) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertIn('slug', response.data) - self.assertEqual(response.data['slug'], badgeclass.entity_id) - - diff --git a/apps/issuer/tests/test_v2_api.py b/apps/issuer/tests/test_v2_api.py deleted file mode 100644 index c69bb42ea..000000000 --- a/apps/issuer/tests/test_v2_api.py +++ /dev/null @@ -1,325 +0,0 @@ -# encoding: utf-8 - - -import time -import urllib.request, urllib.parse, urllib.error -from urllib.parse import urlparse - -from django.urls import reverse -from django.test import override_settings - -from mainsite.tests import SetupIssuerHelper, BadgrTestCase, BadgeUser -from mainsite.models import AccessTokenProxy, AccessTokenScope -from oauth2_provider.models import Application -from django.utils import timezone -from datetime import timedelta -from badgeuser.models import UserRecipientIdentifier - - -@override_settings( - CELERY_ALWAYS_EAGER=True -) -class AssertionsChangedSinceTests(SetupIssuerHelper, BadgrTestCase): - def verify_email(self, user): - email = user.cached_emails()[0] - email.verified = True - email.primary = True - email.save() - - def test_with_two_apps(self): - # Application A - client_a = BadgeUser.objects.create(email="danger@example.com", - first_name="Danger No No", - last_name="No", - create_email_address=True, - send_confirmation=False) - self.verify_email(client_a) - app_a = Application.objects.create( - client_id='client_a', client_secret='secret', authorization_grant_type='client-credentials', - user=client_a) - token_a = AccessTokenProxy.objects.create( - user=client_a, scope="r:assertions", expires=timezone.now() + timedelta(hours=1), - token='prettyplease1', application=app_a - ) - - # Application B - client_b = BadgeUser.objects.create(email="yes@example.com", - first_name="Gimme Yes", - last_name="Yes Yes Yes", - create_email_address=True, - send_confirmation=False) - self.verify_email(client_b) - app_b = Application.objects.create( - client_id='client_b', client_secret='secret', authorization_grant_type='client-credentials', - user=client_b) - token_b = AccessTokenProxy.objects.create( - user=client_b, scope="r:assertions", expires=timezone.now() + timedelta(hours=1), - token='prettyplease2', application=app_b - ) - - user = BadgeUser.objects.create(email="recipient@example.com", - first_name="Firsty", - last_name="Lastington", - create_email_address=True, - send_confirmation=False) - self.verify_email(user) - # token for app b with r:backpack - AccessTokenProxy.objects.create( - user=user, scope="r:backpack", expires=timezone.now() + timedelta(hours=1), - token='prettyplease3', application=app_b - ) - # token for app a with r:profile - AccessTokenProxy.objects.create( - user=user, scope="r:profile", expires=timezone.now() + timedelta(hours=1), - token='prettyplease4', application=app_a - ) - staff = self.setup_user(email="staff@example.com", authenticate=False) - issuer = self.setup_issuer(name="Giver", owner=staff) - badge = self.setup_badgeclass(issuer=issuer) - badge.issue(recipient_id="recipient@example.com") - - # Application A should not have access to the badge instance - self.client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(token_a.token)) - url = reverse('v2_api_assertions_changed_list') - response = self.client.get(url) - self.assertEqual(len(response.data['result']), 0) - - # Application B should *not* have access to the badge instance as per update in BP-2347 - self.client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(token_b.token)) - url = reverse('v2_api_assertions_changed_list') - response = self.client.get(url) - self.assertEqual(len(response.data['result']), 0) - - def test_application_can_fetch_changed_assertions(self): - #as per update in BP-2347, this token should not be able to get anything anymore - staff = self.setup_user(email='staff@example.com') - recipient = self.setup_user(email='recipient@example.com', authenticate=False) - unrelated_recipient = self.setup_user(email='otherrecipient1@example.com') - - issuer = self.setup_issuer(owner=staff) - badgeclass = self.setup_badgeclass(issuer=issuer) - badgeclass.issue(recipient_id=recipient.email) - badgeclass.issue(recipient_id=staff.email) - badgeclass.issue(recipient_id=unrelated_recipient.email) - url = reverse('v2_api_assertions_changed_list') - - clientAppUser = self.setup_user(email='clientApp@example.com', token_scope='r:assertions') - app = Application.objects.create( - client_id='clientApp-authcode', client_secret='testsecret', authorization_grant_type='authorization-code', - user=clientAppUser) - AccessTokenProxy.objects.create( - user=staff, scope='rw:issuer r:profile r:backpack', expires=timezone.now() + timedelta(hours=1), - token='123', application=app - ) - token = AccessTokenProxy.objects.create( - user=recipient, scope='rw:issuer r:profile r:backpack', expires=timezone.now() + timedelta(hours=1), - token='abc2', application=app - ) - # Sanity check that signal was called post AbstractAccessToken save() - self.assertEqual(AccessTokenScope.objects.filter(token = token).count(), 3) - - unrelated_app = Application.objects.create( - client_id='clientApp-authcode-2', client_secret='testsecret', authorization_grant_type='authorization-code', - user=None) - AccessTokenProxy.objects.create( - user=unrelated_recipient, scope='rw:issuer r:profile r:backpack', expires=timezone.now() + timedelta(hours=1), - token='abc3', application=unrelated_app - ) - - response = self.client.get(url) - self.assertEqual(response.status_code, 404) - - def assertions_in_expected_order_through_pagination(self): - staff = self.setup_user(email='staff@example.com', token_scope='r:issuer') - recipient = self.setup_user(email='recipient@example.com', authenticate=False) - issuer = self.setup_issuer(owner=staff) - badgeclass = self.setup_badgeclass(issuer=issuer) - - assertions = [] - for n in range(5): - assertions.append(badgeclass.issue(recipient_id=recipient.email)) - - cut_time = urllib.parse.quote(timezone.now().isoformat()) - time.sleep(0.1) - - for n in range(10): - assertions.append(badgeclass.issue(recipient_id=recipient.email)) - - response = self.client.get(reverse('v2_api_assertions_changed_list') + '?num=5&since={}'.format(cut_time)) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 5) # There are the expected number of results in a page - self.assertEqual(response.data['result'][0]['entityId'], assertions[5].entity_id) - self.assertEqual(response.data['result'][4]['entityId'], assertions[9].entity_id) - - next_url = urlparse(response.data['pagination']['nextResults']) - response = self.client.get("?".join([next_url.path, next_url.query])) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 5) # There are the expected number of results in a page - self.assertEqual(response.data['result'][0]['entityId'], assertions[10].entity_id) - self.assertEqual(response.data['result'][4]['entityId'], assertions[14].entity_id) - - -class AssertionFetching(SetupIssuerHelper, BadgrTestCase): - def test_can_paginate_fetch_assertions_by_recipient(self): - user1 = self.setup_user(authenticate=True, email='user1@example.com') - user2 = self.setup_user(email='user2@example.com') - user3 = self.setup_user(email='user3@example.com') - issuer = self.setup_issuer(owner=user1) - badgeclass = self.setup_badgeclass(issuer=issuer) - - badgeclass.issue(recipient_id=user1.email) - badgeclass.issue(recipient_id=user2.email) - badgeclass.issue(recipient_id=user3.email) - - url = "{url}?num=2&recipient={email3}".format( - url=reverse('v2_api_badgeclass_assertion_list', - kwargs={'entity_id': badgeclass.entity_id}), - email3=user3.email - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - def test_can_fetch_assertions_by_recipient_ids_for_badgeclass(self): - user1 = self.setup_user(authenticate=True, email='user1@example.com') - user2 = self.setup_user(email='user2@example.com') - user3 = self.setup_user(email='user3@example.com') - issuer = self.setup_issuer(owner=user1) - badgeclass = self.setup_badgeclass(issuer=issuer) - - badgeclass.issue(recipient_id=user1.email) - badgeclass.issue(recipient_id=user2.email) - badgeclass.issue(recipient_id=user3.email) - - # Test default case without any filtering - url = "{url}".format( - url=reverse('v2_api_badgeclass_assertion_list', kwargs={'entity_id': badgeclass.entity_id}), - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 3) - - # Filter for 1 recipient - url = "{url}?recipient={email2}".format( - url=reverse('v2_api_badgeclass_assertion_list', kwargs={'entity_id': badgeclass.entity_id}), - email2=user2.email - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - # Filter for 2 recipient - url = "{url}?recipient={email2}&recipient={email3}".format( - url=reverse('v2_api_badgeclass_assertion_list', kwargs={'entity_id': badgeclass.entity_id}), - email2=user2.email, - email3=user3.email - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 2) - - def test_can_fetch_assertions_by_recipient_ids(self): - user1 = self.setup_user(authenticate=True, email='user1@example.com') - user2 = self.setup_user(email='user2@example.com') - user3 = self.setup_user(email='user3@example.com') - issuer = self.setup_issuer(owner=user1) - badgeclass = self.setup_badgeclass(issuer=issuer) - - badgeclass.issue(recipient_id=user1.email) - badgeclass.issue(recipient_id=user2.email) - badgeclass.issue(recipient_id=user3.email) - - # Test default case without any filtering - url = "{url}".format( - url=reverse('v2_api_issuer_assertion_list', kwargs={'entity_id': issuer.entity_id}), - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 3) - - # Filter for 1 recipient - url = "{url}?recipient={email2}".format( - url=reverse('v2_api_issuer_assertion_list', kwargs={'entity_id': issuer.entity_id}), - email2=user2.email - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - # Filter for 2 recipient - url = "{url}?recipient={email2}&recipient={email3}".format( - url=reverse('v2_api_issuer_assertion_list', kwargs={'entity_id': issuer.entity_id}), - email2=user2.email, - email3=user3.email - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 2) - - def test_can_fetch_assertions_by_url_based_recipient_ids(self): - url_recipient = "http://example.com" - u1 = self.setup_user(authenticate=True, email="hey@example.com") - UserRecipientIdentifier.objects.create(identifier=url_recipient, user=u1) - UserRecipientIdentifier.objects.create(identifier="http://example.com/notme", user=u1) - i = self.setup_issuer(owner=u1) - b = self.setup_badgeclass(issuer=i) - b.issue(recipient_id=url_recipient) - b.issue(recipient_id=u1.email) - b.issue(recipient_id="http://example.com/notme") - url = "{url}?recipient={url_recipient}".format( - url=reverse('v2_api_badgeclass_assertion_list', kwargs={'entity_id': b.entity_id}), - url_recipient=url_recipient - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['result']), 1) - - -class AssertionPosting(SetupIssuerHelper, BadgrTestCase): - def test_can_post_assertion_to_v2_api_badgeclass_assertion_list_with_issuer_specified(self): - u1 = self.setup_user(authenticate=True, email="hey@example.com") - u2 = self.setup_user(authenticate=False, email="secondIssuerUser@example.com") - u3 = self.setup_user(authenticate=False, email="reandom3rdUser@example.com") - i = self.setup_issuer(owner=u1) - i2 = self.setup_issuer(owner=u2) - b = self.setup_badgeclass(issuer=i) - url = "{url}".format( - url=reverse('v2_api_badgeclass_assertion_list', kwargs={'entity_id': b.entity_id}) - ) - response = self.client.post(url, data={ - "issuer": i.entity_id, - "recipient": { - "identity": u1.email, - "hashed": True, - "type": "email" - } - }, format="json") - self.assertEqual(response.status_code, 201) - - # put a bad value in the issuer of the payload and make sure it is ignored - response2 = self.client.post(url, data={ - "issuer": i2.entity_id, - "recipient": { - "identity": u2.email, - "hashed": True, - "type": "email" - } - }, format="json") - self.assertEqual(response2.status_code, 201) - - # don't include issuer in request and make sure correct one is used - response3 = self.client.post(url, data={ - "recipient": { - "identity": u3.email, - "hashed": True, - "type": "email" - } - }, format="json") - self.assertEqual(response3.status_code, 201) - - # fetch the assertions - response4 = self.client.get(url) - self.assertEqual(response4.status_code, 200) - self.assertEqual(len(response4.data['result']), 3) - for result in response4.data['result']: - self.assertEqual(result['issuer'], i.entity_id) diff --git a/apps/issuer/utils.py b/apps/issuer/utils.py index 195751103..2cdcc132f 100644 --- a/apps/issuer/utils.py +++ b/apps/issuer/utils.py @@ -3,23 +3,32 @@ import pytz import re from urllib.parse import urlparse, urlunparse +from functools import reduce from django.urls import resolve, Resolver404 from django.utils import timezone +from django.conf import settings + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from geopy.geocoders import Nominatim +from ratelimit import limits, sleep_and_retry from mainsite.utils import OriginSetting OBI_VERSION_CONTEXT_IRIS = { - '1_1': 'https://w3id.org/openbadges/v1', - '2_0': 'https://w3id.org/openbadges/v2', + "1_1": "https://w3id.org/openbadges/v1", + "2_0": "https://w3id.org/openbadges/v2", + "3_0": ["https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"], } -CURRENT_OBI_VERSION = '2_0' +CURRENT_OBI_VERSION = "2_0" CURRENT_OBI_CONTEXT_IRI = OBI_VERSION_CONTEXT_IRIS.get(CURRENT_OBI_VERSION) # assertions that were baked and saved to BadgeInstance.image used this version -UNVERSIONED_BAKED_VERSION = '2_0' +UNVERSIONED_BAKED_VERSION = "3_0" def get_obi_context(obi_version): @@ -30,36 +39,38 @@ def get_obi_context(obi_version): return (obi_version, context_iri) -def add_obi_version_ifneeded(url, obi_version): - if obi_version == CURRENT_OBI_VERSION: +def add_obi_version_ifneeded(url, obi_version, force_add=False): + if obi_version == CURRENT_OBI_VERSION and not force_add: return url if not url.startswith(OriginSetting.HTTP): return url return "{url}{sep}v={obi_version}".format( - url=url, - sep='&' if '?' in url else '?', - obi_version=obi_version) + url=url, sep="&" if "?" in url else "?", obi_version=obi_version + ) def generate_sha256_hashstring(identifier, salt=None): - key = '{}{}'.format(identifier, salt if salt is not None else "") - return 'sha256$' + hashlib.sha256(key.encode('utf-8')).hexdigest() + key = "{}{}".format(identifier, salt if salt is not None else "") + return "sha256$" + hashlib.sha256(key.encode("utf-8")).hexdigest() def generate_md5_hashstring(identifier, salt=None): - key = '{}{}'.format(identifier, salt if salt is not None else "") - return 'md5$' + hashlib.md5(key.encode('utf-8')).hexdigest() + key = "{}{}".format(identifier, salt if salt is not None else "") + return "md5$" + hashlib.md5(key.encode("utf-8")).hexdigest() def generate_rebaked_filename(oldname, badgeclass_filename): - parts = oldname.split('.') - badgeclass_filename_parts = badgeclass_filename.split('.') + parts = oldname.split(".") + badgeclass_filename_parts = badgeclass_filename.split(".") ext = badgeclass_filename_parts.pop() - parts.append('rebaked') - return 'assertion-{}.{}'.format(hashlib.md5(''.join(parts).encode('utf-8')).hexdigest(), ext) + parts.append("rebaked") + return "assertion-{}.{}".format( + hashlib.md5("".join(parts).encode("utf-8")).hexdigest(), ext + ) + def is_probable_url(string): - earl = re.compile(r'^https?') + earl = re.compile(r"^https?") if string is None: return False return earl.match(string) @@ -68,7 +79,16 @@ def is_probable_url(string): def obscure_email_address(email): charlist = list(email) - return ''.join(letter if letter in ('@', '.',) else '*' for letter in charlist) + return "".join( + letter + if letter + in ( + "@", + ".", + ) + else "*" + for letter in charlist + ) def get_badgeclass_by_identifier(identifier): @@ -84,9 +104,9 @@ def get_badgeclass_by_identifier(identifier): # attempt to resolve identifier as JSON-ld id if identifier.startswith(OriginSetting.HTTP): try: - resolver_match = resolve(identifier.replace(OriginSetting.HTTP, '')) + resolver_match = resolve(identifier.replace(OriginSetting.HTTP, "")) if resolver_match: - entity_id = resolver_match.kwargs.get('entity_id', None) + entity_id = resolver_match.kwargs.get("entity_id", None) if entity_id: try: return BadgeClass.cached.get(entity_id=entity_id) @@ -129,36 +149,70 @@ def parse_original_datetime(t, tzinfo=pytz.utc): dt = dt.astimezone(tzinfo) result = dt.isoformat() except (ValueError, TypeError): - dt = timezone.datetime.strptime(t, '%Y-%m-%d') + dt = timezone.datetime.strptime(t, "%Y-%m-%d") if not timezone.is_aware(dt): dt = pytz.utc.localize(dt) elif timezone.is_aware(dt) and dt.tzinfo != tzinfo: dt = dt.astimezone(tzinfo).isoformat() result = dt.isoformat() - if result and result.endswith('00:00'): - return result[:-6] + 'Z' + if result and result.endswith("00:00"): + return result[:-6] + "Z" return result def request_authenticated_with_server_admin_token(request): try: - return 'rw:serverAdmin' in set(request.auth.scope.split()) + return "rw:serverAdmin" in set(request.auth.scope.split()) except AttributeError: return False def sanitize_id(recipient_identifier, identifier_type, allow_uppercase=False): - if identifier_type == 'email': + if identifier_type == "email": return recipient_identifier if allow_uppercase else recipient_identifier.lower() - elif identifier_type == 'url': + elif identifier_type == "url": p = urlparse(recipient_identifier) - return urlunparse(( - p.scheme, - p.netloc.lower(), - p.path, - p.params, - p.query, - p.fragment, - )) + return urlunparse( + ( + p.scheme, + p.netloc.lower(), + p.path, + p.params, + p.query, + p.fragment, + ) + ) return recipient_identifier + + +def generate_private_key_pem(): + private_key = ed25519.Ed25519PrivateKey.generate() + encrypted_key = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption( + settings.SECRET_KEY.encode() + ), + ).decode() + return encrypted_key + + +def assertion_is_v3(assertion_json): + context = assertion_json["@context"] + # if @context is string it's probably v2 + if isinstance(context, str): + return False + # search for vc context IRIs + return reduce(lambda x, y: x or "/credentials/" in y, context, False) + +@sleep_and_retry +@limits(calls=1, period=1) +def geocode(addr_string: str): + """ + Geocodes the given addr_string. + Rate limitted to 1 request/s; will sleep if exceeded. + """ + nom = Nominatim(user_agent="OpenEducationalBadges") + geoloc = nom.geocode(addr_string) + return geoloc \ No newline at end of file diff --git a/apps/issuer/v1_api_urls.py b/apps/issuer/v1_api_urls.py index a83b80504..4edf65c58 100644 --- a/apps/issuer/v1_api_urls.py +++ b/apps/issuer/v1_api_urls.py @@ -1,25 +1,225 @@ -from django.conf.urls import url +from django.urls import re_path -from issuer.api import (IssuerList, IssuerDetail, IssuerBadgeClassList, BadgeClassDetail, BadgeInstanceList, - BadgeInstanceDetail, IssuerBadgeInstanceList, AllBadgeClassesList, BatchAssertionsIssue) +from issuer.api import ( + BadgeClassNetworkShareView, + BadgeRequestList, + IssuerAwardableBadgeClassList, + IssuerLearningPathList, + IssuerList, + IssuerNetworkBadgeClassList, + IssuerSharedNetworkBadgesView, + IssuerStaffRequestConfirm, + NetworkBadgeClassesList, + NetworkBadgeInstanceList, + NetworkBadgeQRCodeList, + NetworkDetail, + NetworkInvitation, + NetworkInvitationConfirm, + NetworkInvitationList, + NetworkIssuerDetail, + NetworkList, + IssuerDetail, + IssuerBadgeClassList, + BadgeClassDetail, + BadgeInstanceList, + BadgeInstanceDetail, + IssuerBadgeInstanceList, + AllBadgeClassesList, + BatchAssertionsIssue, + IssuerStaffRequestDetail, + IssuerStaffRequestList, + LearningPathDetail, + LearningPathParticipantsList, + NetworkSharedBadgesView, + NetworkUserIssuersList, + QRCodeDetail, + BadgeImageComposition, + QRCodeList, +) from issuer.api_v1 import FindBadgeClassDetail, IssuerStaffList urlpatterns = [ # url(r'^$', RedirectView.as_view(url='/v1/issuer/issuers', permanent=False)), - - url(r'^all-badges$', AllBadgeClassesList.as_view(), name='v1_api_issuer_all_badges_list'), - url(r'^all-badges/find$', FindBadgeClassDetail.as_view(), name='v1_api_find_badgeclass_by_identifier'), - - url(r'^issuers$', IssuerList.as_view(), name='v1_api_issuer_list'), - url(r'^issuers/(?P[^/]+)$', IssuerDetail.as_view(), name='v1_api_issuer_detail'), - url(r'^issuers/(?P[^/]+)/staff$', IssuerStaffList.as_view(), name='v1_api_issuer_staff'), - - url(r'^issuers/(?P[^/]+)/badges$', IssuerBadgeClassList.as_view(), name='v1_api_badgeclass_list'), - url(r'^issuers/(?P[^/]+)/badges/(?P[^/]+)$', BadgeClassDetail.as_view(), name='v1_api_badgeclass_detail'), - - url(r'^issuers/(?P[^/]+)/badges/(?P[^/]+)/batchAssertions$', BatchAssertionsIssue.as_view(), name='v1_api_badgeclass_batchissue'), - - url(r'^issuers/(?P[^/]+)/badges/(?P[^/]+)/assertions$', BadgeInstanceList.as_view(), name='v1_api_badgeinstance_list'), - url(r'^issuers/(?P[^/]+)/assertions$', IssuerBadgeInstanceList.as_view(), name='v1_api_issuer_instance_list'), - url(r'^issuers/(?P[^/]+)/badges/(?P[^/]+)/assertions/(?P[^/]+)$', BadgeInstanceDetail.as_view(), name='v1_api_badgeinstance_detail'), + re_path( + r"^all-badges$", + AllBadgeClassesList.as_view(), + name="v1_api_issuer_all_badges_list", + ), + re_path( + r"^all-badges/find$", + FindBadgeClassDetail.as_view(), + name="v1_api_find_badgeclass_by_identifier", + ), + re_path(r"^issuers$", IssuerList.as_view(), name="v1_api_issuer_list"), + re_path( + r"^issuers/(?P[^/]+)$", + IssuerDetail.as_view(), + name="v1_api_issuer_detail", + ), + re_path(r"^networks$", NetworkList.as_view(), name="v1_api_network_list"), + re_path( + r"^networks/(?P[^/]+)$", + NetworkDetail.as_view(), + name="v1_api_issuer_detail", + ), + re_path( + r"^issuers/(?P[^/]+)/staff$", + IssuerStaffList.as_view(), + name="v1_api_issuer_staff", + ), + re_path( + r"^issuers/(?P[^/]+)/badges$", + IssuerBadgeClassList.as_view(), + name="v1_api_badgeclass_list", + ), + re_path( + r"^networks/(?P[^/]+)/badges$", + NetworkBadgeClassesList.as_view(), + name="v1_api_network_badgeclass_list", + ), + re_path( + r"^issuers/(?P[^/]+)/awardable-badges$", + IssuerAwardableBadgeClassList.as_view(), + name="v1_issuer_awardable_badgeclasses", + ), + re_path( + r"^networks/(?P[^/]+)/badges/(?P[^/]+)/share$", + BadgeClassNetworkShareView.as_view(), + name="v1_api_network_badge_share_detail", + ), + re_path( + r"^networks/(?P[^/]+)/shared-badges$", + NetworkSharedBadgesView.as_view(), + name="v1_api_network_shared_badges", + ), + re_path( + r"^issuers/(?P[^/]+)/networks/shared-badges$", + IssuerSharedNetworkBadgesView.as_view(), + name="v1_api_issuer_shared_network_badges", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/qrcodes/(?P[^/]+)$", + QRCodeDetail.as_view(), + name="v1_api_qrcode_detail", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/qrcodes$", + QRCodeList.as_view(), + name="v1_api_qrcode_list", + ), + re_path( + r"^networks/(?P[^/]+)/badges/(?P[^/]+)/qrcodes$", + NetworkBadgeQRCodeList.as_view(), + name="v1_api_network_badge_qrcode_list", + ), + re_path( + r"^networks/(?P[^/]+)/issuers$", + NetworkUserIssuersList.as_view(), + name="v1_api_network_issuer_list", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/qrcodes/(?P[^/]+)$", + QRCodeDetail.as_view(), + name="v1_api_qrcode_detail", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/requests$", + BadgeRequestList.as_view(), + name="v1_api_badgerequest_list", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)$", + BadgeClassDetail.as_view(), + name="v1_api_badgeclass_detail", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/batchAssertions$", + BatchAssertionsIssue.as_view(), + name="v1_api_badgeclass_batchissue", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/batch-assertions/status/(?P[^/]+)$", + BatchAssertionsIssue.as_view(), + name="batch-assertions-status", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/assertions$", + BadgeInstanceList.as_view(), + name="v1_api_badgeinstance_list", + ), + re_path( + r"^issuers/(?P[^/]+)/network/badges$", + IssuerNetworkBadgeClassList.as_view(), + name="v1_api_issuer_network_badgeclass_list", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/network-assertions$", + NetworkBadgeInstanceList.as_view(), + name="v1_api_network_badgeinstance_list", + ), + re_path( + r"^issuers/(?P[^/]+)/assertions$", + IssuerBadgeInstanceList.as_view(), + name="v1_api_issuer_instance_list", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/(?P[^/]+)/assertions/(?P[^/]+)$", + BadgeInstanceDetail.as_view(), + name="v1_api_badgeinstance_detail", + ), + re_path( + r"^issuers/(?P[^/]+)/learningpath$", + IssuerLearningPathList.as_view(), + name="v1_api_learningpath_list", + ), + re_path( + r"^networks/(?P[^/]+)/issuer/(?P[^/]+)$", + NetworkIssuerDetail.as_view(), + name="v1_api_network_issuer_detail", + ), + re_path( + r"^issuers/(?P[^/]+)/learningpath/(?P[^/]+)$", + LearningPathDetail.as_view(), + name="v1_api_learningpath_detail", + ), + re_path( + r"^learningpath/(?P[^/]+)/participants$", + LearningPathParticipantsList.as_view(), + name="v1_api_learningpath_participant_list", + ), + re_path( + r"^issuers/(?P[^/]+)/staffRequests$", + IssuerStaffRequestList.as_view(), + name="v1_api_staffrequest_list", + ), + re_path( + r"^issuers/(?P[^/]+)/staffRequests/(?P[^/]+)$", + IssuerStaffRequestDetail.as_view(), + name="v1_api_staffrequest_detail", + ), + re_path( + r"^issuers/(?P[^/]+)/staffRequests/(?P[^/]+)/confirm$", + IssuerStaffRequestConfirm.as_view(), + name="v1_api_staffrequest_confirmation", + ), + re_path( + r"^issuers/(?P[^/]+)/badges/image/compose$", + BadgeImageComposition.as_view(), + name="v1_api_badge_image_composition", + ), + re_path( + r"^networks/(?P[^/]+)/invites$", + NetworkInvitationList.as_view(), + name="v1_api_network_invite_list", + ), + re_path( + r"^networks/invites/(?P[^/]+)$", + NetworkInvitation.as_view(), + name="v1_api_network_invite_detail", + ), + re_path( + r"^networks/(?P[^/]+)/invite/(?P[^/]+)/confirm$", + NetworkInvitationConfirm.as_view(), + name="v1_api_network_invite_confirmation", + ), ] diff --git a/apps/issuer/v2_api_urls.py b/apps/issuer/v2_api_urls.py index 94ffbc6c7..258032880 100644 --- a/apps/issuer/v2_api_urls.py +++ b/apps/issuer/v2_api_urls.py @@ -1,27 +1,81 @@ -from django.conf.urls import url +from django.urls import re_path -from issuer.api import (IssuerList, IssuerDetail, IssuerBadgeClassList, BadgeClassDetail, BadgeInstanceList, - BadgeInstanceDetail, IssuerBadgeInstanceList, AllBadgeClassesList, BatchAssertionsIssue, - BatchAssertionsRevoke, IssuerTokensList, AssertionsChangedSince, BadgeClassesChangedSince, - IssuersChangedSince) +from issuer.api import ( + IssuerList, + IssuerDetail, + IssuerBadgeClassList, + BadgeClassDetail, + BadgeInstanceList, + BadgeInstanceDetail, + IssuerBadgeInstanceList, + AllBadgeClassesList, + BatchAssertionsIssue, + BatchAssertionsRevoke, + IssuerTokensList, + AssertionsChangedSince, + BadgeClassesChangedSince, + IssuersChangedSince, +) urlpatterns = [ - - url(r'^issuers$', IssuerList.as_view(), name='v2_api_issuer_list'), - url(r'^issuers/changed$', IssuersChangedSince.as_view(), name='v2_api_issuers_changed_list'), - url(r'^issuers/(?P[^/]+)$', IssuerDetail.as_view(), name='v2_api_issuer_detail'), - url(r'^issuers/(?P[^/]+)/assertions$', IssuerBadgeInstanceList.as_view(), name='v2_api_issuer_assertion_list'), - url(r'^issuers/(?P[^/]+)/badgeclasses$', IssuerBadgeClassList.as_view(), name='v2_api_issuer_badgeclass_list'), - - url(r'^badgeclasses$', AllBadgeClassesList.as_view(), name='v2_api_badgeclass_list'), - url(r'^badgeclasses/changed$', BadgeClassesChangedSince.as_view(), name='v2_api_badgeclasses_changed_list'), - url(r'^badgeclasses/(?P[^/]+)$', BadgeClassDetail.as_view(), name='v2_api_badgeclass_detail'), - url(r'^badgeclasses/(?P[^/]+)/issue$', BatchAssertionsIssue.as_view(), name='v2_api_badgeclass_issue'), - url(r'^badgeclasses/(?P[^/]+)/assertions$', BadgeInstanceList.as_view(), name='v2_api_badgeclass_assertion_list'), - - url(r'^assertions/revoke$', BatchAssertionsRevoke.as_view(), name='v2_api_assertion_revoke'), - url(r'^assertions/changed$', AssertionsChangedSince.as_view(), name='v2_api_assertions_changed_list'), - url(r'^assertions/(?P[^/]+)$', BadgeInstanceDetail.as_view(), name='v2_api_assertion_detail'), - - url(r'^tokens/issuers$', IssuerTokensList.as_view(), name='v2_api_tokens_list'), + re_path(r"^issuers$", IssuerList.as_view(), name="v2_api_issuer_list"), + re_path( + r"^issuers/changed$", + IssuersChangedSince.as_view(), + name="v2_api_issuers_changed_list", + ), + re_path( + r"^issuers/(?P[^/]+)$", + IssuerDetail.as_view(), + name="v2_api_issuer_detail", + ), + re_path( + r"^issuers/(?P[^/]+)/assertions$", + IssuerBadgeInstanceList.as_view(), + name="v2_api_issuer_assertion_list", + ), + re_path( + r"^issuers/(?P[^/]+)/badgeclasses$", + IssuerBadgeClassList.as_view(), + name="v2_api_issuer_badgeclass_list", + ), + re_path( + r"^badgeclasses$", AllBadgeClassesList.as_view(), name="v2_api_badgeclass_list" + ), + re_path( + r"^badgeclasses/changed$", + BadgeClassesChangedSince.as_view(), + name="v2_api_badgeclasses_changed_list", + ), + re_path( + r"^badgeclasses/(?P[^/]+)$", + BadgeClassDetail.as_view(), + name="v2_api_badgeclass_detail", + ), + re_path( + r"^badgeclasses/(?P[^/]+)/issue$", + BatchAssertionsIssue.as_view(), + name="v2_api_badgeclass_issue", + ), + re_path( + r"^badgeclasses/(?P[^/]+)/assertions$", + BadgeInstanceList.as_view(), + name="v2_api_badgeclass_assertion_list", + ), + re_path( + r"^assertions/revoke$", + BatchAssertionsRevoke.as_view(), + name="v2_api_assertion_revoke", + ), + re_path( + r"^assertions/changed$", + AssertionsChangedSince.as_view(), + name="v2_api_assertions_changed_list", + ), + re_path( + r"^assertions/(?P[^/]+)$", + BadgeInstanceDetail.as_view(), + name="v2_api_assertion_detail", + ), + re_path(r"^tokens/issuers$", IssuerTokensList.as_view(), name="v2_api_tokens_list"), ] diff --git a/apps/issuer/v3_api_urls.py b/apps/issuer/v3_api_urls.py new file mode 100644 index 000000000..17f1be749 --- /dev/null +++ b/apps/issuer/v3_api_urls.py @@ -0,0 +1,23 @@ +from django.urls import include, path +from rest_framework import routers + +from . import api_v3 + +router = routers.DefaultRouter() +router.register(r"badges/tags", api_v3.BadgeTags, basename="badge-tags") +router.register(r"badges", api_v3.Badges, basename="badges") +router.register(r"badgeinstances", api_v3.BadgeInstances, basename="badgeinstances") +router.register(r"issuers", api_v3.Issuers) +router.register(r"learningpaths", api_v3.LearningPaths) +router.register(r"networks", api_v3.Networks, basename="networks") + +urlpatterns = [ + path("learnersprofile", api_v3.LearnersProfile.as_view()), + path("learners-competencies", api_v3.LearnersCompetencies.as_view()), + path("learners-badges", api_v3.LearnersBadges.as_view()), + path("learners-learningpaths", api_v3.LearnersLearningPaths.as_view()), + path("learners-backpack", api_v3.LearnersBackpack.as_view()), + path("badge-create-embed", api_v3.BadgeCreateEmbed.as_view()), + path("badge-edit-embed", api_v3.BadgeEditEmbed.as_view()), + path("", include(router.urls)), +] diff --git a/apps/mainsite/__init__.py b/apps/mainsite/__init__.py index ee3b83ebd..ad7fc3db4 100644 --- a/apps/mainsite/__init__.py +++ b/apps/mainsite/__init__.py @@ -1,19 +1,23 @@ +# import the celery app so INSTALLED_APPS gets autodiscovered +from .celery import app as celery_app # noqa: F401 import sys import os import semver -default_app_config = 'mainsite.apps.BadgrConfig' +default_app_config = "mainsite.apps.BadgrConfig" -__all__ = ['APPS_DIR', 'TOP_DIR', 'get_version'] +__all__ = ["APPS_DIR", "TOP_DIR", "get_version", "celery_app"] def get_version(version=None): if version is None: from .version import VERSION + version = VERSION return semver.format_version(*version) +__timestamp__ = '' # assume we are ./apps/mainsite/__init__.py APPS_DIR = os.path.dirname(os.path.abspath(os.path.dirname(__file__))) @@ -22,6 +26,3 @@ def get_version(version=None): # Path to the whole project (one level up from apps) TOP_DIR = os.path.dirname(APPS_DIR) - -# import the celery app so INSTALLED_APPS gets autodiscovered -from .celery import app as celery_app diff --git a/apps/mainsite/account_adapter.py b/apps/mainsite/account_adapter.py index 6303d9845..158afe334 100644 --- a/apps/mainsite/account_adapter.py +++ b/apps/mainsite/account_adapter.py @@ -1,64 +1,122 @@ -import logging -import urllib.request, urllib.parse, urllib.error import urllib.parse +import os from allauth.account.adapter import DefaultAccountAdapter, get_adapter from allauth.account.models import EmailConfirmation, EmailConfirmationHMAC from allauth.account.utils import user_pk_to_url_str -from allauth.exceptions import ImmediateHttpResponse +from allauth.core.exceptions import ImmediateHttpResponse from django.conf import settings from django.contrib.auth.tokens import default_token_generator from django.contrib.sites.shortcuts import get_current_site from django.urls import resolve, Resolver404, reverse from django.utils.safestring import mark_safe +from issuer.models import BadgeClass, BadgeInstance from badgeuser.authcode import authcode_for_accesstoken -from badgeuser.models import CachedEmailAddress -import badgrlog +from badgeuser.models import BadgeUser, CachedEmailAddress from badgrsocialauth.utils import set_session_badgr_app -from mainsite.models import BadgrApp, EmailBlacklist, AccessTokenProxy -from mainsite.utils import OriginSetting, set_url_query_params +from mainsite.models import BadgrApp, AccessTokenProxy +from mainsite.utils import get_name, OriginSetting, set_url_query_params + +from mainsite.badge_pdf import BadgePDFCreator + +import logging -logger = badgrlog.BadgrLogger() +logger = logging.getLogger("Badgr.Events") class BadgrAccountAdapter(DefaultAccountAdapter): + def generate_pdf_content(self, slug, base_url): + if slug is None: + raise ValueError("Missing slug parameter") - EMAIL_FROM_STRING = '' + try: + badgeinstance = BadgeInstance.objects.get(entity_id=slug) + except BadgeInstance.DoesNotExist: + raise ValueError("BadgeInstance not found") + try: + badgeclass = BadgeClass.objects.get( + entity_id=badgeinstance.badgeclass.entity_id + ) + except BadgeClass.DoesNotExist: + raise ValueError("BadgeClass not found") - def send_mail(self, template_prefix, email, context): - context['STATIC_URL'] = getattr(settings, 'STATIC_URL') - context['HTTP_ORIGIN'] = getattr(settings, 'HTTP_ORIGIN') - context['PRIVACY_POLICY_URL'] = getattr(settings, 'PRIVACY_POLICY_URL', None) - context['TERMS_OF_SERVICE_URL'] = getattr(settings, 'TERMS_OF_SERVICE_URL', None) - context['GDPR_INFO_URL'] = getattr(settings, 'GDPR_INFO_URL', None) - context['OPERATOR_STREET_ADDRESS'] = getattr(settings, 'OPERATOR_STREET_ADDRESS', None) - context['OPERATOR_NAME'] = getattr(settings, 'OPERATOR_NAME', None) - context['OPERATOR_URL'] = getattr(settings, 'OPERATOR_URL', None) + try: + get_name(badgeinstance) + except BadgeUser.DoesNotExist: + logger.warning("Could not find badgeuser '%s'", slug) - if context.get('unsubscribe_url', None) is None: - try: - badgrapp_pk = context['badgr_app'].pk - except (KeyError, AttributeError): - badgrapp_pk = None - context['unsubscribe_url'] = getattr(settings, 'HTTP_ORIGIN') + EmailBlacklist.generate_email_signature( - email, badgrapp_pk) + pdf_creator = BadgePDFCreator() + pdf_content = pdf_creator.generate_pdf( + badgeinstance, badgeclass, origin=base_url + ) + + return pdf_content + + EMAIL_FROM_STRING = "" - self.EMAIL_FROM_STRING = self.set_email_string(context) + def send_mail(self, template_prefix, email, context, from_email=None): + context["STATIC_URL"] = getattr(settings, "STATIC_URL") + context["HTTP_ORIGIN"] = getattr(settings, "HTTP_ORIGIN") + context["GDPR_INFO_URL"] = getattr(settings, "GDPR_INFO_URL", None) + context["OPERATOR_STREET_ADDRESS"] = getattr( + settings, "OPERATOR_STREET_ADDRESS", None + ) + context["OPERATOR_NAME"] = getattr(settings, "OPERATOR_NAME", None) + context["OPERATOR_URL"] = getattr(settings, "OPERATOR_URL", None) + + if from_email: + self.EMAIL_FROM_STRING = from_email + else: + self.EMAIL_FROM_STRING = self.set_email_string(context) msg = self.render_mail(template_prefix, email, context) - logger.event(badgrlog.EmailRendered(msg)) + # badge_id is equal to the badge instance slug + if template_prefix in ( + "issuer/email/notify_earner", + "issuer/email/notify_micro_degree_earner", + ): + pdf_document = ( + context["pdf_document"] if "pdf_document" in context.keys() else None + ) + badge_name = ( + f"{context['badge_name']}.badge" + if "badge_name" in context.keys() + else None + ) + img_path = os.path.join( + settings.MEDIA_ROOT, context["badge_instance_image"] + ) + + try: + with open(img_path, "rb") as f: + badge_img = f.read() + if badge_img and badge_name: + msg.attach(badge_name + ".png", badge_img, "image/png") + except FileNotFoundError: + pass + if pdf_document and badge_name: + msg.attach(badge_name + ".pdf", pdf_document, "application/pdf") + logger.debug( + "Rendered E-Mail with subject '%s' from '%s' to '%s'", + msg.subject, + msg.from_email, + msg.to, + ) msg.send() def set_email_string(self, context): # site_name should not contain commas. - from_elements = [context.get('site_name', 'Badgr').replace(',', '')] + # email sender name + from_elements = [ + context.get("site_name", "Open Educational Badges").replace(",", "") + ] # DEFAULT_FROM_EMAIL must not already have < > in it. - default_from = getattr(settings, 'DEFAULT_FROM_EMAIL', '') + default_from = getattr(settings, "DEFAULT_FROM_EMAIL", "") if not default_from: raise NotImplementedError("DEFAULT_FROM_EMAIL setting must be defined.") - elif '<' in default_from: + elif "<" in default_from: return default_from else: from_elements.append("<{}>".format(default_from)) @@ -69,7 +127,7 @@ def get_from_email(self): return self.EMAIL_FROM_STRING def is_open_for_signup(self, request): - return getattr(settings, 'OPEN_FOR_SIGNUP', True) + return getattr(settings, "OPEN_FOR_SIGNUP", True) def get_email_confirmation_redirect_url(self, request, badgr_app=None): """ @@ -78,34 +136,44 @@ def get_email_confirmation_redirect_url(self, request, badgr_app=None): if badgr_app is None: badgr_app = BadgrApp.objects.get_current(request) if not badgr_app: - logger = logging.getLogger(self.__class__.__name__) logger.warning("Could not determine authorized badgr app") - return super(BadgrAccountAdapter, self).get_email_confirmation_redirect_url(request) + return super( + BadgrAccountAdapter, self + ).get_email_confirmation_redirect_url(request) try: resolver_match = resolve(request.path) - confirmation = EmailConfirmationHMAC.from_key(resolver_match.kwargs.get('confirm_id')) + confirmation = EmailConfirmationHMAC.from_key( + resolver_match.kwargs.get("confirm_id") + ) # publish changes to cache - email_address = CachedEmailAddress.objects.get(pk=confirmation.email_address.pk) + email_address = CachedEmailAddress.objects.get( + pk=confirmation.email_address.pk + ) email_address.publish() - query_params = { - 'email': email_address.email.encode('utf8') - } + query_params = {"email": email_address.email.encode("utf8")} # Pass source and signup along to UI - source = request.query_params.get('source', None) + source = request.query_params.get("source", None) if source: - query_params['source'] = source + query_params["source"] = source - signup = request.query_params.get('signup', None) + signup = request.query_params.get("signup", None) if signup: - query_params['signup'] = 'true' - return set_url_query_params(badgr_app.get_path('/auth/welcome'), **query_params) + query_params["signup"] = "true" + return set_url_query_params( + badgr_app.get_path("/auth/welcome"), **query_params + ) else: - return set_url_query_params(urllib.parse.urljoin( - badgr_app.email_confirmation_redirect.rstrip('/') + '/', - urllib.parse.quote(email_address.user.first_name.encode('utf8')) - ), **query_params) + return set_url_query_params( + urllib.parse.urljoin( + badgr_app.email_confirmation_redirect.rstrip("/") + "/", + urllib.parse.quote( + email_address.user.first_name.encode("utf8") + ), + ), + **query_params, + ) except Resolver404 as xxx_todo_changeme: EmailConfirmation.DoesNotExist = xxx_todo_changeme @@ -113,39 +181,46 @@ def get_email_confirmation_redirect_url(self, request, badgr_app=None): def get_email_confirmation_url(self, request, emailconfirmation, signup=False): url_name = "v1_api_user_email_confirm" - temp_key = default_token_generator.make_token(emailconfirmation.email_address.user) - token = "{uidb36}-{key}".format(uidb36=user_pk_to_url_str(emailconfirmation.email_address.user), - key=temp_key) - activate_url = OriginSetting.HTTP + reverse(url_name, kwargs={'confirm_id': emailconfirmation.key}) + temp_key = default_token_generator.make_token( + emailconfirmation.email_address.user + ) + token = "{uidb36}-{key}".format( + uidb36=user_pk_to_url_str(emailconfirmation.email_address.user), + key=temp_key, + ) + activate_url = OriginSetting.HTTP + reverse( + url_name, kwargs={"confirm_id": emailconfirmation.key} + ) badgrapp = BadgrApp.objects.get_current(request=request) tokenized_activate_url = "{url}?token={token}&a={badgrapp}".format( - url=activate_url, - token=token, - badgrapp=badgrapp.id + url=activate_url, token=token, badgrapp=badgrapp.id ) # Add source and signup query params to the confimation url if request: source = None - if hasattr(request, 'data'): - source = request.data.get('source', None) - elif hasattr(request, 'session'): - source = request.session.get('source', None) + if hasattr(request, "data"): + source = request.data.get("source", None) + elif hasattr(request, "session"): + source = request.session.get("source", None) if source: - tokenized_activate_url = set_url_query_params(tokenized_activate_url, source=source) + tokenized_activate_url = set_url_query_params( + tokenized_activate_url, source=source + ) if signup: - tokenized_activate_url = set_url_query_params(tokenized_activate_url, signup="true") + tokenized_activate_url = set_url_query_params( + tokenized_activate_url, signup="true" + ) return tokenized_activate_url def send_confirmation_mail(self, request, emailconfirmation, signup): current_site = get_current_site(request) activate_url = self.get_email_confirmation_url( - request, - emailconfirmation, - signup) + request, emailconfirmation, signup + ) badgr_app = BadgrApp.objects.get_current(request, raise_exception=False) ctx = { "user": emailconfirmation.email_address.user, @@ -156,12 +231,12 @@ def send_confirmation_mail(self, request, emailconfirmation, signup): "badgr_app": badgr_app, } if signup: - email_template = 'account/email/email_confirmation_signup' + email_template = "account/email/email_confirmation_signup" else: - email_template = 'account/email/email_confirmation' - get_adapter().send_mail(email_template, - emailconfirmation.email_address.email, - ctx) + email_template = "account/email/email_confirmation" + get_adapter().send_mail( + email_template, emailconfirmation.email_address.email, ctx + ) def get_login_redirect_url(self, request): """ @@ -173,8 +248,11 @@ def get_login_redirect_url(self, request): if badgr_app is not None: accesstoken = AccessTokenProxy.objects.generate_new_token_for_user( request.user, - application=badgr_app.oauth_application if badgr_app.oauth_application_id else None, - scope='rw:backpack rw:profile rw:issuer') + application=badgr_app.oauth_application + if badgr_app.oauth_application_id + else None, + scope="rw:backpack rw:profile rw:issuer", + ) if badgr_app.use_auth_code_exchange: authcode = authcode_for_accesstoken(accesstoken) @@ -184,7 +262,7 @@ def get_login_redirect_url(self, request): return set_url_query_params(badgr_app.ui_login_redirect, **params) else: - return '/' + return "/" def login(self, request, user): """ @@ -193,20 +271,24 @@ def login(self, request, user): """ badgr_app = BadgrApp.objects.get_current(request) - if not user.verified and badgr_app.ui_login_redirect != badgr_app.ui_signup_success_redirect: + if ( + not user.verified + and badgr_app.ui_login_redirect != badgr_app.ui_signup_success_redirect + ): # The usual case if a user gets here without a verified recipient # identifier is a new sign-up with an unverified email. If that's # the case, we just sent them a confirmation. # This is for UI clients that do not have the ability to function without a verified user identifier raise ImmediateHttpResponse( - self.respond_email_verification_sent(request, user)) + self.respond_email_verification_sent(request, user) + ) ret = super(BadgrAccountAdapter, self).login(request, user) set_session_badgr_app(request, badgr_app) return ret def logout(self, request): - badgrapp_pk = request.session.get('badgr_app_pk') + badgrapp_pk = request.session.get("badgr_app_pk") super(BadgrAccountAdapter, self).logout(request) if badgrapp_pk: - request.session['badgr_app_pk'] = badgrapp_pk + request.session["badgr_app_pk"] = badgrapp_pk diff --git a/apps/mainsite/admin.py b/apps/mainsite/admin.py index 32c30ddb7..6d307a688 100644 --- a/apps/mainsite/admin.py +++ b/apps/mainsite/admin.py @@ -1,4 +1,14 @@ -import requests +from oauth2_provider.admin import ApplicationAdmin, AccessTokenAdmin +from django.contrib.sites.models import Site +from django.contrib.auth.models import Group +from django.contrib.auth.admin import GroupAdmin +from allauth.socialaccount.admin import ( + SocialApp, + SocialAppAdmin, + SocialTokenAdmin, + SocialAccountAdmin, +) +from allauth.account.admin import EmailAddressAdmin, EmailConfirmationAdmin from allauth.socialaccount.models import SocialToken, SocialAccount from django.contrib import messages from django.contrib.admin import AdminSite, ModelAdmin, StackedInline, TabularInline @@ -7,40 +17,60 @@ from django.utils import timezone from django.utils.html import format_html from django.utils.module_loading import autodiscover_modules -from django.utils.translation import ugettext_lazy +from django.utils.translation import gettext_lazy from django_object_actions import DjangoObjectActions -from oauth2_provider.models import get_application_model, get_grant_model, get_access_token_model, \ - get_refresh_token_model +from oauth2_provider.models import ( + get_application_model, + get_grant_model, + get_access_token_model, + get_refresh_token_model, +) + +from lti_tool.admin import LtiRegistrationAdmin +from lti_tool.models import LtiRegistration -import badgrlog from badgeuser.models import CachedEmailAddress, ProxyEmailConfirmation -from mainsite.models import BadgrApp, EmailBlacklist, ApplicationInfo, AccessTokenProxy, LegacyTokenProxy +from mainsite.models import ( + AltchaChallenge, + BadgrApp, + EmailBlacklist, + ApplicationInfo, + AccessTokenProxy, + LegacyTokenProxy, +) from mainsite.utils import backoff_cache_key, set_url_query_params +import mainsite + +import logging -badgrlogger = badgrlog.BadgrLogger() +logger = logging.getLogger("Badgr.Events") class BadgrAdminSite(AdminSite): - site_header = ugettext_lazy('Badgr') - index_title = ugettext_lazy('Staff Dashboard') - site_title = 'Badgr' + site_header = gettext_lazy("Badgr") + index_title = f"{gettext_lazy('Staff Dashboard')} - Deployment timestamp: {mainsite.__timestamp__}" + site_title = "Badgr" def autodiscover(self): - autodiscover_modules('admin', register_to=self) + autodiscover_modules("admin", register_to=self) def login(self, request, extra_context=None): response = super(BadgrAdminSite, self).login(request, extra_context) - if request.method == 'POST': + if request.method == "POST": # form submission if response.status_code != 302: # failed /staff login - username = request.POST.get('username', None) - badgrlogger.event(badgrlog.FailedLoginAttempt(request, username, endpoint='/staff/login')) + username = request.POST.get("username", None) + logger.info( + "User '%s' failed to login with code '%s'", + username, + response.status_code, + ) return response -badgr_admin = BadgrAdminSite(name='badgradmin') +badgr_admin = BadgrAdminSite(name="badgradmin") # patch in our delete_selected that calls obj.delete() # FIXME: custom action broken for django 1.10+ @@ -50,50 +80,75 @@ def login(self, request, extra_context=None): class BadgrAppAdmin(ModelAdmin): fieldsets = ( - ('Meta', {'fields': ('is_active', ), - 'classes': ('collapse',)}), - (None, { - 'fields': ('name', 'cors', 'oauth_authorization_redirect', 'use_auth_code_exchange', 'oauth_application', - 'is_default',), - }), - ('signup', { - 'fields': ('signup_redirect', 'email_confirmation_redirect', 'forgot_password_redirect', - 'ui_login_redirect', 'ui_signup_success_redirect', 'ui_signup_failure_redirect', - 'ui_connect_success_redirect') - }), - ('public', { - 'fields': ('public_pages_redirect',) - }) + ("Meta", {"fields": ("is_active",), "classes": ("collapse",)}), + ( + None, + { + "fields": ( + "name", + "cors", + "oauth_authorization_redirect", + "use_auth_code_exchange", + "oauth_application", + "is_default", + ), + }, + ), + ( + "signup", + { + "fields": ( + "signup_redirect", + "email_confirmation_redirect", + "forgot_password_redirect", + "ui_login_redirect", + "ui_signup_success_redirect", + "ui_signup_failure_redirect", + "ui_connect_success_redirect", + ) + }, + ), + ("public", {"fields": ("public_pages_redirect",)}), + ) + list_display = ( + "name", + "cors", ) - list_display = ('name', 'cors',) + + badgr_admin.register(BadgrApp, BadgrAppAdmin) class EmailBlacklistAdmin(ModelAdmin): - readonly_fields = ('email',) - list_display = ('email',) - search_fields = ('email',) + readonly_fields = ("email",) + list_display = ("email",) + search_fields = ("email",) + + badgr_admin.register(EmailBlacklist, EmailBlacklistAdmin) # 3rd party apps class LegacyTokenAdmin(ModelAdmin): - list_display = ('obscured_token','user','created') - list_filter = ('created',) - raw_id_fields = ('user',) - search_fields = ('user__email', 'user__first_name', 'user__last_name') - readonly_fields = ('obscured_token', 'created') - fields = ('obscured_token', 'user', 'created') -badgr_admin.register(LegacyTokenProxy, LegacyTokenAdmin) + list_display = ("obscured_token", "user", "created") + list_filter = ("created",) + raw_id_fields = ("user",) + search_fields = ("user__email", "user__first_name", "user__last_name") + readonly_fields = ("obscured_token", "created") + fields = ("obscured_token", "user", "created") -from allauth.account.admin import EmailAddressAdmin, EmailConfirmationAdmin -from allauth.socialaccount.admin import SocialApp, SocialAppAdmin, SocialTokenAdmin, SocialAccountAdmin -from django.contrib.auth.admin import GroupAdmin -from django.contrib.auth.models import Group -from django.contrib.sites.admin import SiteAdmin -from django.contrib.sites.models import Site +class SiteAdmin(ModelAdmin): + fields = ("id", "name", "domain") + readonly_fields = ("id",) + list_display = ("id", "name", "domain") + list_display_links = ("name",) + search_fields = ("name", "domain") + + +badgr_admin.register(LegacyTokenProxy, LegacyTokenAdmin) + badgr_admin.register(SocialApp, SocialAppAdmin) badgr_admin.register(SocialToken, SocialTokenAdmin) @@ -105,7 +160,6 @@ class LegacyTokenAdmin(ModelAdmin): badgr_admin.register(CachedEmailAddress, EmailAddressAdmin) badgr_admin.register(ProxyEmailConfirmation, EmailConfirmationAdmin) -from oauth2_provider.admin import ApplicationAdmin, AccessTokenAdmin Application = get_application_model() Grant = get_grant_model() @@ -117,79 +171,171 @@ class ApplicationInfoInline(StackedInline): model = ApplicationInfo extra = 1 fieldsets = ( - ('Service Info', {'fields': ('name', 'icon', 'website_url', 'terms_uri', 'policy_uri', 'software_id', - 'software_version', 'default_launch_url')}), - ('Configuration', {'fields': ('allowed_scopes', 'trust_email_verification', 'issue_refresh_token')}), + ( + "Service Info", + { + "fields": ( + "name", + "icon", + "website_url", + "terms_uri", + "policy_uri", + "software_id", + "software_version", + "default_launch_url", + ) + }, + ), + ( + "Configuration", + { + "fields": ( + "allowed_scopes", + "trust_email_verification", + "issue_refresh_token", + ) + }, + ), ) - readonly_fields = ('default_launch_url',) + readonly_fields = ("default_launch_url",) class ApplicationInfoAdmin(DjangoObjectActions, ApplicationAdmin): fieldsets = ( - (None, {'fields': ('name', 'client_id', 'client_secret', 'client_type', 'authorization_grant_type', 'user', - 'redirect_uris',)}), - ('Permissions', {'fields': ('skip_authorization', 'login_backoff',)}), + ( + None, + { + "fields": ( + "name", + "client_id", + "client_secret", + "client_type", + "authorization_grant_type", + "user", + "redirect_uris", + ) + }, + ), + ( + "Permissions", + { + "fields": ( + "skip_authorization", + "login_backoff", + ) + }, + ), ) - readonly_fields = ('login_backoff',) - inlines = [ - ApplicationInfoInline - ] - change_actions = ['launch', 'clear_login_backoff'] + readonly_fields = ("login_backoff",) + inlines = [ApplicationInfoInline] + change_actions = ["launch", "clear_login_backoff"] def launch(self, request, obj): if obj.authorization_grant_type != Application.GRANT_AUTHORIZATION_CODE: - messages.add_message(request, messages.INFO, "This is not a Auth Code Application. Cannot Launch.") + messages.add_message( + request, + messages.INFO, + "This is not a Auth Code Application. Cannot Launch.", + ) return - launch_url = BadgrApp.objects.get_current().get_path('/auth/oauth2/authorize') + launch_url = BadgrApp.objects.get_current().get_path("/auth/oauth2/authorize") launch_url = set_url_query_params( - launch_url, client_id=obj.client_id, redirect_uri=obj.default_redirect_uri, - scope=obj.applicationinfo.allowed_scopes + launch_url, + client_id=obj.client_id, + redirect_uri=obj.default_redirect_uri, + scope=obj.applicationinfo.allowed_scopes, ) return HttpResponseRedirect(launch_url) def clear_login_backoff(self, request, obj): cache_key = backoff_cache_key(obj.client_id) cache.delete(cache_key) + clear_login_backoff.label = "Clear login backoffs" - clear_login_backoff.short_description = "Remove blocks created by failed login attempts" + clear_login_backoff.short_description = ( + "Remove blocks created by failed login attempts" + ) def login_backoff(self, obj): cache_key = backoff_cache_key(obj.client_id) backoff = cache.get(cache_key) if backoff is not None: - backoff_data = "
  • ".join(["{ip}: {until} ({count} attempts)".format( - ip=key, - until=backoff[key].get('until').astimezone(timezone.get_current_timezone()).strftime("%Y-%m-%d %H:%M:%S"), - count=backoff[key].get('count') - ) for key in backoff.keys()]) + backoff_data = "
  • ".join( + [ + "{ip}: {until} ({count} attempts)".format( + ip=key, + until=backoff[key] + .get("until") + .astimezone(timezone.get_current_timezone()) + .strftime("%Y-%m-%d %H:%M:%S"), + count=backoff[key].get("count"), + ) + for key in backoff.keys() + ] + ) return format_html("
    • {}
    ".format(backoff_data)) return "None" + login_backoff.allow_tags = True + badgr_admin.register(Application, ApplicationInfoAdmin) # badgr_admin.register(Grant, GrantAdmin) # badgr_admin.register(RefreshToken, RefreshTokenAdmin) class SecuredRefreshTokenInline(TabularInline): - fields = ('obscured_token', 'user', 'revoked',) - raw_id_fields = ('user', 'application',) - readonly_fields = ('user', 'application', 'revoked', 'obscured_token',) + fields = ( + "obscured_token", + "user", + "revoked", + ) + raw_id_fields = ( + "user", + "application", + ) + readonly_fields = ( + "user", + "application", + "revoked", + "obscured_token", + ) model = RefreshToken extra = 0 def obscured_token(self, obj): if obj.token: return "{}***".format(obj.token[:4]) + obscured_token.allow_tags = True class SecuredAccessTokenAdmin(AccessTokenAdmin): list_display = ("obscured_token", "user", "application", "expires") - raw_id_fields = ('user', 'application') - fields = ('obscured_token', 'user', 'application', 'expires', 'scope',) - readonly_fields = ('obscured_token',) - inlines = [ - SecuredRefreshTokenInline - ] + raw_id_fields = ("user", "application") + fields = ( + "obscured_token", + "user", + "application", + "expires", + "scope", + ) + readonly_fields = ("obscured_token",) + inlines = [SecuredRefreshTokenInline] + + badgr_admin.register(AccessTokenProxy, SecuredAccessTokenAdmin) + + +class AltchaAdmin(ModelAdmin): + fields = ("id", "created_at", "used", "used_at", "solved_by_ip") + list_display = ("id", "created_at", "used", "used_at", "solved_by_ip") + list_filter = ("used", "created_at") + search_fields = ("id", "solved_by_ip") + readonly_fields = ("id", "created_at") + + +badgr_admin.register(AltchaChallenge, AltchaAdmin) + +# register admin views from lti_tool library on our admin backend +badgr_admin.register(LtiRegistration, LtiRegistrationAdmin) diff --git a/apps/mainsite/admin_actions.py b/apps/mainsite/admin_actions.py index 795349ce4..22a399602 100644 --- a/apps/mainsite/admin_actions.py +++ b/apps/mainsite/admin_actions.py @@ -7,8 +7,9 @@ from django.core.cache import cache from django.db import router from django.template.response import TemplateResponse -from django.utils.encoding import force_text -from django.utils.translation import ugettext_lazy, ugettext as _ + +from django.utils.translation import gettext_lazy, gettext as _ +from django.utils.encoding import force_str def delete_selected(modeladmin, request, queryset): @@ -28,29 +29,33 @@ def delete_selected(modeladmin, request, queryset): # Populate deletable_objects, a data structure of all related objects that # will also be deleted. deletable_objects, perms_needed, protected = get_deleted_objects( - queryset, opts, request.user, modeladmin.admin_site, using) + queryset, opts, request.user, modeladmin.admin_site, using + ) # The user has already confirmed the deletion. # Do the deletion and return a None to display the change list view again. - if request.POST.get('post'): + if request.POST.get("post"): if perms_needed: raise PermissionDenied n = queryset.count() if n: for obj in queryset: - obj_display = force_text(obj) + obj_display = force_str(obj) modeladmin.log_deletion(request, obj, obj_display) obj.delete() - modeladmin.message_user(request, _("Successfully deleted %(count)d %(items)s.") % { - "count": n, "items": model_ngettext(modeladmin.opts, n) - }, messages.SUCCESS) + modeladmin.message_user( + request, + _("Successfully deleted %(count)d %(items)s.") + % {"count": n, "items": model_ngettext(modeladmin.opts, n)}, + messages.SUCCESS, + ) # Return None to display the change list page again. return None if len(queryset) == 1: - objects_name = force_text(opts.verbose_name) + objects_name = force_str(opts.verbose_name) else: - objects_name = force_text(opts.verbose_name_plural) + objects_name = force_str(opts.verbose_name_plural) if perms_needed or protected: title = _("Cannot delete %(name)s") % {"name": objects_name} @@ -61,21 +66,31 @@ def delete_selected(modeladmin, request, queryset): "title": title, "objects_name": objects_name, "deletable_objects": [deletable_objects], - 'queryset': queryset, + "queryset": queryset, "perms_lacking": perms_needed, "protected": protected, "opts": opts, - 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME, } # Display the confirmation page - return TemplateResponse(request, modeladmin.delete_selected_confirmation_template or [ - "admin/%s/%s/delete_selected_confirmation.html" % (app_label, opts.model_name), - "admin/%s/delete_selected_confirmation.html" % app_label, - "admin/delete_selected_confirmation.html" - ], context, current_app=modeladmin.admin_site.name) + return TemplateResponse( + request, + modeladmin.delete_selected_confirmation_template + or [ + "admin/%s/%s/delete_selected_confirmation.html" + % (app_label, opts.model_name), + "admin/%s/delete_selected_confirmation.html" % app_label, + "admin/delete_selected_confirmation.html", + ], + context, + current_app=modeladmin.admin_site.name, + ) + -delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s") +delete_selected.short_description = gettext_lazy( + "Delete selected %(verbose_name_plural)s" +) def clear_cache(): diff --git a/apps/mainsite/apps.py b/apps/mainsite/apps.py index 8b55cfc6f..27e8b2ce5 100644 --- a/apps/mainsite/apps.py +++ b/apps/mainsite/apps.py @@ -1,14 +1,8 @@ from django.apps import AppConfig -from django.conf import settings - -from corsheaders.signals import check_request_enabled class BadgrConfig(AppConfig): - name = 'mainsite' + name = "mainsite" def ready(self): - # Makes sure all signal handlers are connected - if getattr(settings, 'BADGR_CORS_MODEL'): - from mainsite.signals import cors_allowed_sites - check_request_enabled.connect(cors_allowed_sites) + import mainsite.openapi # noqa: F401 diff --git a/apps/mainsite/authentication.py b/apps/mainsite/authentication.py index 53d170615..76d46d325 100644 --- a/apps/mainsite/authentication.py +++ b/apps/mainsite/authentication.py @@ -5,11 +5,12 @@ from oauth2_provider.models import Application from oauth2_provider.oauth2_backends import get_oauthlib_core from rest_framework.authentication import BaseAuthentication, TokenAuthentication +from rest_framework.permissions import BasePermission -import badgrlog +from apps.mainsite.utils import validate_altcha +import logging - -badgrlogger = badgrlog.BadgrLogger() +logger = logging.getLogger("Badgr.Events") class BadgrOAuth2Authentication(BaseAuthentication): @@ -26,13 +27,22 @@ def authenticate(self, request): oauthlib_core = get_oauthlib_core() valid, r = oauthlib_core.verify_request(request, scopes=[]) if valid: - token_session_timeout = getattr(settings, 'OAUTH2_TOKEN_SESSION_TIMEOUT_SECONDS', None) + token_session_timeout = getattr( + settings, "OAUTH2_TOKEN_SESSION_TIMEOUT_SECONDS", None + ) if token_session_timeout is not None: - half_expiration_ahead = timezone.now() + datetime.timedelta(seconds=token_session_timeout/2) + half_expiration_ahead = timezone.now() + datetime.timedelta( + seconds=token_session_timeout / 2 + ) if r.access_token.expires < half_expiration_ahead: - r.access_token.expires = timezone.now() + datetime.timedelta(seconds=token_session_timeout) + r.access_token.expires = timezone.now() + datetime.timedelta( + seconds=token_session_timeout + ) r.access_token.save() - if r.client.authorization_grant_type == Application.GRANT_CLIENT_CREDENTIALS: + if ( + r.client.authorization_grant_type + == Application.GRANT_CLIENT_CREDENTIALS + ): return r.client.user, r.access_token else: return r.access_token.user, r.access_token @@ -42,7 +52,18 @@ def authenticate(self, request): class LoggedLegacyTokenAuthentication(TokenAuthentication): def authenticate(self, request): - authenticated_credentials = super(LoggedLegacyTokenAuthentication, self).authenticate(request) + authenticated_credentials = super( + LoggedLegacyTokenAuthentication, self + ).authenticate(request) if authenticated_credentials is not None: - badgrlogger.event(badgrlog.DeprecatedApiAuthToken(request, authenticated_credentials[0].username)) + logger.warning("Deprecated auth token") + logger.info("Username: '%s'", authenticated_credentials[0].username) return authenticated_credentials + + +class ValidAltcha(BasePermission): + def has_permission(self, request, view): + if "HTTP_X_OEB_ALTCHA" in request.META: + return validate_altcha(request.META["HTTP_X_OEB_ALTCHA"], request) + + return False diff --git a/apps/mainsite/badge_pdf.py b/apps/mainsite/badge_pdf.py new file mode 100644 index 000000000..411d63cff --- /dev/null +++ b/apps/mainsite/badge_pdf.py @@ -0,0 +1,1063 @@ +import base64 +import math +import os +from functools import partial +from io import BytesIO +from json import loads as json_loads +import cairosvg + + +import qrcode +from badgeuser.models import BadgeUser +from django.conf import settings +from django.db.models import Max +from issuer.models import BadgeInstance, LearningPath +from mainsite.utils import get_name +from django.core.files.storage import DefaultStorage +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.lib.utils import ImageReader +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas +from reportlab.platypus import ( + BaseDocTemplate, + Flowable, + Frame, + Image, + PageBreak, + PageTemplate, + Paragraph, + Spacer, + Table, + TableStyle, + KeepInFrame, +) + +font_path_rubik_regular = os.path.join( + os.path.dirname(__file__), "static", "fonts", "Rubik-Regular.ttf" +) +font_path_rubik_medium = os.path.join( + os.path.dirname(__file__), "static", "fonts", "Rubik-Medium.ttf" +) +font_path_rubik_bold = os.path.join( + os.path.dirname(__file__), "static", "fonts", "Rubik-Bold.ttf" +) +font_path_rubik_italic = os.path.join( + os.path.dirname(__file__), "static", "fonts", "Rubik-Italic.ttf" +) + +pdfmetrics.registerFont(TTFont("Rubik-Regular", font_path_rubik_regular)) +pdfmetrics.registerFont(TTFont("Rubik-Medium", font_path_rubik_medium)) +pdfmetrics.registerFont(TTFont("Rubik-Bold", font_path_rubik_bold)) +pdfmetrics.registerFont(TTFont("Rubik-Italic", font_path_rubik_italic)) + + +class BadgePDFCreator: + def __init__(self): + self.competencies = [] + self.used_space = 0 + + def add_badge_image(self, first_page_content, badgeImage): + image_width = 180 + img = image_file_to_image(badgeImage, image_width=image_width) + first_page_content.append(img) + self.used_space += img.imageHeight if img is not None else 0 + + def add_recipient_name( + self, + first_page_content, + name, + issuedOn, + activityStartDate=None, + activityEndDate=None, + activityCity=None, + activityOnline=False, + ): + first_page_content.append(Spacer(1, 58)) + self.used_space += 58 + recipient_style = ParagraphStyle( + name="Recipient", + fontSize=18, + leading=21.6, + textColor="#492E98", + fontName="Rubik-Bold", + alignment=TA_CENTER, + ) + + recipient_name = f"{name}" + first_page_content.append(Paragraph(recipient_name, recipient_style)) + first_page_content.append(Spacer(1, 25)) + self.used_space += 46.6 # spacer and paragraph + + text_style = ParagraphStyle(name="Text_Style", fontSize=18, alignment=TA_CENTER) + + if ( + activityStartDate + and activityEndDate + and activityStartDate != activityEndDate + ): + if activityStartDate.year == activityEndDate.year: + date_text = ( + f"{activityStartDate.strftime('%d.%m.')}" + f" – {activityEndDate.strftime('%d.%m.%Y')}" + ) + else: + date_text = ( + f"{activityStartDate.strftime('%d.%m.%Y')}" + f" – {activityEndDate.strftime('%d.%m.%Y')}" + ) + text = "hat vom " + date_text + elif activityStartDate: + date_text = f"{activityStartDate.strftime('%d.%m.%Y')}" + text = "hat am " + date_text + else: + date_text = f"{issuedOn.strftime('%d.%m.%Y')}" + text = "hat am " + date_text + + if activityCity: + text += f" in {activityCity}" + elif activityOnline: + text += " online" + + first_page_content.append(Paragraph(text, text_style)) + + first_page_content.append(Spacer(1, 10)) + self.used_space += 28 # spacer and paragraph + + text = "den folgenden Badge erworben:" + first_page_content.append(Paragraph(text, text_style)) + first_page_content.append(Spacer(1, 25)) + self.used_space += 43 # spacer and paragraph + + def add_title(self, first_page_content, badge_class_name): + document_width, _ = A4 + line_height = 30 + title_style = ParagraphStyle( + name="Title", + fontSize=20, + textColor="#492E98", + fontName="Rubik-Bold", + leading=line_height, + alignment=TA_CENTER, + ) + first_page_content.append(Spacer(1, 10)) + + title = badge_class_name + width = document_width - 40 + max_h = line_height * 2 + p = Paragraph(f"{title}", title_style) + p.wrap(width, max_h) + if len(p.blPara.lines) <= 2: + first_page_content.append(KeepInFrame(width, max_h, [p])) + else: + ellipsis = "\u2026" + words = title.split() + while words: + trial = " ".join(words) + ellipsis + p = Paragraph(f"{trial}", title_style) + p.wrap(width, max_h) + if len(p.blPara.lines) <= 2: + first_page_content.append(KeepInFrame(width, max_h, [p])) + break + words.pop() + first_page_content.append(Spacer(1, 15)) + self.used_space += ( + len(p.blPara.lines) * line_height + 25 + ) # badge class name and spaces + + def truncate_text(text, max_words=70): + words = text.split() + if len(words) > max_words: + return " ".join(words[:max_words]) + "..." + else: + return text + + def add_dynamic_spacer(self, first_page_content, text): + line_char_count = 79 + line_height = 16.5 + num_lines = math.ceil(len(text) / line_char_count) + spacer_height = 160 - (num_lines - 1) * line_height + spacer_height = max(spacer_height, 0) + first_page_content.append(Spacer(1, spacer_height)) + self.used_space += spacer_height + + def add_description(self, first_page_content, description): + description_style = ParagraphStyle( + name="Description", + fontSize=12, + fontName="Rubik-Regular", + leading=16.5, + alignment=TA_CENTER, + leftIndent=20, + rightIndent=20, + ) + first_page_content.append(Paragraph(description, description_style)) + line_char_count = 79 + line_height = 16.5 + num_lines = math.ceil(len(description) / line_char_count) + self.used_space += num_lines * line_height + + def add_issued_by(self, first_page_content, issued_by, qrCodeImage=None): + issued_by_style = ParagraphStyle( + name="IssuedBy", + fontSize=10, + textColor="#323232", + fontName="Rubik-Medium", + alignment=TA_CENTER, + backColor="#F5F5F5", + leftIndent=-45, + rightIndent=-45, + ) + # use document width to calculate the table and its size + document_width, _ = A4 + + qr_code_height = 0 + if qrCodeImage: + if qrCodeImage.startswith("data:image"): + qrCodeImage = qrCodeImage.split(",")[1] # Entfernt das Präfix + + image = base64.b64decode(qrCodeImage) + qrCodeImage = BytesIO(image) + qrCodeImage = ImageReader(qrCodeImage) + + rounded_img = RoundedImage( + img_path=qrCodeImage, + width=57, + height=57, + border_color="#492E98", + border_width=1, + padding=1, + radius=2 * mm, + ) + + img_table = Table([[rounded_img]], colWidths=[document_width]) + img_table.hAlign = "CENTER" + img_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F5F5F5")), + ( + "LEFTPADDING", + (0, 0), + (-1, -1), + -45, + ), # Negative padding to extend beyond the document margin + ("RIGHTPADDING", (0, 0), (-1, -1), -45), + ("TOPPADDING", (0, 0), (-1, -1), 20), + ] + ) + ) + first_page_content.append(img_table) + qr_code_height = 57 + 20 + + # Add the simple html + content_html = """ +
    ERSTELLT ÜBER OPENBADGES.EDUCATION +
    + Der digitale Badge kann Ãŧber den QR-Code abgerufen werden +

    + """ + + # Add content as a Paragraph to first_page_content + first_page_content.append(Paragraph(content_html, issued_by_style)) + + paragraph_height = 60 + self.used_space += qr_code_height + paragraph_height + + # draw header with image of institution and a hr + def header(self, canvas, doc, content, instituteName): + canvas.saveState() + header_height = 0 + + if content is not None: + # for non-square images move them into the center of the 80x80 reserved space + # by going up 40 and then down half the height of the image. + # If the image is 80 the offset will be 0 and thus the picture is placed at 740 + content.drawOn(canvas, doc.leftMargin, 740 + (40 - content.drawHeight / 2)) + header_height += content.drawHeight + + canvas.setStrokeColor("#492E98") + canvas.setLineWidth(1) + canvas.line(doc.leftMargin + 100, 775, doc.leftMargin + doc.width, 775) + header_height += 1 + # name of institute barely above the hr that was just set + canvas.setFont("Rubik-Medium", 12) + max_length = 50 + line_height = 12 + # logic if a linebreak is needed + if len(instituteName) > max_length: + split_index = instituteName.rfind(" ", 0, max_length) + if split_index == -1: + split_index = max_length + + line1 = instituteName[:split_index] + line2 = instituteName[split_index:].strip() + + canvas.drawString(doc.leftMargin + 100, 778 + line_height, line1) + canvas.drawString(doc.leftMargin + 100, 778, line2) + header_height += 2 * line_height + else: + canvas.drawString(doc.leftMargin + 100, 778, instituteName) + header_height += line_height + + self.used_space += header_height + canvas.restoreState() + + def add_competencies(self, Story, competencies, name, badge_name): + num_competencies = len(competencies) + page_used_space = 0 + + if num_competencies > 0: + max_studyload = str(max(c["studyLoad"] for c in competencies)) + competenciesPerPage = 9 + + Story.append(PageBreak()) + Story.append(Spacer(1, 70)) + page_used_space += 70 + + title_style = ParagraphStyle( + name="Title", + fontSize=20, + fontName="Rubik-Medium", + textColor="#492E98", + alignment=TA_LEFT, + textTransform="uppercase", + ) + text_style = ParagraphStyle( + name="Text", + fontSize=18, + leading=20, + textColor="#323232", + alignment=TA_LEFT, + ) + + Story.append(Paragraph("Kompetenzen", title_style)) + Story.append(Spacer(1, 15)) + page_used_space += 35 # Title height + spacing + + text = f"die {name} mit dem Badge {badge_name} erworben hat:" + Story.append(Paragraph(text, text_style)) + Story.append(Spacer(1, 10)) + page_used_space += 30 # Text height + spacer + + for i in range(num_competencies): + if i != 0 and i % competenciesPerPage == 0: + Story.append(PageBreak()) + page_used_space = 0 + + Story.append(Spacer(1, 70)) + page_used_space += 70 + + Story.append(Paragraph("Kompetenzen", title_style)) + Story.append(Spacer(1, 15)) + page_used_space += 35 # Title height + spacer + + text = f"die {name} mit dem Badge {badge_name} erworben hat:" + Story.append(Paragraph(text, text_style)) + Story.append(Spacer(1, 20)) + page_used_space += 40 # Text height + spacer + + studyload = "%s:%s h" % ( + math.floor(competencies[i]["studyLoad"] / 60), + str(competencies[i]["studyLoad"] % 60).zfill(2), + ) + competency_name = competencies[i]["name"] + competency = competency_name + if competencies[i] not in self.competencies: + self.competencies.append(competencies[i]) + rounded_rect = RoundedRectFlowable( + 0, + -10, + 515, + 45, + 10, + text=competency, + strokecolor="#492E98", + fillcolor="#F5F5F5", + studyload=studyload, + max_studyload=max_studyload, + esco=competencies[i]["framework_identifier"], + ) + Story.append(rounded_rect) + Story.append(Spacer(1, 10)) + page_used_space += 55 # RoundedRectFlowable height 45 + spacing + + self.used_space += page_used_space + + def add_learningpath_badges(self, Story, badges, name, badge_name, competencies): + num_badges = len(badges) + if num_badges > 0: + badgesPerPage = 5 + + Story.append(PageBreak()) + Story.append(Spacer(1, 70)) + + title_style = ParagraphStyle( + name="Title", + fontSize=20, + fontName="Rubik-Medium", + textColor="#492E98", + alignment=TA_LEFT, + textTransform="uppercase", + ) + text_style = ParagraphStyle( + name="Text", + fontSize=18, + leading=20, + textColor="#323232", + alignment=TA_LEFT, + ) + + Story.append(Paragraph("Badges", title_style)) + + Story.append(Spacer(1, 15)) + + text = f"die {name} mit dem Micro Degree {badge_name} erworben hat:" + Story.append(Paragraph(text, text_style)) + Story.append(Spacer(1, 30)) + + for i in range(num_badges): + extensions = badges[i].badgeclass.cached_extensions() + categoryExtension = extensions.get(name="extensions:CategoryExtension") + category = json_loads(categoryExtension.original_json)["Category"] + if category == "competency": + competencies = badges[i].badgeclass.json[ + "extensions:CompetencyExtension" + ] + for competency in competencies: + if competency not in self.competencies: + self.competencies.append(competency) + + if i != 0 and i % badgesPerPage == 0: + Story.append(PageBreak()) + Story.append(Spacer(1, 70)) + Story.append(Paragraph("Badges", title_style)) + Story.append(Spacer(1, 15)) + + text = ( + f"die {name} mit dem Micro Degree" + f"{badge_name} erworben hat:" + ) + + Story.append(Paragraph(text, text_style)) + Story.append(Spacer(1, 30)) + + img = image_file_to_image(badges[i].image, 74) + + lp_badge_info_style = ParagraphStyle( + name="Text", + fontSize=14, + leading=16.8, + textColor="#323232", + alignment=TA_LEFT, + ) + + badge_title = Paragraph( + f"{badges[i].badgeclass.name}", lp_badge_info_style + ) + issuer = Paragraph( + badges[i].badgeclass.issuer.name, lp_badge_info_style + ) + date = Paragraph( + badges[i].issued_on.strftime("%d.%m.%Y"), lp_badge_info_style + ) + data = [[img, [badge_title, Spacer(1, 10), issuer, Spacer(1, 5), date]]] + + table = Table(data, colWidths=[100, 450]) + + table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("LEFTPADDING", (1, 0), (1, 0), 12), + ("BOTTOMPADDING", (0, 0), (-1, -1), 20), + ] + ) + ) + + Story.append(table) + Story.append(Spacer(1, 10)) + + self.add_competencies(Story, self.competencies, name, badge_name) + + def generate_qr_code(self, badge_instance, origin): + # build the qr code in the backend + + qrCodeImageUrl = f"{origin}/public/assertions/{badge_instance.entity_id}" + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(qrCodeImageUrl) + qr.make(fit=True) + qrCodeImage = qr.make_image(fill_color="black", back_color="white") + + buffered = BytesIO() + qrCodeImage.save(buffered, format="PNG") + qrCodeImageBase64 = base64.b64encode(buffered.getvalue()).decode("utf-8") + return qrCodeImageBase64 + + def add_criteria(self, Story, criteria): + if not criteria: + return + + title_style = ParagraphStyle( + name="Title", + fontSize=20, + fontName="Rubik-Medium", + textColor="#492E98", + alignment=TA_LEFT, + textTransform="uppercase", + ) + + Story.append(Spacer(1, 30)) + self.used_space += 30 + + Story.append(Paragraph("Vergabe-Kriterien", title_style)) + Story.append(Spacer(1, 15)) + self.used_space += 35 + + name_style = ParagraphStyle( + name="Name", fontSize=16, leading=18, textColor="#323232", alignment=TA_LEFT + ) + + description_style = ParagraphStyle( + name="Description", + fontSize=14, + leading=16, + textColor="#777777", + alignment=TA_LEFT, + fontName="Rubik-Italic", + ) + + for index, item in enumerate(criteria): + criteria_space = 15 + 18 # criteria name line height + spacing + + # Check if adding criteria would exceed the page + if self.used_space + criteria_space > 680: + Story.append(PageBreak()) + Story.append(Spacer(1, 70)) + + Story.append(Paragraph("Vergabe-Kriterien", title_style)) + Story.append(Spacer(1, 15)) + + # Reset used space counter with the header space + self.used_space = 70 + 35 # Header spacer + title and spacing + + if "name" in item and item["name"]: + bullet_text = f"â€ĸ {item['name']}" + Story.append(Spacer(1, 15)) + Story.append(Paragraph(bullet_text, name_style)) + self.used_space += criteria_space + + if "description" in item and item["description"]: + line_char_count = 79 + line_height = 16.5 + num_lines = math.ceil(len(item["description"]) / line_char_count) + criteria_space += num_lines * line_height + if self.used_space + criteria_space > 750: + Story.append(PageBreak()) + Story.append(Spacer(1, 70)) + Story.append(Paragraph("Vergabe-Kriterien", title_style)) + Story.append(Spacer(1, 15)) + self.used_space = 70 + 35 # Header spacer + title and spacing + Story.append(Paragraph(item["description"], description_style)) + self.used_space += criteria_space + + else: + self.used_space += num_lines * line_height + Story.append(Spacer(1, 5)) + Story.append(Paragraph(item["description"], description_style)) + + Story.append(Spacer(1, 15)) + self.used_space += 15 + + def add_evidence(self, Story, evidence_items, narrative, category): + """ + Adds the evidence section to the Story + + evidence_items: list of dicts from JSONField + narrative: string (same for all list items) + category: badge category + """ + + if not evidence_items and not narrative: + return + + title_style = ParagraphStyle( + name="EvidenceTitle", + fontSize=20, + fontName="Rubik-Medium", + textColor="#492E98", + alignment=TA_LEFT, + textTransform="uppercase", + ) + + narrative_style = ParagraphStyle( + name="Narrative", + fontSize=16, + leading=18, + textColor="#323232", + alignment=TA_LEFT, + ) + + linknote_style = ParagraphStyle( + name="LinkNote", + fontSize=12, + leading=17, + textColor="#323232", + alignment=TA_LEFT, + ) + + space_needed = 0 + title_height = 20 + 15 # font size + spacer + space_needed += title_height + + has_evidence_url = any(item.evidence_url for item in (evidence_items or [])) + if has_evidence_url: + space_needed += 10 + 16 + 10 # spacer + icon + spacer + + narratives = [ + item.narrative for item in (evidence_items or []) if item.narrative + ] + if narrative or narratives: + narrative_text = narratives[0] if narratives else narrative + line_char_count = 79 + line_height = 18 + num_lines = math.ceil(len(narrative_text) / line_char_count) + narrative_height = num_lines * line_height + 15 + space_needed += narrative_height + + # some top spacing before section + space_needed += 30 + + if self.used_space + space_needed > 680 or category == "participation": + Story.append(PageBreak()) + Story.append(Spacer(1, 70)) + self.used_space = 70 # reset used space with header + else: + self.used_space += 30 # top spacer + + Story.append(Paragraph("Narrativ", title_style)) + Story.append(Spacer(1, 15)) + self.used_space += 35 # title + spacer + + if has_evidence_url: + Story.append(Spacer(1, 10)) + icon_path = os.path.join(settings.STATIC_URL, "images/external_link.png") + icon_img = Image(icon_path, width=16, height=16) + + t = Table( + [ + [ + icon_img, + Paragraph( + "Auf der Badge-Detail-Seite ist ein Link zum Nachweis hinterlegt (s. QR-Code, Seite 1).", + linknote_style, + ), + ] + ], + colWidths=[20, 475], + ) + t.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ] + ) + ) + Story.append(t) + Story.append(Spacer(1, 10)) + self.used_space += 40 + + if narrative or narratives: + narrative_text = narratives[0] if narratives else narrative + Story.append(Paragraph(narrative_text, narrative_style)) + Story.append(Spacer(1, 15)) + self.used_space += narrative_height + + def generate_pdf(self, badge_instance, badge_class, origin): + buffer = BytesIO() + competencies = badge_class.json["extensions:CompetencyExtension"] + criteria = badge_class.criteria + try: + name = get_name(badge_instance) + except BadgeUser.DoesNotExist: + # To resolve the issue with old awarded badges that doesn't + # include recipient-name and only have recipient-email + # We use email as this is the only identifier we have + name = badge_instance.recipient_identifier + # raise Http404 + + self.used_space = 0 + + first_page_content = [] + + self.add_recipient_name( + first_page_content, + name, + badge_instance.issued_on, + activityStartDate=badge_instance.activity_start_date, + activityEndDate=badge_instance.activity_end_date, + activityCity=badge_instance.activity_city, + activityOnline=badge_instance.activity_online, + ) + self.add_badge_image(first_page_content, badge_instance.image) + self.add_title(first_page_content, badge_class.name) + self.add_description(first_page_content, badge_class.description) + self.add_dynamic_spacer(first_page_content, (badge_class.description or "")) + self.add_issued_by( + first_page_content, + badge_class.issuer.name, + self.generate_qr_code(badge_instance, origin), + ) + + # doc template with margins according to design doc + doc = BaseDocTemplate( + buffer, + pagesize=A4, + leftMargin=40, + rightMargin=40, + topMargin=40, + bottomMargin=40, + ) + + styles = getSampleStyleSheet() + styles.add(ParagraphStyle(name="Justify", alignment=TA_JUSTIFY)) + + Story = [] + Story.extend(first_page_content) + + extensions = badge_class.cached_extensions() + categoryExtension = extensions.get(name="extensions:CategoryExtension") + category = json_loads(categoryExtension.original_json)["Category"] + + if category == "learningpath": + lp = LearningPath.objects.filter(participationBadge=badge_class).first() + lp_badges = [badge.badge for badge in lp.learningpath_badges] + badgeuser = BadgeUser.objects.get(email=badge_instance.recipient_identifier) + badge_ids = ( + BadgeInstance.objects.filter( + badgeclass__in=lp_badges, + recipient_identifier__in=badgeuser.verified_emails, + ) + .values("badgeclass") + .annotate(max_id=Max("id")) + .values_list("max_id", flat=True) + ) + + badgeinstances = BadgeInstance.objects.filter(id__in=badge_ids) + self.add_learningpath_badges( + Story, badgeinstances, name, badge_class.name, competencies=competencies + ) + else: + self.used_space = 0 # Reset used_space for competencies page + self.add_competencies(Story, competencies, name, badge_class.name) + self.add_criteria(Story, criteria) + self.add_evidence( + Story, + evidence_items=badge_instance.evidence_items, + narrative=badge_instance.narrative, + category=category, + ) + + frame = Frame( + doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="normal" + ) + + try: + imageContent = image_file_to_image(badge_instance.issuer.image) + except Exception: + imageContent = None + template = PageTemplate( + id="header", + frames=frame, + onPage=partial( + self.header, + content=imageContent, + instituteName=badge_instance.issuer.name, + ), + ) + doc.addPageTemplates([template]) + doc.build(Story, canvasmaker=partial(PageNumCanvas, self.competencies)) + pdfContent = buffer.getvalue() + buffer.close() + return pdfContent + + +# Class for rounded image as reportlabs table cell don't support rounded corners +# taken from AI +class RoundedImage(Flowable): + def __init__( + self, img_path, width, height, border_color, border_width, padding, radius + ): + super().__init__() + self.img_path = img_path + self.width = width + self.height = height + self.border_color = border_color + self.border_width = border_width + self.padding = padding + self.radius = radius + + def draw(self): + # Calculate total padding to prevent image overlap + total_padding = self.padding + self.border_width + 1.8 + + # Draw the rounded rectangle for the border + canvas = self.canv + canvas.setFillColor("white") + canvas.setStrokeColor(self.border_color) + canvas.setLineWidth(self.border_width) + canvas.roundRect( + 0, # Start at the lower-left corner of the Flowable + 0, + self.width + 2 * total_padding, # Width includes padding on both sides + self.height + 2 * total_padding, # Height includes padding on both sides + self.radius, # Radius for rounded corners, + stroke=1, + fill=1, + ) + + # Draw the image inside the rounded rectangle + canvas.drawImage( + self.img_path, + total_padding, # Offset by total padding to stay within rounded border + total_padding, + width=self.width, + height=self.height, + mask="auto", + ) + + +class RoundedRectFlowable(Flowable): + def __init__( + self, + x, + y, + width, + height, + radius, + text, + strokecolor, + fillcolor, + studyload, + max_studyload, + esco="", + ): + super().__init__() + self.x = x + self.y = y + self.width = width + self.height = height + self.radius = radius + self.strokecolor = strokecolor + self.fillcolor = fillcolor + self.text = text + self.studyload = studyload + self.max_studyload = max_studyload + self.esco = esco + + def split_text(self, text, max_width): + words = text.split() + lines = [] + current_line = "" + + for word in words: + test_line = f"{current_line} {word}".strip() + if self.canv.stringWidth(test_line, "Rubik-Medium", 12) <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + + lines.append(current_line) + return lines + + def draw(self): + self.canv.setFillColor(self.fillcolor) + self.canv.setStrokeColor(self.strokecolor) + self.canv.roundRect( + self.x, self.y, self.width, self.height, self.radius, stroke=1, fill=1 + ) + + self.canv.setFillColor("#323232") + text_width = self.canv.stringWidth(self.text) + self.canv.setFont("Rubik-Medium", 12) + if text_width > self.width - 175: + available_text_width = self.width - 150 + y_text_position = self.y + 25 + else: + available_text_width = self.width - 150 + y_text_position = self.y + 17.5 + + text_lines = self.split_text(self.text, available_text_width) + + for line in text_lines: + self.canv.drawString(self.x + 10, y_text_position, line) + y_text_position -= 15 + + self.canv.setFillColor("blue") + if self.esco: + last_line_width = self.canv.stringWidth(text_lines[-1]) + self.canv.setFillColor("blue") + self.canv.drawString( + self.x + 10 + last_line_width, y_text_position + 15, " [E]" + ) + self.canv.linkURL( + self.esco, + (self.x, self.y, self.width, self.height), + relative=1, + thickness=0, + ) + + self.canv.setFillColor("#492E98") + self.canv.setFont("Rubik-Regular", 14) + studyload_width = self.canv.stringWidth(self.studyload) + self.canv.drawString( + self.x + 500 - (studyload_width + 10), self.y + 15, self.studyload + ) + + max_studyload_width = self.canv.stringWidth(self.max_studyload) + clockIcon = ImageReader("{}images/clock-icon.png".format(settings.STATIC_URL)) + self.canv.drawImage( + clockIcon, + self.x + 475 - (max_studyload_width + 35), + self.y + 12.5, + width=15, + height=15, + mask="auto", + preserveAspectRatio=True, + ) + + +# Inspired by https://www.blog.pythonlibrary.org/2013/08/12/reportlab-how-to-add-page-numbers/ +class PageNumCanvas(canvas.Canvas): + """ + http://code.activestate.com/recipes/546511-page-x-of-y-with-reportlab/ + http://code.activestate.com/recipes/576832/ + """ + + # ---------------------------------------------------------------------- + def __init__(self, competencies, *args, **kwargs): + """Constructor""" + canvas.Canvas.__init__(self, *args, **kwargs) + self.pages = [] + self.competencies = competencies + + # ---------------------------------------------------------------------- + def showPage(self): + """ + On a page break, add information to the list + """ + self.pages.append(dict(self.__dict__)) + self._startPage() + + # ---------------------------------------------------------------------- + def save(self): + """ + Add the page number to each page (page x of y) + """ + page_count = len(self.pages) + + for page in self.pages: + self.__dict__.update(page) + self.draw_page_number(page_count) + canvas.Canvas.showPage(self) + + canvas.Canvas.save(self) + + # ---------------------------------------------------------------------- + def draw_page_number(self, page_count): + """ + Add the page number + """ + page = "%s / %s" % (self._pageNumber, page_count) + self.setStrokeColor("#492E98") + page_width = self._pagesize[0] + self.line(10, 17.5, page_width / 2 - 20, 17.5) + self.line(page_width / 2 + 20, 17.5, page_width - 10, 17.5) + self.setFont("Rubik-Regular", 10) + self.drawCentredString(page_width / 2, 15, page) + if self._pageNumber == page_count: + self.setLineWidth(3) + self.line(10, 10, page_width - 10, 10) + num_competencies = len(self.competencies) + if num_competencies > 0: + esco = any(c["framework"] for c in self.competencies) + if esco: + self.draw_esco_info(page_width) + + # Draws ESCO competency information + def draw_esco_info(self, page_width): + self.setStrokeColor("#777777") + self.setLineWidth(1) + self.line(10, 75, page_width, 75) + text_style = ParagraphStyle( + name="Text_Style", + fontName="Rubik-Italic", + fontSize=10, + leading=13, + alignment=TA_CENTER, + leftIndent=-35, + rightIndent=-35, + ) + link_text = ( + "(E) = Kompetenz nach ESCO (European Skills, Competences, Qualifications and Occupations).
    " + 'Die Kompetenzbeschreibungen gemäß ESCO sind abrufbar Ãŧber "' + 'https://esco.ec.europa.eu/de.
    ' + ) + paragraph_with_link = Paragraph(link_text, text_style) + story = [paragraph_with_link] + story[0].wrapOn(self, page_width - 20, 50) + story[0].drawOn(self, 10, 40) + + +def image_file_to_image(image, image_width=80): + file_ext = image.path.split(".")[-1].lower() + imageContent = None + if file_ext == "svg": + storage = DefaultStorage() + bio = BytesIO() + file_path = image.name + try: + with storage.open(file_path, "rb") as svg_file: + cairosvg.svg2png(file_obj=svg_file, write_to=bio, dpi=300, scale=4) + except IOError: + raise ValueError(f"Failed to convert SVG to PNG: {image}") + + bio.seek(0) + dummy = Image(bio) + aspect = dummy.imageHeight / dummy.imageWidth + imageContent = Image(bio, width=image_width, height=image_width * aspect) + elif file_ext in ["png", "jpg", "jpeg", "gif"]: + dummy = Image(image) + aspect = dummy.imageHeight / dummy.imageWidth + try: + image.open() + img_data = BytesIO(image.read()) + image.close() + imageContent = Image( + img_data, width=image_width, height=image_width * aspect + ) + except Exception as e: + print(f"Unexpected error for image {image}: {e}") + else: + raise ValueError(f"Unsupported file type: {file_ext}") + + return imageContent diff --git a/apps/mainsite/blacklist.py b/apps/mainsite/blacklist.py index 9b889e1ba..32fd45072 100644 --- a/apps/mainsite/blacklist.py +++ b/apps/mainsite/blacklist.py @@ -7,18 +7,21 @@ def api_submit_recipient_id(id_type, recipient_id): - blacklist_api_key = getattr(settings, 'BADGR_BLACKLIST_API_KEY', None) - blacklist_query_endpoint = getattr(settings, 'BADGR_BLACKLIST_QUERY_ENDPOINT', None) + blacklist_api_key = getattr(settings, "BADGR_BLACKLIST_API_KEY", None) + blacklist_query_endpoint = getattr(settings, "BADGR_BLACKLIST_QUERY_ENDPOINT", None) if blacklist_api_key and blacklist_query_endpoint: recipient_id_hash = generate_hash(id_type, recipient_id) try: response = requests.post( - blacklist_query_endpoint, json={"id": recipient_id_hash}, headers={ + blacklist_query_endpoint, + json={"id": recipient_id_hash}, + headers={ "Authorization": "BEARER {api_key}".format( api_key=blacklist_api_key ), - }) + }, + ) except ConnectionError: return None @@ -27,18 +30,21 @@ def api_submit_recipient_id(id_type, recipient_id): return None -def api_query_recipient_id(id_type, recipient_id, blacklist_query_endpoint, blacklist_api_key): +def api_query_recipient_id( + id_type, recipient_id, blacklist_query_endpoint, blacklist_api_key +): recipient_id_hash = generate_hash(id_type, recipient_id) request_query = "{endpoint}?id={recipient_id_hash}".format( - endpoint=blacklist_query_endpoint, - recipient_id_hash=recipient_id_hash) + endpoint=blacklist_query_endpoint, recipient_id_hash=recipient_id_hash + ) try: - response = requests.get(request_query, headers={ - "Authorization": "BEARER {api_key}".format( - api_key=blacklist_api_key - ), - }) + response = requests.get( + request_query, + headers={ + "Authorization": "BEARER {api_key}".format(api_key=blacklist_api_key), + }, + ) except ConnectionError: return None @@ -46,10 +52,12 @@ def api_query_recipient_id(id_type, recipient_id, blacklist_query_endpoint, blac def api_query_is_in_blacklist(id_type, recipient_id): - blacklist_api_key = getattr(settings, 'BADGR_BLACKLIST_API_KEY', None) - blacklist_query_endpoint = getattr(settings, 'BADGR_BLACKLIST_QUERY_ENDPOINT', None) + blacklist_api_key = getattr(settings, "BADGR_BLACKLIST_API_KEY", None) + blacklist_query_endpoint = getattr(settings, "BADGR_BLACKLIST_QUERY_ENDPOINT", None) if blacklist_query_endpoint and blacklist_api_key: - response = api_query_recipient_id(id_type, recipient_id, blacklist_query_endpoint, blacklist_api_key) + response = api_query_recipient_id( + id_type, recipient_id, blacklist_query_endpoint, blacklist_api_key + ) if response and response.status_code == 200: query = response.json() @@ -66,5 +74,6 @@ def api_query_is_in_blacklist(id_type, recipient_id): def generate_hash(id_type, id_value): - return "{id_type}$sha256${hash}".format(id_type=id_type, - hash=sha256(id_value.encode('utf-8')).hexdigest()) + return "{id_type}$sha256${hash}".format( + id_type=id_type, hash=sha256(id_value.encode("utf-8")).hexdigest() + ) diff --git a/apps/mainsite/celery.py b/apps/mainsite/celery.py index a73ee7ab9..d78af43a8 100644 --- a/apps/mainsite/celery.py +++ b/apps/mainsite/celery.py @@ -3,9 +3,8 @@ from celery import Celery import os -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mainsite.settings_local') -app = Celery('mainsite') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mainsite.settings_local") +app = Celery("mainsite") -app.config_from_object('django.conf:settings') +app.config_from_object("django.conf:settings") app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) - diff --git a/apps/mainsite/collection_pdf.py b/apps/mainsite/collection_pdf.py new file mode 100644 index 000000000..c6cd23e4e --- /dev/null +++ b/apps/mainsite/collection_pdf.py @@ -0,0 +1,562 @@ +import base64 +import math +import os +from functools import partial +from io import BytesIO + +import qrcode +from django.conf import settings +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch, mm +from reportlab.lib.utils import ImageReader +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas +from reportlab.platypus import ( + BaseDocTemplate, + Flowable, + Frame, + PageBreak, + PageTemplate, + Paragraph, + Spacer, + Table, + TableStyle, +) + +from apps.mainsite.badge_pdf import image_file_to_image + +font_path_rubik_regular = os.path.join( + os.path.dirname(__file__), "static", "fonts", "Rubik-Regular.ttf" +) +font_path_rubik_medium = os.path.join( + os.path.dirname(__file__), "static", "fonts", "Rubik-Medium.ttf" +) +font_path_rubik_bold = os.path.join( + os.path.dirname(__file__), "static", "fonts", "Rubik-Bold.ttf" +) +font_path_rubik_italic = os.path.join( + os.path.dirname(__file__), "static", "fonts", "Rubik-Italic.ttf" +) + +pdfmetrics.registerFont(TTFont("Rubik-Regular", font_path_rubik_regular)) +pdfmetrics.registerFont(TTFont("Rubik-Medium", font_path_rubik_medium)) +pdfmetrics.registerFont(TTFont("Rubik-Bold", font_path_rubik_bold)) +pdfmetrics.registerFont(TTFont("Rubik-Italic", font_path_rubik_italic)) + + +class CollectionPDFCreator: + def __init__(self): + self.used_space = 0 + + def add_title(self, first_page_content, title): + first_page_content.append(Spacer(1, 50)) + title_style = ParagraphStyle( + name="Title", + fontSize=20, + textColor="#492E98", + fontName="Rubik-Bold", + leading=30, + alignment=TA_CENTER, + ) + first_page_content.append(Spacer(1, 10)) + first_page_content.append(Paragraph(f"{title}", title_style)) + first_page_content.append(Spacer(1, 15)) + self.used_space += 105 # Three spacers and paragraph + + def add_description(self, first_page_content, description): + description_style = ParagraphStyle( + name="Description", + fontSize=12, + fontName="Rubik-Regular", + leading=16.5, + alignment=TA_CENTER, + leftIndent=20, + rightIndent=20, + ) + first_page_content.append(Paragraph(description, description_style)) + line_char_count = 79 + line_height = 16.5 + num_lines = math.ceil(len(description) / line_char_count) + self.used_space += num_lines * line_height + + def add_badges(self, first_page_content, badges): + PAGE_WIDTH, PAGE_HEIGHT = A4 + CONTENT_WIDTH = PAGE_WIDTH - 40 # Accounting for margins + ITEM_WIDTH = CONTENT_WIDTH / 3 # 3 items per row + ITEM_HEIGHT = 1 * inch + ITEM_MARGIN = 0.2 * inch + + rows = [] + current_row = [] + + rowsPerPage = 6 # 18 badges + + for i, badge in enumerate(badges): + b = BadgeCard( + badge.image, + badge.badgeclass.name, + badge.badgeclass.issuer.name, + badge.issued_on, + width=ITEM_WIDTH - ITEM_MARGIN, + height=ITEM_HEIGHT, + ) + + current_row.append(b) + + if len(current_row) == 3: + rows.append(current_row) + current_row = [] + + if current_row: + while len(current_row) < 3: + current_row.append(Spacer(ITEM_WIDTH - ITEM_MARGIN, ITEM_HEIGHT)) + rows.append(current_row) + + for i, row in enumerate(rows): + if i != 0 and i % rowsPerPage == 0: + first_page_content.append(PageBreak()) + first_page_content.append(Spacer(1, 70)) + self.used_space = 0 + table = Table([row], colWidths=[ITEM_WIDTH - ITEM_MARGIN / 2] * 3) + table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("RIGHTPADDING", (0, 0), (-1, -1), ITEM_MARGIN / 2), + ("LEFTPADDING", (0, 0), (-1, -1), ITEM_MARGIN / 2), + ] + ) + ) + + first_page_content.append(table) + first_page_content.append(Spacer(1, 10)) + self.used_space += ITEM_HEIGHT + 10 + + def add_qrcode_section( + self, first_page_content, entity_id, origin: str, qrCodeImage=None + ): + issued_by_style = ParagraphStyle( + name="IssuedBy", + fontSize=10, + textColor="#323232", + fontName="Rubik-Medium", + alignment=TA_CENTER, + backColor="#F5F5F5", + leftIndent=-45, + rightIndent=-45, + ) + document_width, _ = A4 + + qr_code_height = 0 + if qrCodeImage: + if qrCodeImage.startswith("data:image"): + qrCodeImage = qrCodeImage.split(",")[1] # Entfernt das Präfix + + image = base64.b64decode(qrCodeImage) + qrCodeImage = BytesIO(image) + qrCodeImage = ImageReader(qrCodeImage) + + rounded_img = RoundedImage( + img_path=qrCodeImage, + width=57, + height=57, + border_color="#492E98", + border_width=1, + padding=1, + radius=2 * mm, + ) + + img_table = Table([[rounded_img]], colWidths=[document_width]) + img_table.hAlign = "CENTER" + img_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (0, 0), "CENTER"), + ("VALIGN", (0, 0), (0, 0), "MIDDLE"), + ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F5F5F5")), + ( + "LEFTPADDING", + (0, 0), + (-1, -1), + -45, + ), # Negative padding to extend beyond the document margin + ("RIGHTPADDING", (0, 0), (-1, -1), -45), + ("TOPPADDING", (0, 0), (-1, -1), 20), + ] + ) + ) + first_page_content.append(img_table) + qr_code_height = 57 + 20 + + content_html = f""" +

    + Detaillierte Infos zu den einzelnen Badges
    + und den damit gestärkten Kompetenzen
    erhalten Sie
    Ãŧber den QR-Code oder diesen Link: + Digitale Badge-Sammlung

    +
    +

    + """ + + first_page_content.append(Paragraph(content_html, issued_by_style)) + + paragraph_height = 60 + self.used_space += qr_code_height + paragraph_height + + def header(self, canvas, doc, userName): + canvas.saveState() + header_height = 0 + + oebLogo = ImageReader("{}images/logo-square.png".format(settings.STATIC_URL)) + + if oebLogo is not None: + canvas.drawImage( + oebLogo, + 20, + 740, + width=80, + height=80, + mask="auto", + preserveAspectRatio=True, + ) + header_height += 80 + + canvas.setStrokeColor("#492E98") + canvas.setLineWidth(1) + canvas.line(doc.leftMargin + 100, 775, doc.leftMargin + doc.width, 775) + header_height += 1 + canvas.setFont("Rubik-Medium", 12) + max_length = 50 + line_height = 12 + # logic if a linebreak is needed + if len(userName) > max_length: + split_index = userName.rfind(" ", 0, max_length) + if split_index == -1: + split_index = max_length + + line1 = userName[:split_index] + line2 = userName[split_index:].strip() + + canvas.drawString(doc.leftMargin + 100, 778 + line_height, line1) + canvas.drawString(doc.leftMargin + 100, 778, line2) + header_height += 2 * line_height + else: + canvas.drawString(doc.leftMargin + 100, 778, userName) + header_height += line_height + + self.used_space += header_height + canvas.restoreState() + + def generate_qr_code(self, collection, origin): + # build the qr code in the backend + + qrCodeImageUrl = f"{origin}/public/collections/{collection.entity_id}" + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(qrCodeImageUrl) + qr.make(fit=True) + qrCodeImage = qr.make_image(fill_color="black", back_color="white") + + buffered = BytesIO() + qrCodeImage.save(buffered, format="PNG") + qrCodeImageBase64 = base64.b64encode(buffered.getvalue()).decode("utf-8") + return qrCodeImageBase64 + + def generate_pdf(self, collection, origin): + buffer = BytesIO() + self.used_space = 0 + + first_page_content = [] + + self.add_title(first_page_content, collection.name) + self.add_description(first_page_content, collection.description) + first_page_content.append(Spacer(1, 20 if collection.description else 50)) + self.add_badges(first_page_content, collection.assertions.all()) + if collection.published: + # not enough space for qrcode on this page + if self.used_space > 550: + first_page_content.append(PageBreak()) + first_page_content.append(Spacer(1, 70)) + self.used_space = 0 + self.add_qrcode_section( + first_page_content, + collection.entity_id, + origin, + self.generate_qr_code(collection, origin), + ) + + # doc template with margins according to design doc + doc = BaseDocTemplate( + buffer, + pagesize=A4, + leftMargin=40, + rightMargin=40, + topMargin=40, + bottomMargin=40, + ) + + styles = getSampleStyleSheet() + styles.add(ParagraphStyle(name="Justify", alignment=TA_JUSTIFY)) + + Story = [] + Story.extend(first_page_content) + + frame = Frame( + doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="normal" + ) + template = PageTemplate( + id="header", + frames=frame, + onPage=partial(self.header, userName=collection.owner.get_full_name()), + ) + doc.addPageTemplates([template]) + pageNumCanvas = partial(PageNumCanvas, origin=origin) + doc.build(Story, canvasmaker=pageNumCanvas) + pdfContent = buffer.getvalue() + buffer.close() + return pdfContent + + +class BadgeCard(Flowable): + def __init__( + self, image, title, issuer, issued_on, width=6 * inch, height=2 * inch + ): + Flowable.__init__(self) + self.image = image + self.title = title + self.issuer = issuer + self.issued_on = issued_on + self.width = width + self.height = height + + def truncate_text(self, text, max_chars): + if len(text) > max_chars: + return text[: max_chars - 3] + "..." + return text + + def draw(self): + self.canv.setStrokeColor("#492E98") + self.canv.setLineWidth(1) + + radius = 10 + radius_pt = radius * 0.75 + self.canv.roundRect(0, 0, self.width, self.height, radius_pt, stroke=1, fill=0) + + img_size = 60 * 0.75 + img_x = 0.2 * inch + img_y = (self.height - img_size) / 2 + try: + if self.image: + img = image_file_to_image(self.image, math.ceil(img_size)) + if img is not None: + img.drawOn(self.canv, img_x, img_y) + except Exception as e: + print(e) + + text_x = img_x + img_size + 0.15 * inch + text_width = self.width - text_x - 0.15 * inch + + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + "TitleStyle", + parent=styles["Heading3"], + fontSize=8, + leading=10, + fontName="Rubik-Bold", + textColor=colors.HexColor("#333333"), + splitLongWords=1, + spaceAfter=1, + ) + issuer_style = ParagraphStyle( + "IssuerStyle", + parent=styles["BodyText"], + fontSize=6.5, + leading=8, + fontName="Rubik-Regular", + textColor=colors.HexColor("#323232"), + splitLongWords=1, + spaceAfter=1, + ) + date_style = ParagraphStyle( + "DateStyle", + parent=styles["BodyText"], + fontSize=6, + leading=7, + fontName="Rubik-Regular", + textColor=colors.HexColor("#323232"), + ) + + title_text = self.truncate_text(self.title, 60) + issuer_text = self.truncate_text(self.issuer, 90) + date_text = self.issued_on.strftime("%d.%m.%Y") + + title_para = Paragraph(title_text, title_style) + issuer_para = Paragraph(issuer_text, issuer_style) + date_para = Paragraph(date_text, date_style) + + _, title_height = title_para.wrap(text_width, self.height) + _, issuer_height = issuer_para.wrap(text_width, self.height) + _, date_height = date_para.wrap(text_width, self.height) + + spacing = 2 + total_text_height = title_height + issuer_height + date_height + 2 * spacing + + if total_text_height > self.height - 10: + title_style.fontSize -= 1 + issuer_style.fontSize -= 0.5 + date_style.fontSize -= 0.5 + + title_para = Paragraph(title_text, title_style) + issuer_para = Paragraph(issuer_text, issuer_style) + date_para = Paragraph(date_text, date_style) + + _, title_height = title_para.wrap(text_width, self.height) + _, issuer_height = issuer_para.wrap(text_width, self.height) + _, date_height = date_para.wrap(text_width, self.height) + total_text_height = title_height + issuer_height + date_height + 2 * spacing + + # Start drawing from vertical center + current_y = (self.height + total_text_height) / 2 + + title_para.drawOn(self.canv, text_x, current_y - title_height) + current_y -= title_height + spacing + + issuer_para.drawOn(self.canv, text_x, current_y - issuer_height) + current_y -= issuer_height + spacing + + date_para.drawOn(self.canv, text_x, current_y - date_height) + + +class RoundedImage(Flowable): + def __init__( + self, img_path, width, height, border_color, border_width, padding, radius + ): + super().__init__() + self.img_path = img_path + self.width = width + self.height = height + self.border_color = border_color + self.border_width = border_width + self.padding = padding + self.radius = radius + + def draw(self): + # Calculate total padding to prevent image overlap + total_padding = self.padding + self.border_width + 1.8 + + # Draw the rounded rectangle for the border + canvas = self.canv + canvas.setFillColor("white") + canvas.setStrokeColor(self.border_color) + canvas.setLineWidth(self.border_width) + canvas.roundRect( + 0, # Start at the lower-left corner of the Flowable + 0, + self.width + 2 * total_padding, # Width includes padding on both sides + self.height + 2 * total_padding, # Height includes padding on both sides + self.radius, # Radius for rounded corners, + stroke=1, + fill=1, + ) + + # Draw the image inside the rounded rectangle + canvas.drawImage( + self.img_path, + total_padding, # Offset by total padding to stay within rounded border + total_padding, + width=self.width, + height=self.height, + mask="auto", + ) + + +# Inspired by https://www.blog.pythonlibrary.org/2013/08/12/reportlab-how-to-add-page-numbers/ +class PageNumCanvas(canvas.Canvas): + """ + http://code.activestate.com/recipes/546511-page-x-of-y-with-reportlab/ + http://code.activestate.com/recipes/576832/ + """ + + # ---------------------------------------------------------------------- + def __init__(self, *args, origin: str, **kwargs): + """Constructor""" + canvas.Canvas.__init__(self, *args, **kwargs) + self.pages = [] + self.origin = origin + + # ---------------------------------------------------------------------- + def showPage(self): + """ + On a page break, add information to the list + """ + self.pages.append(dict(self.__dict__)) + self._startPage() + + # ---------------------------------------------------------------------- + def save(self): + """ + Add the page number to each page (page x of y) + """ + page_count = len(self.pages) + + for page in self.pages: + self.__dict__.update(page) + self.draw_page_number(page_count) + canvas.Canvas.showPage(self) + + canvas.Canvas.save(self) + + # ---------------------------------------------------------------------- + def draw_page_number(self, page_count): + """ + Add the page number + """ + page = "%s / %s" % (self._pageNumber, page_count) + self.setStrokeColor("#492E98") + page_width = self._pagesize[0] + self.line(10, 17.5, page_width / 2 - 20, 17.5) + self.line(page_width / 2 + 20, 17.5, page_width - 10, 17.5) + self.setFont("Rubik-Regular", 10) + self.drawCentredString(page_width / 2, 15, page) + if self._pageNumber == page_count: + self.setFont("Rubik-Bold", 10) + text_before = "ERSTELLT ÜBER " + link_text = "OPENBADGES.EDUCATION" + + text_before_width = self.stringWidth(text_before, "Rubik-Bold", 10) + link_text_width = self.stringWidth(link_text, "Rubik-Bold", 10) + full_text_width = text_before_width + link_text_width + + x_start = (page_width - full_text_width) / 2 + + self.drawString(x_start, 35, text_before) + + self.setFillColor("#1400FF") + + self.drawString(x_start + text_before_width, 35, link_text) + + link_x = x_start + text_before_width + self.line(link_x, 34, link_x + link_text_width, 34) + + self.linkURL( + self.origin, + rect=( + x_start + text_before_width, + 35, + x_start + text_before_width + link_text_width, + 35 + 10, + ), + relative=0, + thickness=0, + ) + self.setLineWidth(3) + self.line(10, 10, page_width - 10, 10) diff --git a/apps/mainsite/context_processors.py b/apps/mainsite/context_processors.py index fa55a587b..7c3f12370 100644 --- a/apps/mainsite/context_processors.py +++ b/apps/mainsite/context_processors.py @@ -1,9 +1,10 @@ from django.conf import settings +# TODO: Use email related to the new domain, when one is created. Not urgent in this phase. def extra_settings(request): return { - 'HELP_EMAIL': getattr(settings, 'HELP_EMAIL', 'help@badgr.io'), - 'PINGDOM_MONITORING_ID': getattr(settings, 'PINGDOM_MONITORING_ID', None), - 'GOOGLE_ANALYTICS_ID': getattr(settings, 'GOOGLE_ANALYTICS_ID', None), + "HELP_EMAIL": getattr(settings, "HELP_EMAIL", "info@opensenselab.org"), + "PINGDOM_MONITORING_ID": getattr(settings, "PINGDOM_MONITORING_ID", None), + "GOOGLE_ANALYTICS_ID": getattr(settings, "GOOGLE_ANALYTICS_ID", None), } diff --git a/apps/mainsite/drf_fields.py b/apps/mainsite/drf_fields.py index 0a2ef8535..eca9c23e9 100644 --- a/apps/mainsite/drf_fields.py +++ b/apps/mainsite/drf_fields.py @@ -7,33 +7,36 @@ from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.files.uploadedfile import UploadedFile -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext as _ from rest_framework.fields import FileField, SkipField from mainsite.validators import ValidImageValidator class Base64FileField(FileField): - # mimetypes.guess_extension() may return different values for same mimetype, but we need one extension for one mime - _MIME_MAPPING = { - 'image/jpeg': '.jpg', - 'audio/wav': '.wav', - 'image/svg+xml': '.svg' - } - _ERROR_MESSAGE = _('Base64 string is incorrect') + _MIME_MAPPING = {"image/jpeg": ".jpg", "audio/wav": ".wav", "image/svg+xml": ".svg"} + _ERROR_MESSAGE = _("Base64 string is incorrect") def to_internal_value(self, data): if isinstance(data, UploadedFile): return super(Base64FileField, self).to_internal_value(data) try: - mime, encoded_data = data.replace('data:', '', 1).split(';base64,') - extension = self._MIME_MAPPING[mime] if mime in list(self._MIME_MAPPING.keys()) else mimetypes.guess_extension(mime) + mime, encoded_data = data.replace("data:", "", 1).split(";base64,") + extension = ( + self._MIME_MAPPING[mime] + if mime in list(self._MIME_MAPPING.keys()) + else mimetypes.guess_extension(mime) + ) if extension is None: - raise ValidationError('Invalid MIME type') - ret = ContentFile(base64.b64decode(encoded_data), name='{name}{extension}'.format(name=str(uuid.uuid4()), - extension=extension)) + raise ValidationError("Invalid MIME type") + ret = ContentFile( + base64.b64decode(encoded_data), + name="{name}{extension}".format( + name=str(uuid.uuid4()), extension=extension + ), + ) return ret except (ValueError, binascii.Error): return super(Base64FileField, self).to_internal_value(data) @@ -42,28 +45,46 @@ def to_internal_value(self, data): class ValidImageField(Base64FileField): default_validators = [ValidImageValidator()] - def __init__(self, skip_http=True, allow_empty_file=False, use_url=True, allow_null=True, **kwargs): + def __init__( + self, + skip_http=True, + allow_empty_file=False, + use_url=True, + allow_null=True, + **kwargs, + ): self.skip_http = skip_http - self.use_public = kwargs.pop('use_public', False) + self.use_public = kwargs.pop("use_public", False) super(ValidImageField, self).__init__( - allow_empty_file=allow_empty_file, use_url=use_url, allow_null=allow_null, **kwargs + allow_empty_file=allow_empty_file, + use_url=use_url, + allow_null=allow_null, + **kwargs, ) def to_internal_value(self, data): # Skip http/https urls to avoid overwriting valid data when, for example, a client GETs and subsequently PUTs an # entity containing an image URL. - if self.skip_http and not isinstance(data, UploadedFile) and urllib.parse.urlparse(data).scheme in ('http', 'https'): + if ( + self.skip_http + and not isinstance(data, UploadedFile) + and urllib.parse.urlparse(data).scheme in ("http", "https") + ): raise SkipField() - self.source_attrs = ['image'] # Kind of a dirty hack, because this is failing to stick if set on init. + self.source_attrs = [ + "image" + ] # Kind of a dirty hack, because this is failing to stick if set on init. return super(ValidImageField, self).to_internal_value(data) def to_representation(self, value): if self.use_public: try: - if getattr(value, 'instance', None): - return value.instance.image_url(public=True) # sometimes value is a FileField despite source="*" + if getattr(value, "instance", None): + return value.instance.image_url( + public=True + ) # sometimes value is a FileField despite source="*" return value.image_url(public=True) except AttributeError: pass diff --git a/apps/mainsite/exceptions.py b/apps/mainsite/exceptions.py index e9e545649..e4e90319d 100644 --- a/apps/mainsite/exceptions.py +++ b/apps/mainsite/exceptions.py @@ -3,28 +3,30 @@ class BadgrApiException400(APIException): - def __init__(self, error_message, error_code): if not error_code: - detail = {'An exception occurred'} + detail = {"An exception occurred"} else: - detail = {'detail': 'validation_error', - 'fields': {'error_message': error_message, 'error_code': error_code}} + detail = { + "detail": "validation_error", + "fields": {"error_message": error_message, "error_code": error_code}, + } super(BadgrApiException400, self).__init__(detail) status_code = 400 class BadgrValidationError(ValidationError): - status_code = 400 def __init__(self, error_message, error_code): if not error_code: - detail = {'An exception occurred'} + detail = {"An exception occurred"} else: - detail = {'detail': 'validation_error', - 'fields': {'error_message': error_message, 'error_code': error_code}} + detail = { + "detail": "validation_error", + "fields": {"error_message": error_message, "error_code": error_code}, + } super(BadgrValidationError, self).__init__(detail) @@ -34,8 +36,9 @@ class BadgrValidationFieldError(BadgrValidationError): status_code = 400 def __init__(self, field_name, error_message, error_code): - error_message = {field_name: [{'error_message': error_message, - 'error_code': error_code}]} + error_message = { + field_name: [{"error_message": error_message, "error_code": error_code}] + } super(BadgrValidationFieldError, self).__init__(error_message, 999) @@ -51,6 +54,7 @@ def __init__(self, errors): """ error_messages = {} for field_name, error_message, error_code in errors: - error_messages[field_name] = [{'error_message': error_message, - 'error_code': error_code}] - super(BadgrValidationMultipleFieldError, self).__init__(error_messages, 999) \ No newline at end of file + error_messages[field_name] = [ + {"error_message": error_message, "error_code": error_code} + ] + super(BadgrValidationMultipleFieldError, self).__init__(error_messages, 999) diff --git a/apps/mainsite/formatters.py b/apps/mainsite/formatters.py index 4b1d3506c..0d2dce29c 100644 --- a/apps/mainsite/formatters.py +++ b/apps/mainsite/formatters.py @@ -1,8 +1,5 @@ # Created by wiggins@concentricsky.com on 8/27/15. - -import logging from pythonjsonlogger import jsonlogger -from django.utils import timezone import datetime @@ -10,9 +7,8 @@ class JsonFormatter(jsonlogger.JsonFormatter): default_time_format = "%Y-%m-%dT%H:%M:%S.%f%z" def converter(self, timestamp): - return datetime.datetime.fromtimestamp(timestamp, tz=timezone.utc) + return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc) def formatTime(self, record, datefmt=None): dt = self.converter(record.created) return dt.strftime(datefmt if datefmt else self.default_time_format) - diff --git a/apps/mainsite/management/commands/clean_email_records.py b/apps/mainsite/management/commands/clean_email_records.py index 9e6667e37..30a1ceba1 100644 --- a/apps/mainsite/management/commands/clean_email_records.py +++ b/apps/mainsite/management/commands/clean_email_records.py @@ -9,8 +9,10 @@ class Command(BaseCommand): - args = '' - help = 'Ensures users have the proper EmailAddress objects created for their accounts' + args = "" + help = ( + "Ensures users have the proper EmailAddress objects created for their accounts" + ) def handle(self, *args, **options): users_processed = 0 @@ -26,7 +28,7 @@ def handle(self, *args, **options): # handle users who don't have an EmailAddress record if emails.count() < 1 and user.email: try: - existing_email = CachedEmailAddress.objects.get(email=user.email) + CachedEmailAddress.objects.get(email=user.email) except CachedEmailAddress.DoesNotExist: new_primary = CachedEmailAddress( user=user, email=user.email, verified=False, primary=True @@ -34,7 +36,9 @@ def handle(self, *args, **options): new_primary.save() new_primary.send_confirmation(signup="canvas") else: - user.delete() # User record has no email addresses and email address has been added under another account + # User record has no email addresses and email address + # has been added under another account + user.delete() continue emails = CachedEmailAddress.objects.filter(user=user) @@ -43,23 +47,36 @@ def handle(self, *args, **options): elif len([e for e in emails if e.primary is True]) == 0: new_primary = emails.first() new_primary.set_as_primary(conditional=True) - self.stdout.write("Set {} as primary for user {}".format(new_primary.email, user.pk)) + self.stdout.write( + "Set {} as primary for user {}".format( + new_primary.email, user.pk + ) + ) primaries_set += 1 - prior_confirmations = EmailConfirmation.objects.filter(email_address=new_primary) + prior_confirmations = EmailConfirmation.objects.filter( + email_address=new_primary + ) - if new_primary.verified is False and not prior_confirmations.exists(): + if ( + new_primary.verified is False + and not prior_confirmations.exists() + ): try: new_primary.send_confirmation(signup=True) except SMTPException as e: raise e except Exception as e: - raise SMTPException("Error sending mail to {} -- {}".format( - new_primary.email, str(e) - )) + raise SMTPException( + "Error sending mail to {} -- {}".format( + new_primary.email, str(e) + ) + ) except IntegrityError as e: user_errors += 1 - self.stdout.write("Error in user {} record: {}".format(user.pk, e.message)) + self.stdout.write( + "Error in user {} record: {}".format(user.pk, e.message) + ) continue except SMTPException as e: email_errors += 1 diff --git a/apps/mainsite/management/commands/cleanup_altcha.py b/apps/mainsite/management/commands/cleanup_altcha.py new file mode 100644 index 000000000..f0d598bf9 --- /dev/null +++ b/apps/mainsite/management/commands/cleanup_altcha.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from datetime import timedelta +from mainsite.models import AltchaChallenge + + +class Command(BaseCommand): + help = "Cleanup old Altcha challenges" + + def handle(self, *args, **kwargs): + cutoff = timezone.now() - timedelta(hours=24) + old_challenges = AltchaChallenge.objects.filter(created_at__lt=cutoff) + count = old_challenges.count() + old_challenges.delete() + + self.stdout.write( + self.style.SUCCESS(f"Successfully deleted {count} old challenges") + ) diff --git a/apps/mainsite/management/commands/cleanup_iframe_urls.py b/apps/mainsite/management/commands/cleanup_iframe_urls.py new file mode 100644 index 000000000..3c5bbd344 --- /dev/null +++ b/apps/mainsite/management/commands/cleanup_iframe_urls.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from datetime import timedelta +from mainsite.models import IframeUrl + + +class Command(BaseCommand): + help = "Cleanup old iframe URLs" + + def handle(self, *args, **kwargs): + cutoff = timezone.now() - timedelta(hours=24) + old_iframe_urls = IframeUrl.objects.filter(created_at__lt=cutoff) + count = old_iframe_urls.count() + old_iframe_urls.delete() + + self.stdout.write( + self.style.SUCCESS(f"Successfully deleted {count} old iframe URLs") + ) diff --git a/apps/mainsite/management/commands/clear_cache.py b/apps/mainsite/management/commands/clear_cache.py index e63ba0b74..abb063099 100644 --- a/apps/mainsite/management/commands/clear_cache.py +++ b/apps/mainsite/management/commands/clear_cache.py @@ -5,9 +5,10 @@ class Command(BaseCommand): """A simple management command which clears the site-wide cache.""" - help = 'Fully clear your site-wide cache.' + + help = "Fully clear your site-wide cache." def handle(self, *args, **kwargs): - assert settings.CACHES, 'The CACHES setting is not configured!' + assert settings.CACHES, "The CACHES setting is not configured!" cache.clear() - self.stdout.write('Your cache has been cleared!\n') \ No newline at end of file + self.stdout.write("Your cache has been cleared!\n") diff --git a/apps/mainsite/management/commands/convert_to_unicode.py b/apps/mainsite/management/commands/convert_to_unicode.py index 5797c449f..51e65de23 100644 --- a/apps/mainsite/management/commands/convert_to_unicode.py +++ b/apps/mainsite/management/commands/convert_to_unicode.py @@ -6,15 +6,14 @@ class Command(BaseCommand): - args = '' - help = '' + args = "" + help = "" def handle(self, *args, **options): with connection.cursor() as cursor: - # Ignore "PROCEDURE convert_to_unicode does not exist" warning. with warnings.catch_warnings(): - warnings.simplefilter('ignore') + warnings.simplefilter("ignore") cursor.execute("DROP PROCEDURE IF EXISTS convert_to_unicode") cursor.execute(""" @@ -24,14 +23,16 @@ def handle(self, *args, **options): DECLARE done INT DEFAULT FALSE; DECLARE tname VARCHAR(64); - DECLARE all_tables CURSOR FOR SELECT `table_name` FROM information_schema.tables WHERE table_schema=dbname COLLATE utf8_unicode_ci; + DECLARE all_tables CURSOR FOR SELECT `table_name` + FROM information_schema.tables WHERE table_schema=dbname COLLATE utf8_unicode_ci; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN all_tables; read_loop: LOOP FETCH all_tables INTO tname; - set @sql = 'ALTER TABLE ?tname? CONVERT TO CHARACTER SET utf8 COLLATE utf8_unicode_ci;' COLLATE utf8_unicode_ci; + set @sql = 'ALTER TABLE ?tname? + CONVERT TO CHARACTER SET utf8 COLLATE utf8_unicode_ci;' COLLATE utf8_unicode_ci; set @sql = REPLACE(@sql, '?tname?' COLLATE utf8_unicode_ci , tname); PREPARE stmt FROM @sql; EXECUTE stmt; @@ -47,7 +48,11 @@ def handle(self, *args, **options): END """) - cursor.execute("CALL convert_to_unicode('{}')".format(settings.DATABASES['default']['NAME'])) + cursor.execute( + "CALL convert_to_unicode('{}')".format( + settings.DATABASES["default"]["NAME"] + ) + ) for row in cursor.fetchall(): print(row) diff --git a/apps/mainsite/management/commands/dist.py b/apps/mainsite/management/commands/dist.py index b07577c8f..4353dd8d2 100644 --- a/apps/mainsite/management/commands/dist.py +++ b/apps/mainsite/management/commands/dist.py @@ -1,27 +1,50 @@ import os -import pkg_resources -import sys - from django.core.management import call_command -from django.core.management.base import BaseCommand, CommandError -from subprocess import call +from django.core.management.base import BaseCommand -import mainsite from mainsite import TOP_DIR class Command(BaseCommand): - args = '' - help = 'Runs build tasks to compile javascript and css' + args = "" + help = ( + "Runs build tasks to compile javascript and css and generate API documentation" + ) def handle(self, *args, **options): - dirname = os.path.join(TOP_DIR, 'apps', 'mainsite', 'static', 'swagger-ui') + dirname = os.path.join(TOP_DIR, "apps", "mainsite", "static", "swagger-ui") if not os.path.exists(dirname): os.makedirs(dirname) - call_command('generate_swagger_spec', - output=os.path.join(dirname, 'api_spec_{version}.json'), - preamble=os.path.join(dirname, "API_DESCRIPTION_{version}.md"), - versions=['v1', 'v2', 'bcv1'], - include_oauth2_security=True + # Generate OpenAPI schema for different versions + versions = ["v1", "v2", "bcv1"] + + for version in versions: + output_file = os.path.join(dirname, f"api_spec_{version}.json") + + self.stdout.write(f"Generating schema for version {version}...") + + try: + # Generate the schema file + # Note: drf-spectacular doesn't have native multi-version support + with open(output_file, "w"): + call_command( + "spectacular", + "--file", + output_file, + "--format", + "openapi-json", + "--validate", + ) + + self.stdout.write( + self.style.SUCCESS(f"✓ Successfully generated {output_file}") + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f"✗ Failed to generate {output_file}: {str(e)}") + ) + + self.stdout.write( + self.style.SUCCESS("\nAll API documentation generated successfully!") ) diff --git a/apps/mainsite/management/commands/generate_token_scopes.py b/apps/mainsite/management/commands/generate_token_scopes.py index 695208cd0..f506f54ab 100644 --- a/apps/mainsite/management/commands/generate_token_scopes.py +++ b/apps/mainsite/management/commands/generate_token_scopes.py @@ -6,24 +6,27 @@ class Command(BaseCommand): def handle(self, *args, **options): - self.stdout.write('Splitting all scopes on tokens') + self.stdout.write("Splitting all scopes on tokens") chunk_size = 5000 page = 0 - self.stdout.write('Deleting AccessTokenScopes') + self.stdout.write("Deleting AccessTokenScopes") AccessTokenScope.objects.all().delete() - - self.stdout.write('Bulk creating AccessTokenScope') + + self.stdout.write("Bulk creating AccessTokenScope") while True: - tokens = AccessTokenProxy.objects.filter(expires__gt=timezone.now())[page:page+chunk_size] + tokens = AccessTokenProxy.objects.filter(expires__gt=timezone.now())[ + page : page + chunk_size + ] for t in tokens: scopes = [] for s in t.scope.split(): scopes.append(AccessTokenScope(scope=s, token=t)) AccessTokenScope.objects.bulk_create(scopes) - if len(tokens) < chunk_size: break + if len(tokens) < chunk_size: + break page += chunk_size - self.stdout.write('All done.') + self.stdout.write("All done.") diff --git a/apps/mainsite/management/commands/list_esco_issuers.py b/apps/mainsite/management/commands/list_esco_issuers.py new file mode 100644 index 000000000..11261f6a8 --- /dev/null +++ b/apps/mainsite/management/commands/list_esco_issuers.py @@ -0,0 +1,59 @@ +from json import loads +from django.core.management.base import BaseCommand +from issuer.models import BadgeClass +import os + + +class Command(BaseCommand): + def handle(self, *args, **options): + # Structure: {issuer_name: {badge_id: [esco_ids]}} + issuer_data = {} + + badgeclasses = BadgeClass.objects.all() + + file_path = os.path.join(os.getcwd(), "esco_issuers.txt") + try: + for badgeclass in badgeclasses: + extensions = badgeclass.get_extensions_manager() + competency_extension = extensions.filter( + name="extensions:CompetencyExtension" + ).first() + + if competency_extension is not None: + competency_json = competency_extension.original_json + competency_dict = loads(competency_json) + + esco_ids = [] + for item in competency_dict: + escoID = item.get("escoID") + if escoID is not None and escoID != "": + esco_ids.append(escoID) + + if esco_ids: + issuer_name = badgeclass.issuer.name + badge_id = badgeclass.entity_id + + if issuer_name not in issuer_data: + issuer_data[issuer_name] = {} + + issuer_data[issuer_name][badge_id] = esco_ids + + with open(file_path, "w") as f: + for issuer_name, badges_data in issuer_data.items(): + f.write(f"\nIssuer: {issuer_name}\n") + f.write("Badges and their Competencies:\n") + + for badge_id, esco_ids in badges_data.items(): + f.write(f" - Badge ID: {badge_id}\n") + f.write(" ESCO IDs:\n") + for esco_id in esco_ids: + f.write(f" * {esco_id}\n") + + f.write("-" * 50 + "\n") + + self.stdout.write( + self.style.SUCCESS("Successfully wrote grouped badge data to file") + ) + + except Exception as e: + self.stdout.write(self.style.ERROR(f"An error occurred: {str(e)}")) diff --git a/apps/mainsite/management/commands/seed.py b/apps/mainsite/management/commands/seed.py index 18009ac2d..3690b4d26 100644 --- a/apps/mainsite/management/commands/seed.py +++ b/apps/mainsite/management/commands/seed.py @@ -1,57 +1,88 @@ from django.core.management.base import BaseCommand -from mainsite.models import * -from badgeuser.models import * -from oauth2_provider.models import * from django.contrib.auth import get_user_model + +from apps.badgeuser.models import CachedEmailAddress, TermsVersion +from apps.mainsite.models import ApplicationInfo, BadgrApp + User = get_user_model() + class Command(BaseCommand): """Seed your development database for talking with local fronturl""" - help = 'Seed your database for local development' + + help = "Seed your database for local development" defaults = { - '--username':'root', - '--email' :'root@example.com', - '--password':'12345678', - '--fronturl':'http://localhost:4200' + "--username": "root", + "--email": "root@example.com", + "--password": "12345678", + "--fronturl": "http://localhost:4200", } def add_arguments(self, parser): # print(self.defaults) for flag, default_val in list(self.defaults.items()): - parser.add_argument(flag, nargs='?', type=str, help='Defaults to: {}'.format(default_val)) - + parser.add_argument( + flag, nargs="?", type=str, help="Defaults to: {}".format(default_val) + ) + def handle(self, *args, **options): variables = { - 'username' : options['username'] or self.defaults['--username'], - 'email' : options['email'] or self.defaults['--email'], - 'password' : options['password'] or self.defaults['--password'], - 'fronturl' : options['fronturl'] or self.defaults['--fronturl'] + "username": options["username"] or self.defaults["--username"], + "email": options["email"] or self.defaults["--email"], + "password": options["password"] or self.defaults["--password"], + "fronturl": options["fronturl"] or self.defaults["--fronturl"], } - + # Create a super user and verify the email: try: - User.objects.create_superuser(username=variables['username'], email=variables['email'], password=variables['password']) - CachedEmailAddress.objects.create(email=variables['email'], user_id=1, verified=True, primary=True) - + User.objects.create_superuser( + username=variables["username"], + email=variables["email"], + password=variables["password"], + ) + CachedEmailAddress.objects.create( + email=variables["email"], user_id=1, verified=True, primary=True + ) + # Setup an Application, initial Terms Summary and a BadgrApp: - a = Application.objects.create(name="dev", client_id="public", client_type="public", redirect_uris=variables['fronturl']+"", authorization_grant_type="password") - ApplicationInfo.objects.create(allowed_scopes="rw:profile rw:issuer rw:backpack", application=a) - TermsVersion.objects.create(is_active=True, version="1", short_description="This is a summary of our terms of service.") - BadgrApp.objects.create( name="dev", cors="localhost", email_confirmation_redirect=variables['fronturl']+"/login", - signup_redirect=variables['fronturl']+"/signup", - forgot_password_redirect=variables['fronturl']+"/forgot-password/", - ui_login_redirect=variables['fronturl']+"/login/", - ui_signup_success_redirect=variables['fronturl']+"/signup/success/", - ui_connect_success_redirect=variables['fronturl']+"/profile/", - public_pages_redirect=variables['fronturl']+"/public", - oauth_authorization_redirect=variables['fronturl']+"/auth/oauth2/authorize", - oauth_application=a ) - except: - self.stdout.write("\nSomething went wrong. ./manage.py flush and try again.\n\n") + a = ApplicationInfo.objects.create( + name="dev", + client_id="public", + client_type="public", + redirect_uris=variables["fronturl"] + "", + authorization_grant_type="password", + ) + ApplicationInfo.objects.create( + allowed_scopes="rw:profile rw:issuer rw:backpack", application=a + ) + TermsVersion.objects.create( + is_active=True, + version="1", + short_description="This is a summary of our terms of service.", + ) + BadgrApp.objects.create( + name="dev", + cors="localhost", + email_confirmation_redirect=variables["fronturl"] + "/login", + signup_redirect=variables["fronturl"] + "/signup", + forgot_password_redirect=variables["fronturl"] + "/forgot-password/", + ui_login_redirect=variables["fronturl"] + "/login/", + ui_signup_success_redirect=variables["fronturl"] + "/signup/success/", + ui_connect_success_redirect=variables["fronturl"] + "/profile/", + public_pages_redirect=variables["fronturl"] + "/public", + oauth_authorization_redirect=variables["fronturl"] + + "/auth/oauth2/authorize", + oauth_application=a, + ) + except Exception: + self.stdout.write( + "\nSomething went wrong. ./manage.py flush and try again.\n\n" + ) return - summary = """ + summary = ( + """ superuser: %(username)s email: %(email)s password: %(password)s @@ -61,6 +92,8 @@ def handle(self, *args, **options): ./manage.py flush - """ % variables + """ + % variables + ) - self.stdout.write(summary) \ No newline at end of file + self.stdout.write(summary) diff --git a/apps/mainsite/management/commands/send_badgerequest_mails.py b/apps/mainsite/management/commands/send_badgerequest_mails.py new file mode 100644 index 000000000..56a3bc742 --- /dev/null +++ b/apps/mainsite/management/commands/send_badgerequest_mails.py @@ -0,0 +1,65 @@ +from datetime import date, timedelta + +from allauth.account.adapter import get_adapter +from django.core.management.base import BaseCommand +from issuer.models import QrCode, RequestedBadge + + +class Command(BaseCommand): + """Send mail to issuer staff when badges were requested via qr code""" + + help = "Send mail to issuer staff when badges were requested via qr code that day" + + def handle(self, *args, **kwargs): + # qr codes that have been created prior to implementation of the notification feature + # do not have the created_by_user field set and are therefore skipped + qr_codes = QrCode.objects.filter( + notifications=True, created_by_user__isnull=False + ) + self.stdout.write( + "Total number of notifiable qr codes with active notifications: " + + str(len(qr_codes)) + ) + + for qr in qr_codes: + # this command is intended to run once a day after midnight, + # so only those badges that have been request in the last 24 hours + # are relevant when we decide whether or not to send out an email. + # this prevents flooding the user with daily mails even though no new + # requests came in. + reference_date = date.today() - timedelta(days=1) + if ( + len( + RequestedBadge.objects.filter( + qrcode=qr, requestedOn__gt=reference_date + ) + ) + > 0 + ): + request_url = ( + f"https://openbadges.education/issuer/issuers/{qr.issuer.entity_id}" + f"/badges/{qr.badgeclass.entity_id}?focusRequests=true" + ) + + ctx = { + "badge_name": qr.badgeclass.name, + "number_of_open_requests": len( + RequestedBadge.objects.filter(qrcode=qr) + ), + "activate_url": request_url, + "call_to_action_label": "Anfrage bestätigen", + } + get_adapter().send_mail( + "account/email/email_badge_request", + qr.created_by_user.email, + ctx, + ) + self.stdout.write("QR " + str(qr.entity_id) + " notification was sent") + else: + self.stdout.write( + "QR " + + str(qr.entity_id) + + " does not have any requests to notify about" + ) + + self.stdout.write(self.style.SUCCESS("Successfully sent emails")) diff --git a/apps/mainsite/management/commands/send_test_mail.py b/apps/mainsite/management/commands/send_test_mail.py new file mode 100644 index 000000000..01530fba6 --- /dev/null +++ b/apps/mainsite/management/commands/send_test_mail.py @@ -0,0 +1,33 @@ +from django.core.management.base import BaseCommand, CommandParser +from allauth.account.adapter import get_adapter + + +class Command(BaseCommand): + """Send a test email using the specified template""" + + help = "Send a test email using the specified template" + + def add_arguments(self, parser: CommandParser) -> None: + super().add_arguments(parser) + parser.add_argument( + "--to", type=str, help="Address to send the test mail to", required=True + ) + parser.add_argument( + "--template", type=str, help="Email template to use", required=True + ) + parser.add_argument( + "extras", nargs="*", type=str, help="Extra arguments as key=value pairs" + ) + + def handle(self, *args, **kwargs) -> None: + to = kwargs["to"] + template = kwargs["template"] + # build the context from the extras + extras_list = kwargs["extras"] + ctx = dict(pair.split("=", 1) for pair in extras_list if "=" in pair) + self.stdout.write(f"Sending email template '{template}' to {to}") + self.stdout.write(f"Context: {ctx}") + + get_adapter().send_mail(template, to, ctx) + + self.stdout.write("Sent!") diff --git a/apps/mainsite/management/commands/testserver.py b/apps/mainsite/management/commands/testserver.py index 3b984a06b..2819daf41 100644 --- a/apps/mainsite/management/commands/testserver.py +++ b/apps/mainsite/management/commands/testserver.py @@ -1,6 +1,8 @@ import os -from django.contrib.staticfiles.management.commands.runserver import Command as RunserverCommand +from django.contrib.staticfiles.management.commands.runserver import ( + Command as RunserverCommand, +) from django.core.management import call_command from django.test.utils import setup_databases @@ -8,14 +10,14 @@ class Command(RunserverCommand): - args = '' - help = 'Builds a test database, and runs a test runserver' + args = "" + help = "Builds a test database, and runs a test runserver" def handle(self, *args, **options): if len(args) < 1: args = ["8001"] - if not options.get('settings'): - os.environ['DJANGO_SETTINGS_MODULE'] = 'mainsite.settings_testserver' + if not options.get("settings"): + os.environ["DJANGO_SETTINGS_MODULE"] = "mainsite.settings_testserver" setup_databases(verbosity=1, interactive=False) call_command("loaddata", os.path.join(TOP_DIR, "fixtures", "testserver.json")) return super(Command, self).handle(*args, **options) diff --git a/apps/mainsite/managers.py b/apps/mainsite/managers.py index 62c02137e..a7b049d95 100644 --- a/apps/mainsite/managers.py +++ b/apps/mainsite/managers.py @@ -1,13 +1,12 @@ # Created by wiggins@concentricsky.com on 4/18/16. import cachemodel -from django.conf import settings from django.urls import resolve, Resolver404 from mainsite.utils import OriginSetting class SlugOrJsonIdCacheModelManager(cachemodel.CacheModelManager): - def __init__(self, slug_kwarg_name='slug', slug_field_name='slug'): + def __init__(self, slug_kwarg_name="slug", slug_field_name="slug"): super(SlugOrJsonIdCacheModelManager, self).__init__() self.slug_kwarg_name = slug_kwarg_name self.slug_field_name = slug_field_name @@ -17,21 +16,21 @@ def get_slug_kwarg_name(self): def get_by_slug_or_id(self, slug): if slug.startswith(OriginSetting.HTTP): - path = slug[len(OriginSetting.HTTP):] + path = slug[len(OriginSetting.HTTP) :] try: r = resolve(path) slug = r.kwargs.get(self.get_slug_kwarg_name()) - except Resolver404 as e: + except Resolver404: raise self.model.DoesNotExist return self.get(**{self.slug_field_name: slug}) def get_by_slug_or_entity_id_or_id(self, slug): if slug.startswith(OriginSetting.HTTP): - path = slug[len(OriginSetting.HTTP):] + path = slug[len(OriginSetting.HTTP) :] try: r = resolve(path) slug = r.kwargs.get(self.get_slug_kwarg_name()) - except Resolver404 as e: + except Resolver404: raise self.model.DoesNotExist return self.get_by_slug_or_entity_id(slug) diff --git a/apps/mainsite/middleware.py b/apps/mainsite/middleware.py index e03de0ba7..292f1625b 100644 --- a/apps/mainsite/middleware.py +++ b/apps/mainsite/middleware.py @@ -2,13 +2,20 @@ from django.utils import deprecation from django.utils.deprecation import MiddlewareMixin from mainsite import settings +from django.conf import settings as django_settings +import logging + +logger = logging.getLogger("Badgr.Events") class MaintenanceMiddleware(deprecation.MiddlewareMixin): """Serve a temporary redirect to a maintenance url in maintenance mode""" + def process_request(self, request): - if request.method == 'POST': - if getattr(settings, 'MAINTENANCE_MODE', False) is True and hasattr(settings, 'MAINTENANCE_URL'): + if request.method == "POST": + if getattr(settings, "MAINTENANCE_MODE", False) is True and hasattr( + settings, "MAINTENANCE_URL" + ): return http.HttpResponseRedirect(settings.MAINTENANCE_URL) return None @@ -16,12 +23,12 @@ def process_request(self, request): class TrailingSlashMiddleware(deprecation.MiddlewareMixin): def process_request(self, request): """Removes the slash from urls, or adds a slash for the admin urls""" - exceptions = ['/staff', '/__debug__'] + exceptions = ["/staff", "/__debug__"] if list(filter(request.path.startswith, exceptions)): - if request.path[-1] != '/': - return http.HttpResponsePermanentRedirect(request.path+"/") + if request.path[-1] != "/": + return http.HttpResponsePermanentRedirect(request.path + "/") else: - if request.path != '/' and request.path[-1] == '/': + if request.path != "/" and request.path[-1] == "/": return http.HttpResponsePermanentRedirect(request.path[:-1]) return None @@ -31,3 +38,23 @@ def process_response(self, request, response): if response.status_code == 500: response.xframe_options_exempt = True return response + +class ExceptionLoggingMiddleware(MiddlewareMixin): + def process_exception(self, request, exception): + if django_settings.DEBUG: + return None + logger.exception("An unhandled exception occured:", exc_info=exception) + +class CookieToBearerMiddleware(MiddlewareMixin): + """ + Makes sure that tokens passed as cookie are added as + bearer HTTP_AUTHORIZATION, so that oauth2_provider can + handle them + """ + + def process_request(self, request): + # do something only if request contains access token cookie + if "access_token" in request.COOKIES: + request.META["HTTP_AUTHORIZATION"] = ( + f"Bearer {request.COOKIES['access_token']}" + ) diff --git a/apps/mainsite/migrations/0001_initial.py b/apps/mainsite/migrations/0001_initial.py index 70bcdd29c..974a08a9e 100644 --- a/apps/mainsite/migrations/0001_initial.py +++ b/apps/mainsite/migrations/0001_initial.py @@ -5,19 +5,24 @@ class Migration(migrations.Migration): - - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='EmailBlacklist', + name="EmailBlacklist", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('email', models.EmailField(unique=True, max_length=75)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("email", models.EmailField(unique=True, max_length=75)), ], - options={ - }, + options={}, bases=(models.Model,), ), ] diff --git a/apps/mainsite/migrations/0002_badgrapp.py b/apps/mainsite/migrations/0002_badgrapp.py index 6e1a305e5..42518747b 100644 --- a/apps/mainsite/migrations/0002_badgrapp.py +++ b/apps/mainsite/migrations/0002_badgrapp.py @@ -7,28 +7,53 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('mainsite', '0001_initial'), + ("mainsite", "0001_initial"), ] operations = [ migrations.CreateModel( - name='BadgrApp', + name="BadgrApp", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('is_active', models.BooleanField(default=True, db_index=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('cors', models.CharField(unique=True, max_length=254)), - ('email_confirmation_redirect', models.URLField()), - ('forgot_password_redirect', models.URLField()), - ('created_by', models.ForeignKey(related_name='badgrapp_created', on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)), - ('updated_by', models.ForeignKey(related_name='badgrapp_updated', on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("is_active", models.BooleanField(default=True, db_index=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("cors", models.CharField(unique=True, max_length=254)), + ("email_confirmation_redirect", models.URLField()), + ("forgot_password_redirect", models.URLField()), + ( + "created_by", + models.ForeignKey( + related_name="badgrapp_created", + on_delete=django.db.models.deletion.SET_NULL, + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), + ( + "updated_by", + models.ForeignKey( + related_name="badgrapp_updated", + on_delete=django.db.models.deletion.SET_NULL, + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), diff --git a/apps/mainsite/migrations/0003_auto_20160901_1537.py b/apps/mainsite/migrations/0003_auto_20160901_1537.py index 25f0de1bf..7377ea7ab 100644 --- a/apps/mainsite/migrations/0003_auto_20160901_1537.py +++ b/apps/mainsite/migrations/0003_auto_20160901_1537.py @@ -1,18 +1,20 @@ # -*- coding: utf-8 -*- -from django.db import models, migrations +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0002_badgrapp'), + ("mainsite", "0002_badgrapp"), ] operations = [ migrations.AlterModelOptions( - name='emailblacklist', - options={'verbose_name': 'Blacklisted email', 'verbose_name_plural': 'Blacklisted emails'}, + name="emailblacklist", + options={ + "verbose_name": "Blacklisted email", + "verbose_name_plural": "Blacklisted emails", + }, ), ] diff --git a/apps/mainsite/migrations/0004_auto_20170120_1724.py b/apps/mainsite/migrations/0004_auto_20170120_1724.py index 14a98c1ce..d43ce4ac8 100644 --- a/apps/mainsite/migrations/0004_auto_20170120_1724.py +++ b/apps/mainsite/migrations/0004_auto_20170120_1724.py @@ -5,22 +5,21 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0003_auto_20160901_1537'), + ("mainsite", "0003_auto_20160901_1537"), ] operations = [ migrations.AddField( - model_name='badgrapp', - name='name', - field=models.CharField(default='Badgr', max_length=254), + model_name="badgrapp", + name="name", + field=models.CharField(default="Badgr", max_length=254), preserve_default=False, ), migrations.AddField( - model_name='badgrapp', - name='signup_redirect', - field=models.URLField(default='https://badgr.io/signup'), + model_name="badgrapp", + name="signup_redirect", + field=models.URLField(default="https://badgr.io/signup"), preserve_default=False, ), ] diff --git a/apps/mainsite/migrations/0005_auto_20170427_0724.py b/apps/mainsite/migrations/0005_auto_20170427_0724.py index 0d707b036..5f903599e 100644 --- a/apps/mainsite/migrations/0005_auto_20170427_0724.py +++ b/apps/mainsite/migrations/0005_auto_20170427_0724.py @@ -5,15 +5,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0004_auto_20170120_1724'), + ("mainsite", "0004_auto_20170120_1724"), ] operations = [ migrations.AlterField( - model_name='emailblacklist', - name='email', + model_name="emailblacklist", + name="email", field=models.EmailField(unique=True, max_length=254), ), ] diff --git a/apps/mainsite/migrations/0005_auto_20170616_1406.py b/apps/mainsite/migrations/0005_auto_20170616_1406.py index 6da621773..663ee3e80 100644 --- a/apps/mainsite/migrations/0005_auto_20170616_1406.py +++ b/apps/mainsite/migrations/0005_auto_20170616_1406.py @@ -5,21 +5,20 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0004_auto_20170120_1724'), + ("mainsite", "0004_auto_20170120_1724"), ] operations = [ migrations.AddField( - model_name='badgrapp', - name='ui_login_redirect', + model_name="badgrapp", + name="ui_login_redirect", field=models.URLField(null=True), preserve_default=True, ), migrations.AddField( - model_name='badgrapp', - name='ui_signup_success_redirect', + model_name="badgrapp", + name="ui_signup_success_redirect", field=models.URLField(null=True), preserve_default=True, ), diff --git a/apps/mainsite/migrations/0006_merge.py b/apps/mainsite/migrations/0006_merge.py index 8e9ac6d82..555b148e2 100644 --- a/apps/mainsite/migrations/0006_merge.py +++ b/apps/mainsite/migrations/0006_merge.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- -from django.db import migrations, models +from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0005_auto_20170427_0724'), - ('mainsite', '0005_auto_20170616_1406'), + ("mainsite", "0005_auto_20170427_0724"), + ("mainsite", "0005_auto_20170616_1406"), ] - operations = [ - ] + operations = [] diff --git a/apps/mainsite/migrations/0007_badgrapp_ui_connect_success_redirect.py b/apps/mainsite/migrations/0007_badgrapp_ui_connect_success_redirect.py index e35f3667c..120a7eec4 100644 --- a/apps/mainsite/migrations/0007_badgrapp_ui_connect_success_redirect.py +++ b/apps/mainsite/migrations/0007_badgrapp_ui_connect_success_redirect.py @@ -5,15 +5,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0006_merge'), + ("mainsite", "0006_merge"), ] operations = [ migrations.AddField( - model_name='badgrapp', - name='ui_connect_success_redirect', + model_name="badgrapp", + name="ui_connect_success_redirect", field=models.URLField(null=True), ), ] diff --git a/apps/mainsite/migrations/0008_auto_20170711_1326.py b/apps/mainsite/migrations/0008_auto_20170711_1326.py index 012042df7..90f1338aa 100644 --- a/apps/mainsite/migrations/0008_auto_20170711_1326.py +++ b/apps/mainsite/migrations/0008_auto_20170711_1326.py @@ -8,25 +8,36 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0007_badgrapp_ui_connect_success_redirect'), + ("mainsite", "0007_badgrapp_ui_connect_success_redirect"), ] operations = [ migrations.AlterField( - model_name='badgrapp', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="badgrapp", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterField( - model_name='badgrapp', - name='is_active', + model_name="badgrapp", + name="is_active", field=models.BooleanField(default=True), ), migrations.AlterField( - model_name='badgrapp', - name='updated_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + model_name="badgrapp", + name="updated_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/apps/mainsite/migrations/0009_applicationinfo.py b/apps/mainsite/migrations/0009_applicationinfo.py index 403fb4736..dc65b6b69 100644 --- a/apps/mainsite/migrations/0009_applicationinfo.py +++ b/apps/mainsite/migrations/0009_applicationinfo.py @@ -8,24 +8,37 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - ('mainsite', '0008_auto_20170711_1326'), - ('oauth2_provider', '0001_initial'), + ("mainsite", "0008_auto_20170711_1326"), + ("oauth2_provider", "0001_initial"), ] operations = [ migrations.CreateModel( - name='ApplicationInfo', + name="ApplicationInfo", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('icon', models.ImageField(blank=True, null=True, upload_to=b'')), - ('name', models.CharField(blank=True, max_length=254, null=True)), - ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("icon", models.ImageField(blank=True, null=True, upload_to=b"")), + ("name", models.CharField(blank=True, max_length=254, null=True)), + ( + "application", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, ), ] diff --git a/apps/mainsite/migrations/0010_auto_20171004_1510.py b/apps/mainsite/migrations/0010_auto_20171004_1510.py index b09347cbd..81b539f20 100644 --- a/apps/mainsite/migrations/0010_auto_20171004_1510.py +++ b/apps/mainsite/migrations/0010_auto_20171004_1510.py @@ -6,30 +6,29 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0009_applicationinfo'), + ("mainsite", "0009_applicationinfo"), ] operations = [ migrations.AddField( - model_name='applicationinfo', - name='allowed_scopes', + model_name="applicationinfo", + name="allowed_scopes", field=models.TextField(blank=True), ), migrations.AddField( - model_name='applicationinfo', - name='website_url', + model_name="applicationinfo", + name="website_url", field=models.URLField(blank=True, default=None, null=True), ), migrations.AlterField( - model_name='applicationinfo', - name='icon', - field=models.FileField(blank=True, null=True, upload_to=b''), + model_name="applicationinfo", + name="icon", + field=models.FileField(blank=True, null=True, upload_to=b""), ), migrations.AlterField( - model_name='applicationinfo', - name='name', + model_name="applicationinfo", + name="name", field=models.CharField(blank=True, default=None, max_length=254, null=True), ), ] diff --git a/apps/mainsite/migrations/0011_auto_20171019_0634.py b/apps/mainsite/migrations/0011_auto_20171019_0634.py index f4adf081f..a8bd7581f 100644 --- a/apps/mainsite/migrations/0011_auto_20171019_0634.py +++ b/apps/mainsite/migrations/0011_auto_20171019_0634.py @@ -7,15 +7,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0010_auto_20171004_1510'), + ("mainsite", "0010_auto_20171004_1510"), ] operations = [ migrations.AlterField( - model_name='applicationinfo', - name='allowed_scopes', - field=models.TextField(validators=[mainsite.models.DefinedScopesValidator()]), + model_name="applicationinfo", + name="allowed_scopes", + field=models.TextField( + validators=[mainsite.models.DefinedScopesValidator()] + ), ), ] diff --git a/apps/mainsite/migrations/0012_badgrapp_public_pages_redirect.py b/apps/mainsite/migrations/0012_badgrapp_public_pages_redirect.py index 21f6954b2..d572fc94b 100644 --- a/apps/mainsite/migrations/0012_badgrapp_public_pages_redirect.py +++ b/apps/mainsite/migrations/0012_badgrapp_public_pages_redirect.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0011_auto_20171019_0634'), + ("mainsite", "0011_auto_20171019_0634"), ] operations = [ migrations.AddField( - model_name='badgrapp', - name='public_pages_redirect', + model_name="badgrapp", + name="public_pages_redirect", field=models.URLField(null=True), ), ] diff --git a/apps/mainsite/migrations/0013_badgrapp_oauth_authorization_redirect.py b/apps/mainsite/migrations/0013_badgrapp_oauth_authorization_redirect.py index 3b791195a..a8942471c 100644 --- a/apps/mainsite/migrations/0013_badgrapp_oauth_authorization_redirect.py +++ b/apps/mainsite/migrations/0013_badgrapp_oauth_authorization_redirect.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0012_badgrapp_public_pages_redirect'), + ("mainsite", "0012_badgrapp_public_pages_redirect"), ] operations = [ migrations.AddField( - model_name='badgrapp', - name='oauth_authorization_redirect', + model_name="badgrapp", + name="oauth_authorization_redirect", field=models.URLField(null=True), ), ] diff --git a/apps/mainsite/migrations/0014_applicationinfo_trust_email_verification.py b/apps/mainsite/migrations/0014_applicationinfo_trust_email_verification.py index a85589f5f..fc3bca9d4 100644 --- a/apps/mainsite/migrations/0014_applicationinfo_trust_email_verification.py +++ b/apps/mainsite/migrations/0014_applicationinfo_trust_email_verification.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0013_badgrapp_oauth_authorization_redirect'), + ("mainsite", "0013_badgrapp_oauth_authorization_redirect"), ] operations = [ migrations.AddField( - model_name='applicationinfo', - name='trust_email_verification', + model_name="applicationinfo", + name="trust_email_verification", field=models.BooleanField(default=False), ), ] diff --git a/apps/mainsite/migrations/0015_badgrapp_use_auth_code_exchange.py b/apps/mainsite/migrations/0015_badgrapp_use_auth_code_exchange.py index e20d02fb0..a15cccf6b 100644 --- a/apps/mainsite/migrations/0015_badgrapp_use_auth_code_exchange.py +++ b/apps/mainsite/migrations/0015_badgrapp_use_auth_code_exchange.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0014_applicationinfo_trust_email_verification'), + ("mainsite", "0014_applicationinfo_trust_email_verification"), ] operations = [ migrations.AddField( - model_name='badgrapp', - name='use_auth_code_exchange', + model_name="badgrapp", + name="use_auth_code_exchange", field=models.BooleanField(default=False), ), ] diff --git a/apps/mainsite/migrations/0016_auto_20181003_0903.py b/apps/mainsite/migrations/0016_auto_20181003_0903.py index a1b5e35f5..59a0c1844 100644 --- a/apps/mainsite/migrations/0016_auto_20181003_0903.py +++ b/apps/mainsite/migrations/0016_auto_20181003_0903.py @@ -8,42 +8,44 @@ class Migration(migrations.Migration): - dependencies = [ - ('authtoken', '0002_auto_20160226_1747'), - ('oauth2_provider', '0001_initial'), + ("authtoken", "0002_auto_20160226_1747"), + ("oauth2_provider", "0001_initial"), migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - ('mainsite', '0015_badgrapp_use_auth_code_exchange'), + ("mainsite", "0015_badgrapp_use_auth_code_exchange"), ] operations = [ migrations.CreateModel( - name='AccessTokenProxy', - fields=[ - ], + name="AccessTokenProxy", + fields=[], options={ - 'verbose_name': 'access token', - 'proxy': True, - 'verbose_name_plural': 'access tokens', - 'indexes': [], + "verbose_name": "access token", + "proxy": True, + "verbose_name_plural": "access tokens", + "indexes": [], }, - bases=('oauth2_provider.accesstoken',), + bases=("oauth2_provider.accesstoken",), ), migrations.CreateModel( - name='LegacyTokenProxy', - fields=[ - ], + name="LegacyTokenProxy", + fields=[], options={ - 'verbose_name': 'Legacy token', - 'proxy': True, - 'verbose_name_plural': 'Legacy tokens', - 'indexes': [], + "verbose_name": "Legacy token", + "proxy": True, + "verbose_name_plural": "Legacy tokens", + "indexes": [], }, - bases=('authtoken.token',), + bases=("authtoken.token",), ), migrations.AddField( - model_name='badgrapp', - name='oauth_application', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + model_name="badgrapp", + name="oauth_application", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), ), ] diff --git a/apps/mainsite/migrations/0017_accesstokenscope.py b/apps/mainsite/migrations/0017_accesstokenscope.py index 559f0d6fe..dd08ae374 100644 --- a/apps/mainsite/migrations/0017_accesstokenscope.py +++ b/apps/mainsite/migrations/0017_accesstokenscope.py @@ -8,19 +8,32 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), - ('mainsite', '0016_auto_20181003_0903'), + ("mainsite", "0016_auto_20181003_0903"), ] operations = [ migrations.CreateModel( - name='AccessTokenScope', + name="AccessTokenScope", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('scope', models.CharField(max_length=256)), - ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("scope", models.CharField(max_length=256)), + ( + "token", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, + ), + ), ], ), ] diff --git a/apps/mainsite/migrations/0018_auto_20190723_1532.py b/apps/mainsite/migrations/0018_auto_20190723_1532.py index c95bbd521..92c0e49c5 100644 --- a/apps/mainsite/migrations/0018_auto_20190723_1532.py +++ b/apps/mainsite/migrations/0018_auto_20190723_1532.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0017_accesstokenscope'), + ("mainsite", "0017_accesstokenscope"), ] operations = [ migrations.AlterField( - model_name='accesstokenscope', - name='scope', + model_name="accesstokenscope", + name="scope", field=models.CharField(max_length=255), ), ] diff --git a/apps/mainsite/migrations/0019_auto_20190710_0805.py b/apps/mainsite/migrations/0019_auto_20190710_0805.py index cb8681d39..07a510be7 100644 --- a/apps/mainsite/migrations/0019_auto_20190710_0805.py +++ b/apps/mainsite/migrations/0019_auto_20190710_0805.py @@ -7,15 +7,14 @@ class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), - ('mainsite', '0018_auto_20190723_1532'), + ("mainsite", "0018_auto_20190723_1532"), ] operations = [ migrations.AlterUniqueTogether( - name='accesstokenscope', - unique_together=set([('token', 'scope')]), + name="accesstokenscope", + unique_together=set([("token", "scope")]), ), ] diff --git a/apps/mainsite/migrations/0020_auto_20191114_1056.py b/apps/mainsite/migrations/0020_auto_20191114_1056.py index c526ebf84..ce7baff6d 100644 --- a/apps/mainsite/migrations/0020_auto_20191114_1056.py +++ b/apps/mainsite/migrations/0020_auto_20191114_1056.py @@ -6,20 +6,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0019_auto_20190710_0805'), + ("mainsite", "0019_auto_20190710_0805"), ] operations = [ migrations.AddField( - model_name='badgrapp', - name='ui_signup_failure_redirect', + model_name="badgrapp", + name="ui_signup_failure_redirect", field=models.URLField(null=True), ), migrations.AlterField( - model_name='badgrapp', - name='ui_login_redirect', - field=models.URLField(default=b'default_redirect', null=True), + model_name="badgrapp", + name="ui_login_redirect", + field=models.URLField(default=b"default_redirect", null=True), ), ] diff --git a/apps/mainsite/migrations/0021_applicationinfo_badge_connect.py b/apps/mainsite/migrations/0021_applicationinfo_badge_connect.py index b970fa6a5..308d75d48 100644 --- a/apps/mainsite/migrations/0021_applicationinfo_badge_connect.py +++ b/apps/mainsite/migrations/0021_applicationinfo_badge_connect.py @@ -5,40 +5,39 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0020_auto_20191114_1056'), + ("mainsite", "0020_auto_20191114_1056"), ] operations = [ migrations.AddField( - model_name='applicationinfo', - name='logo_uri', + model_name="applicationinfo", + name="logo_uri", field=models.URLField(blank=True, null=True), ), migrations.AddField( - model_name='applicationinfo', - name='policy_uri', + model_name="applicationinfo", + name="policy_uri", field=models.URLField(blank=True, null=True), ), migrations.AddField( - model_name='applicationinfo', - name='software_id', + model_name="applicationinfo", + name="software_id", field=models.CharField(blank=True, default=None, max_length=254, null=True), ), migrations.AddField( - model_name='applicationinfo', - name='software_version', + model_name="applicationinfo", + name="software_version", field=models.CharField(blank=True, default=None, max_length=254, null=True), ), migrations.AddField( - model_name='applicationinfo', - name='terms_uri', + model_name="applicationinfo", + name="terms_uri", field=models.URLField(blank=True, null=True), ), migrations.AddField( - model_name='applicationinfo', - name='issue_refresh_token', + model_name="applicationinfo", + name="issue_refresh_token", field=models.BooleanField(default=True), ), ] diff --git a/apps/mainsite/migrations/0021_auto_20191230_1752.py b/apps/mainsite/migrations/0021_auto_20191230_1752.py index 449e2ffd3..20cd2ae0e 100644 --- a/apps/mainsite/migrations/0021_auto_20191230_1752.py +++ b/apps/mainsite/migrations/0021_auto_20191230_1752.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0020_auto_20191114_1056'), + ("mainsite", "0020_auto_20191114_1056"), ] operations = [ migrations.AlterField( - model_name='badgrapp', - name='ui_login_redirect', + model_name="badgrapp", + name="ui_login_redirect", field=models.URLField(null=True), ), ] diff --git a/apps/mainsite/migrations/0022_badgrapp_is_default.py b/apps/mainsite/migrations/0022_badgrapp_is_default.py index c96327e72..119d84e6a 100644 --- a/apps/mainsite/migrations/0022_badgrapp_is_default.py +++ b/apps/mainsite/migrations/0022_badgrapp_is_default.py @@ -6,15 +6,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0021_auto_20191230_1752'), + ("mainsite", "0021_auto_20191230_1752"), ] operations = [ migrations.AddField( - model_name='badgrapp', - name='is_default', + model_name="badgrapp", + name="is_default", field=models.BooleanField(default=False), ), ] diff --git a/apps/mainsite/migrations/0023_merge_20200211_0702.py b/apps/mainsite/migrations/0023_merge_20200211_0702.py index 4e2ccef61..5a5ceb233 100644 --- a/apps/mainsite/migrations/0023_merge_20200211_0702.py +++ b/apps/mainsite/migrations/0023_merge_20200211_0702.py @@ -6,11 +6,9 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0021_applicationinfo_badge_connect'), - ('mainsite', '0022_badgrapp_is_default'), + ("mainsite", "0021_applicationinfo_badge_connect"), + ("mainsite", "0022_badgrapp_is_default"), ] - operations = [ - ] + operations = [] diff --git a/apps/mainsite/migrations/0024_auto_20200608_0452.py b/apps/mainsite/migrations/0024_auto_20200608_0452.py index ca1e0e873..9f6c89aca 100644 --- a/apps/mainsite/migrations/0024_auto_20200608_0452.py +++ b/apps/mainsite/migrations/0024_auto_20200608_0452.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('mainsite', '0023_merge_20200211_0702'), + ("mainsite", "0023_merge_20200211_0702"), ] operations = [ migrations.AlterField( - model_name='applicationinfo', - name='icon', - field=models.FileField(blank=True, null=True, upload_to=''), + model_name="applicationinfo", + name="icon", + field=models.FileField(blank=True, null=True, upload_to=""), ), ] diff --git a/apps/mainsite/migrations/0025_auto_20250227_0945.py b/apps/mainsite/migrations/0025_auto_20250227_0945.py new file mode 100644 index 000000000..010112b9c --- /dev/null +++ b/apps/mainsite/migrations/0025_auto_20250227_0945.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2 on 2025-02-27 17:45 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("mainsite", "0024_auto_20200608_0452"), + ] + + operations = [ + migrations.CreateModel( + name="AltchaChallenge", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("salt", models.CharField(max_length=24)), + ("challenge", models.CharField(max_length=64)), + ("signature", models.CharField(max_length=64)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("used", models.BooleanField(default=False)), + ("used_at", models.DateTimeField(blank=True, null=True)), + ("solved_by_ip", models.GenericIPAddressField(blank=True, null=True)), + ], + ), + migrations.AddIndex( + model_name="altchachallenge", + index=models.Index(fields=["used"], name="mainsite_al_used_f638a8_idx"), + ), + migrations.AddIndex( + model_name="altchachallenge", + index=models.Index( + fields=["created_at"], name="mainsite_al_created_2be6dc_idx" + ), + ), + ] diff --git a/apps/mainsite/migrations/0026_iframeurl.py b/apps/mainsite/migrations/0026_iframeurl.py new file mode 100644 index 000000000..fecc21b62 --- /dev/null +++ b/apps/mainsite/migrations/0026_iframeurl.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2 on 2025-08-04 09:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import jsonfield.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('mainsite', '0025_auto_20250227_0945'), + ] + + operations = [ + migrations.CreateModel( + name='IframeUrl', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('params', jsonfield.fields.JSONField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/apps/mainsite/mixins.py b/apps/mainsite/mixins.py index 780abcbc9..ff08f5fe4 100644 --- a/apps/mainsite/mixins.py +++ b/apps/mainsite/mixins.py @@ -1,5 +1,4 @@ import io -import os from PIL import Image from django.conf import settings @@ -10,8 +9,7 @@ from defusedxml.cElementTree import parse as safe_parse -from mainsite.utils import verify_svg, scrubSvgElementTree, hash_for_image, convert_svg_to_png -from mainsite.utils import verify_svg, scrubSvgElementTree, convert_svg_to_png +from mainsite.utils import verify_svg, scrubSvgElementTree, hash_for_image def _decompression_bomb_check(image, max_pixels=Image.MAX_IMAGE_PIXELS): @@ -21,7 +19,7 @@ def _decompression_bomb_check(image, max_pixels=Image.MAX_IMAGE_PIXELS): class HashUploadedImage(models.Model): # Adds new django field - image_hash = models.CharField(max_length=72, blank=True, default='') + image_hash = models.CharField(max_length=72, blank=True, default="") class Meta: abstract = True @@ -55,7 +53,9 @@ def schedule_image_update_task(self): class PngImagePreview(object): def save(self, *args, **kwargs): # Check that conversions are enabled and that an image was uploaded. - if getattr(settings, 'SVG_HTTP_CONVERSION_ENABLED', False) and kwargs.get('force_resize'): + if getattr(settings, "SVG_HTTP_CONVERSION_ENABLED", False) and kwargs.get( + "force_resize" + ): # Set this to None to ensure that we make a updated preview image later in post_save. self.image_preview = None @@ -63,7 +63,6 @@ def save(self, *args, **kwargs): class ResizeUploadedImage(object): - def save(self, force_resize=False, *args, **kwargs): if (self.pk is None and self.image) or force_resize: try: @@ -73,31 +72,36 @@ def save(self, force_resize=False, *args, **kwargs): except IOError: return super(ResizeUploadedImage, self).save(*args, **kwargs) - if image.format == 'PNG': - max_square = getattr(settings, 'IMAGE_FIELD_MAX_PX', 400) + if image.format == "PNG": + max_square = getattr(settings, "IMAGE_FIELD_MAX_PX", 600) - smaller_than_canvas = \ - (image.width < max_square and image.height < max_square) + smaller_than_canvas = ( + image.width < max_square and image.height < max_square + ) if smaller_than_canvas: - max_square = (image.width - if image.width > image.height - else image.height) + max_square = ( + image.width if image.width > image.height else image.height + ) new_image = resize_contain(image, (max_square, max_square)) byte_string = io.BytesIO() - new_image.save(byte_string, 'PNG') + new_image.save(byte_string, "PNG") - self.image = InMemoryUploadedFile(byte_string, None, - self.image.name, 'image/png', - byte_string.tell(), None) + self.image = InMemoryUploadedFile( + byte_string, + None, + self.image.name, + "image/png", + byte_string.tell(), + None, + ) return super(ResizeUploadedImage, self).save(*args, **kwargs) class ScrubUploadedSvgImage(object): - def save(self, *args, **kwargs): if self.image and verify_svg(self.image.file): self.image.file.seek(0) @@ -108,5 +112,7 @@ def save(self, *args, **kwargs): buf = io.BytesIO() tree.write(buf) - self.image = InMemoryUploadedFile(buf, 'image', self.image.name, 'image/svg+xml', buf.tell(), 'utf8') + self.image = InMemoryUploadedFile( + buf, "image", self.image.name, "image/svg+xml", buf.tell(), "utf8" + ) return super(ScrubUploadedSvgImage, self).save(*args, **kwargs) diff --git a/apps/mainsite/models.py b/apps/mainsite/models.py index af14307fd..304b83a88 100644 --- a/apps/mainsite/models.py +++ b/apps/mainsite/models.py @@ -8,30 +8,30 @@ from basic_models.models import CreatedUpdatedBy, CreatedUpdatedAt, IsActive from django.conf import settings -from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.exceptions import ValidationError from django.urls import reverse from django.db import models, transaction from django.utils import timezone +from jsonfield import JSONField from oauthlib.common import generate_token import cachemodel -from django.db.models import Manager from django.utils.deconstruct import deconstructible from oauth2_provider.models import AccessToken, Application, RefreshToken from rest_framework.authtoken.models import Token from mainsite.utils import set_url_query_params +import uuid - -AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') +AUTH_USER_MODEL = getattr(settings, "AUTH_USER_MODEL", "auth.User") class EmailBlacklist(models.Model): email = models.EmailField(unique=True) class Meta: - verbose_name = 'Blacklisted email' - verbose_name_plural = 'Blacklisted emails' + verbose_name = "Blacklisted email" + verbose_name_plural = "Blacklisted emails" @staticmethod def generate_email_signature(email, badgrapp_pk=None): @@ -40,22 +40,29 @@ def generate_email_signature(email, badgrapp_pk=None): expiration = datetime.utcnow() + timedelta(days=7) # In one week. timestamp = int((expiration - datetime(1970, 1, 1)).total_seconds()) - email_encoded = base64.b64encode(email.encode('utf-8')).decode("utf-8") - hashed = hmac.new(secret_key.encode('utf-8'), (email_encoded + str(timestamp)).encode('utf-8'), sha1) + email_encoded = base64.b64encode(email.encode("utf-8")).decode("utf-8") + hashed = hmac.new( + secret_key.encode("utf-8"), + (email_encoded + str(timestamp)).encode("utf-8"), + sha1, + ) if badgrapp_pk is None: badgrapp_pk = BadgrApp.objects.get_by_id_or_default().pk - return reverse('unsubscribe', kwargs={ - 'email_encoded': email_encoded, - 'expiration': timestamp, - 'signature': hashed.hexdigest(), - }) + '?a={}'.format(badgrapp_pk) + return reverse( + "unsubscribe", + kwargs={ + "email_encoded": email_encoded, + "expiration": timestamp, + "signature": hashed.hexdigest(), + }, + ) + "?a={}".format(badgrapp_pk) @staticmethod def verify_email_signature(email_encoded, expiration, signature): - secret_key = bytes(settings.UNSUBSCRIBE_SECRET_KEY, 'utf-8') - b_email_encoded_and_expired = bytes(email_encoded + expiration, 'utf-8') + secret_key = bytes(settings.UNSUBSCRIBE_SECRET_KEY, "utf-8") + b_email_encoded_and_expired = bytes(email_encoded + expiration, "utf-8") hashed = hmac.new(secret_key, b_email_encoded_and_expired, sha1) return hmac.compare_digest(hashed.hexdigest(), str(signature)) @@ -74,11 +81,11 @@ def get_current(self, request=None, raise_exception=False): existing_session_app_id = None if request: - if request.META.get('HTTP_ORIGIN'): - origin = request.META.get('HTTP_ORIGIN') - elif request.META.get('HTTP_REFERER'): - origin = request.META.get('HTTP_REFERER') - existing_session_app_id = request.session.get('badgr_app_pk', None) + if request.META.get("HTTP_ORIGIN"): + origin = request.META.get("HTTP_ORIGIN") + elif request.META.get("HTTP_REFERER"): + origin = request.META.get("HTTP_REFERER") + existing_session_app_id = request.session.get("badgr_app_pk", None) if existing_session_app_id: try: @@ -101,13 +108,19 @@ def get_by_id_or_default(self, badgrapp_id=None): if badgrapp_id: try: return self.get(pk=badgrapp_id) - except (self.model.DoesNotExist, ValueError,): + except ( + self.model.DoesNotExist, + ValueError, + ): pass try: return self.get(is_default=True) - except (self.model.DoesNotExist, self.model.MultipleObjectsReturned,): + except ( + self.model.DoesNotExist, + self.model.MultipleObjectsReturned, + ): badgrapp = None - legacy_default_setting = getattr(settings, 'BADGR_APP_ID', None) + legacy_default_setting = getattr(settings, "BADGR_APP_ID", None) if legacy_default_setting is not None: try: badgrapp = self.get(pk=legacy_default_setting) @@ -123,9 +136,9 @@ def get_by_id_or_default(self, badgrapp_id=None): # failsafe: return a new entry if there are none return self.create( - cors='localhost:4200', + cors="localhost:4200", is_default=True, - signup_redirect='http://localhost:4200/signup' + signup_redirect="http://localhost:4200/signup", ) except self.model.MultipleObjectsReturned: badgrapp = self.filter(is_default=True).first() @@ -135,6 +148,10 @@ def get_by_id_or_default(self, badgrapp_id=None): class BadgrApp(CreatedUpdatedBy, CreatedUpdatedAt, IsActive, cachemodel.CacheModel): name = models.CharField(max_length=254) + # Note that this is NOT used for CORS anymore! It is rather + # used as the base url and only exists for legacy reasons. + # TODO: Do we even need this anymore? Do we *need* anything + # from this BadgrApp anymore? cors = models.CharField(max_length=254, unique=True) is_default = models.BooleanField(default=False) email_confirmation_redirect = models.URLField() @@ -147,24 +164,32 @@ class BadgrApp(CreatedUpdatedBy, CreatedUpdatedAt, IsActive, cachemodel.CacheMod public_pages_redirect = models.URLField(null=True) oauth_authorization_redirect = models.URLField(null=True) use_auth_code_exchange = models.BooleanField(default=False) - oauth_application = models.ForeignKey("oauth2_provider.Application", null=True, blank=True, - on_delete=models.CASCADE) + oauth_application = models.ForeignKey( + "oauth2_provider.Application", null=True, blank=True, on_delete=models.CASCADE + ) objects = BadgrAppManager() PROPS_FOR_DEFAULT = [ - 'forgot_password_redirect', 'ui_login_redirect', 'ui_signup_success_redirect', 'ui_connect_success_redirect', - 'ui_signup_failure_redirect', 'oauth_authorization_redirect', 'email_confirmation_redirect' + "forgot_password_redirect", + "ui_login_redirect", + "ui_signup_success_redirect", + "ui_connect_success_redirect", + "ui_signup_failure_redirect", + "oauth_authorization_redirect", + "email_confirmation_redirect", ] def __str__(self): return self.cors - def get_path(self, path='/', use_https=None): + def get_path(self, path="/", use_https=None): if use_https is None: - use_https = self.signup_redirect.startswith('https') - scheme = 'https://' if use_https else 'http://' - return '{}{}{}'.format(scheme, self.cors, path) + use_https = self.signup_redirect.startswith("https") + scheme = "https://" if use_https else "http://" + if self.cors.startswith(scheme): + scheme = "" + return "{}{}{}".format(scheme, self.cors, path) @property def oauth_application_client_id(self): @@ -184,7 +209,9 @@ def oauth_application_client_id(self, value): def save(self, *args, **kwargs): if self.is_default: # Set all other BadgrApp instances as no longer the default. - existing_default = self.__class__.objects.filter(is_default=True).exclude(id=self.pk) + existing_default = self.__class__.objects.filter(is_default=True).exclude( + id=self.pk + ) if existing_default.exists(): for b in existing_default: b.is_default = False @@ -200,17 +227,19 @@ def save(self, *args, **kwargs): def publish(self): super(BadgrApp, self).publish() - self.publish_by('cors') + self.publish_by("cors") @deconstructible class DefinedScopesValidator(object): message = "Does not match defined scopes" - code = 'invalid' + code = "invalid" def __call__(self, value): - defined_scopes = set(getattr(settings, 'OAUTH2_PROVIDER', {}).get('SCOPES', {}).keys()) - provided_scopes = set(s.strip() for s in re.split(r'[\s\n]+', value)) + defined_scopes = set( + getattr(settings, "OAUTH2_PROVIDER", {}).get("SCOPES", {}).keys() + ) + provided_scopes = set(s.strip() for s in re.split(r"[\s\n]+", value)) if provided_scopes - defined_scopes: raise ValidationError(self.message, code=self.code) pass @@ -223,12 +252,15 @@ def __eq__(self, other): class ApplicationInfo(cachemodel.CacheModel): - application = models.OneToOneField('oauth2_provider.Application', - on_delete=models.CASCADE) + application = models.OneToOneField( + "oauth2_provider.Application", on_delete=models.CASCADE + ) icon = models.FileField(blank=True, null=True) name = models.CharField(max_length=254, blank=True, null=True, default=None) website_url = models.URLField(blank=True, null=True, default=None) - allowed_scopes = models.TextField(blank=False, validators=[DefinedScopesValidator()]) + allowed_scopes = models.TextField( + blank=False, validators=[DefinedScopesValidator()] + ) trust_email_verification = models.BooleanField(default=False) # Badge Connect Extra Data @@ -236,7 +268,9 @@ class ApplicationInfo(cachemodel.CacheModel): terms_uri = models.URLField(blank=True, null=True) policy_uri = models.URLField(blank=True, null=True) software_id = models.CharField(max_length=254, blank=True, null=True, default=None) - software_version = models.CharField(max_length=254, blank=True, null=True, default=None) + software_version = models.CharField( + max_length=254, blank=True, null=True, default=None + ) issue_refresh_token = models.BooleanField(default=True) def get_visible_name(self): @@ -253,26 +287,34 @@ def default_launch_url(self): application = self.application if application.authorization_grant_type != Application.GRANT_AUTHORIZATION_CODE: # This is not a Auth Code Application. Cannot Launch. - return '' - launch_url = BadgrApp.objects.get_current().get_path('/auth/oauth2/authorize') + return "" + launch_url = BadgrApp.objects.get_current().get_path("/auth/oauth2/authorize") launch_url = set_url_query_params( - launch_url, client_id=application.client_id, redirect_uri=application.default_redirect_uri, - scope=self.allowed_scopes + launch_url, + client_id=application.client_id, + redirect_uri=application.default_redirect_uri, + scope=self.allowed_scopes, ) return launch_url @property def scope_list(self): - return [s for s in re.split(r'[\s\n]+', self.allowed_scopes) if s] + return [s for s in re.split(r"[\s\n]+", self.allowed_scopes) if s] class AccessTokenProxyManager(models.Manager): - - def generate_new_token_for_user(self, user, scope='r:profile', application=None, expires=None, refresh_token=False): + def generate_new_token_for_user( + self, + user, + scope="r:profile", + application=None, + expires=None, + refresh_token=False, + ): with transaction.atomic(): if application is None: application, created = Application.objects.get_or_create( - client_id='public', + client_id="public", client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, ) @@ -280,16 +322,19 @@ def generate_new_token_for_user(self, user, scope='r:profile', application=None, ApplicationInfo.objects.create(application=application) if expires is None: - access_token_expires_seconds = getattr(settings, 'OAUTH2_PROVIDER', {}).get( - 'ACCESS_TOKEN_EXPIRE_SECONDS', 86400) - expires = timezone.now() + timezone.timedelta(seconds=access_token_expires_seconds) + access_token_expires_seconds = getattr( + settings, "OAUTH2_PROVIDER", {} + ).get("ACCESS_TOKEN_EXPIRE_SECONDS", 86400) + expires = timezone.now() + timezone.timedelta( + seconds=access_token_expires_seconds + ) accesstoken = self.create( application=application, user=user, expires=expires, token=generate_token(), - scope=scope + scope=scope, ) if refresh_token: @@ -297,7 +342,7 @@ def generate_new_token_for_user(self, user, scope='r:profile', application=None, access_token=accesstoken, user=user, application=application, - token=generate_token() + token=generate_token(), ) return accesstoken @@ -306,12 +351,12 @@ def get_from_entity_id(self, entity_id): # lookup by a faked padding = len(entity_id) % 4 if padding > 0: - entity_id = '{}{}'.format(entity_id, (4 - padding) * '=') - decoded = str(base64.urlsafe_b64decode(entity_id.encode('utf-8')), 'utf-8') - id = re.sub(r'^{}'.format(self.model.fake_entity_id_prefix), '', decoded) + entity_id = "{}{}".format(entity_id, (4 - padding) * "=") + decoded = str(base64.urlsafe_b64decode(entity_id.encode("utf-8")), "utf-8") + id = re.sub(r"^{}".format(self.model.fake_entity_id_prefix), "", decoded) try: pk = int(id) - except ValueError as e: + except ValueError: raise self.model.DoesNotExist() return self.get(pk=pk) @@ -323,8 +368,8 @@ class AccessTokenProxy(AccessToken): class Meta: proxy = True - verbose_name = 'access token' - verbose_name_plural = 'access tokens' + verbose_name = "access token" + verbose_name_plural = "access tokens" def revoke(self): RefreshToken.objects.filter(access_token=self.pk).delete() @@ -334,8 +379,8 @@ def revoke(self): def entity_id(self): # fake an entityId for this non-entity digest = "{}{}".format(self.fake_entity_id_prefix, self.pk) - b64_string = str(base64.urlsafe_b64encode(digest.encode('utf-8')), 'utf-8') - b64_trimmed = re.sub(r'=+$', '', b64_string) + b64_string = str(base64.urlsafe_b64encode(digest.encode("utf-8")), "utf-8") + b64_trimmed = re.sub(r"=+$", "", b64_string) return b64_trimmed @property @@ -343,7 +388,7 @@ def client_id(self): return self.application.client_id def get_entity_class_name(self): - return 'AccessToken' + return "AccessToken" @property def application_name(self): @@ -371,12 +416,11 @@ def seconds_to_expiration(self): class AccessTokenScope(models.Model): - token = models.ForeignKey(AccessToken, - on_delete=models.CASCADE) + token = models.ForeignKey(AccessToken, on_delete=models.CASCADE) scope = models.CharField(max_length=255) class Meta: - unique_together = ['token', 'scope'] + unique_together = ["token", "scope"] def __str__(self): return self.scope @@ -385,8 +429,8 @@ def __str__(self): class LegacyTokenProxy(Token): class Meta: proxy = True - verbose_name = 'Legacy token' - verbose_name_plural = 'Legacy tokens' + verbose_name = "Legacy token" + verbose_name_plural = "Legacy tokens" def __str__(self): return self.obscured_token @@ -395,3 +439,39 @@ def __str__(self): def obscured_token(self): if self.key: return "{}***".format(self.key[:4]) + + +class AltchaChallenge(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + salt = models.CharField(max_length=24) # 12-byte hex encoded salt + challenge = models.CharField(max_length=64) # SHA-256 hash is 64 chars + signature = models.CharField(max_length=64) + created_at = models.DateTimeField(auto_now_add=True) + used = models.BooleanField(default=False) + used_at = models.DateTimeField(null=True, blank=True) + solved_by_ip = models.GenericIPAddressField(null=True, blank=True) + + class Meta: + indexes = [ + models.Index(fields=["used"]), + models.Index(fields=["created_at"]), + ] + + +class IframeUrl(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=255) + params = JSONField() + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + "badgeuser.BadgeUser", + blank=True, + null=True, + related_name="+", + on_delete=models.SET_NULL, + ) + + @property + def url(self): + baseUrl = getattr(settings, "HTTP_ORIGIN", "http://localhost:8000") + return f"{baseUrl}/iframes/{self.id}/" diff --git a/apps/mainsite/oauth2_api.py b/apps/mainsite/oauth2_api.py index c1fc93b75..cc5fefb40 100644 --- a/apps/mainsite/oauth2_api.py +++ b/apps/mainsite/oauth2_api.py @@ -1,38 +1,62 @@ # encoding: utf-8 import base64 +import requests import json import re from urllib.parse import urlparse +import datetime +import jwt from django.core.files.storage import default_storage from django.conf import settings from django.core.validators import URLValidator from django.http import HttpResponse from django.utils import timezone +from django.contrib.auth import logout +from django.contrib.auth.hashers import check_password from oauth2_provider.exceptions import OAuthToolkitError -from oauth2_provider.models import get_application_model, get_access_token_model, Application +from oauth2_provider.models import ( + get_application_model, + get_access_token_model, + Application, + AccessToken, +) from oauth2_provider.scopes import get_scopes_backend from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import TokenView as OAuth2ProviderTokenView +from oauth2_provider.views import RevokeTokenView as OAuth2ProviderRevokeTokenView from oauth2_provider.views.mixins import OAuthLibMixin +from oauth2_provider.signals import app_authorized from oauthlib.oauth2.rfc6749.utils import scope_to_list -from rest_framework import serializers +from rest_framework import serializers, permissions from rest_framework.response import Response -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED +from rest_framework.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, +) from rest_framework.views import APIView -import badgrlog from badgeuser.authcode import accesstoken_for_authcode from backpack.badge_connect_api import BADGE_CONNECT_SCOPES from mainsite.models import ApplicationInfo from mainsite.oauth_validator import BadgrRequestValidator, BadgrOauthServer from mainsite.serializers import ApplicationInfoSerializer, AuthorizationSerializer -from mainsite.utils import fetch_remote_file_to_storage, throttleable, set_url_query_params +from mainsite.utils import ( + fetch_remote_file_to_storage, + throttleable, + set_url_query_params, +) +from drf_spectacular.utils import extend_schema, extend_schema_field +from drf_spectacular.types import OpenApiTypes +import logging -badgrlogger = badgrlog.BadgrLogger() +logger = logging.getLogger("Badgr.Events") +@extend_schema(exclude=True) class AuthorizationApiView(OAuthLibMixin, APIView): permission_classes = [] @@ -44,14 +68,16 @@ class AuthorizationApiView(OAuthLibMixin, APIView): def get_authorization_redirect_url(self, scopes, credentials, allow=True): uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=scopes, credentials=credentials, allow=allow) - return set_url_query_params(uri, **{'scope': scopes}) + request=self.request, scopes=scopes, credentials=credentials, allow=allow + ) + return set_url_query_params(uri, **{"scope": scopes}) def post(self, request, *args, **kwargs): if not self.request.user.is_authenticated: - return Response({ - 'error': 'Incorrect authentication credentials.' - }, status=HTTP_401_UNAUTHORIZED) + return Response( + {"error": "Incorrect authentication credentials."}, + status=HTTP_401_UNAUTHORIZED, + ) # Copy/Pasta'd from oauth2_provider.views.BaseAuthorizationView.form_valid try: @@ -64,27 +90,31 @@ def post(self, request, *args, **kwargs): "response_type": serializer.data.get("response_type", None), "state": serializer.data.get("state", None), } - if serializer.data.get('code_challenge', False): - credentials['code_challenge'] = serializer.data.get('code_challenge') - credentials['code_challenge_method'] = serializer.data.get('code_challenge_method', 'S256') + if serializer.data.get("code_challenge", False): + credentials["code_challenge"] = serializer.data.get("code_challenge") + credentials["code_challenge_method"] = serializer.data.get( + "code_challenge_method", "S256" + ) - if serializer.data.get('scopes'): - scopes = ' '.join(serializer.data.get("scopes")) + if serializer.data.get("scopes"): + scopes = " ".join(serializer.data.get("scopes")) else: - scopes = serializer.data.get('scope') + scopes = serializer.data.get("scope") allow = serializer.data.get("allow") - success_url = self.get_authorization_redirect_url(scopes, credentials, allow) - return Response({'success_url': success_url}) + success_url = self.get_authorization_redirect_url( + scopes, credentials, allow + ) + return Response({"success_url": success_url}) except OAuthToolkitError as error: - return Response({ - 'error': error.oauthlib_error.description - }, status=HTTP_400_BAD_REQUEST) + return Response( + {"error": error.oauthlib_error.description}, status=HTTP_400_BAD_REQUEST + ) def get(self, request, *args, **kwargs): application = None - client_id = request.query_params.get('client_id') + request.query_params.get("client_id") # Copy/Pasta'd from oauth2_provider.views.BaseAuthorizationView.get try: scopes, credentials = self.validate_authorization_request(request) @@ -94,79 +124,127 @@ def get(self, request, *args, **kwargs): # at this point we know an Application instance with such client_id exists in the database # TODO: Cache this! - application = get_application_model().objects.get(client_id=credentials["client_id"]) + application = get_application_model().objects.get( + client_id=credentials["client_id"] + ) kwargs["client_id"] = credentials["client_id"] kwargs["redirect_uri"] = credentials["redirect_uri"] kwargs["response_type"] = credentials["response_type"] kwargs["state"] = credentials["state"] try: - application_info = ApplicationInfoSerializer(application.applicationinfo) + application_info = ApplicationInfoSerializer( + application.applicationinfo + ) kwargs["application"] = application_info.data - app_scopes = [s for s in re.split(r'[\s\n]+', application.applicationinfo.allowed_scopes) if s] + app_scopes = [ + s + for s in re.split( + r"[\s\n]+", application.applicationinfo.allowed_scopes + ) + if s + ] except ApplicationInfo.DoesNotExist: app_scopes = ["r:profile"] - kwargs["application"] = dict( - name=application.name - ) + kwargs["application"] = dict(name=application.name) filtered_scopes = set(app_scopes) & set(scopes) - kwargs['scopes'] = list(filtered_scopes) + kwargs["scopes"] = list(filtered_scopes) all_scopes = get_scopes_backend().get_all_scopes() - kwargs['scopes_descriptions'] = {scope: all_scopes[scope] for scope in scopes} + kwargs["scopes_descriptions"] = { + scope: all_scopes[scope] for scope in scopes + } self.oauth2_data = kwargs # Check to see if the user has already granted access and return # a successful response depending on "approval_prompt" url parameter - require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) + require_approval = request.GET.get( + "approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT + ) # If skip_authorization field is True, skip the authorization screen even # if this is the first use of the application and there was no previous authorization. # This is useful for in-house applications-> assume an in-house applications # are already approved. if application.skip_authorization and not request.user.is_anonymous: - success_url = self.get_authorization_redirect_url(" ".join(kwargs['scopes']), credentials) - return Response({'success_url': success_url}) + success_url = self.get_authorization_redirect_url( + " ".join(kwargs["scopes"]), credentials + ) + return Response({"success_url": success_url}) elif require_approval == "auto" and not request.user.is_anonymous: - tokens = get_access_token_model().objects.filter( - user=request.user, - application=application, - expires__gt=timezone.now() - ).all() + tokens = ( + get_access_token_model() + .objects.filter( + user=request.user, + application=application, + expires__gt=timezone.now(), + ) + .all() + ) # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): - success_url = self.get_authorization_redirect_url(" ".join(kwargs['scopes']), credentials) - return Response({'success_url': success_url}) + success_url = self.get_authorization_redirect_url( + " ".join(kwargs["scopes"]), credentials + ) + return Response({"success_url": success_url}) return Response(kwargs) except OAuthToolkitError as error: - return Response({ - 'error': error.oauthlib_error.description - }, status=HTTP_400_BAD_REQUEST) + return Response( + {"error": error.oauthlib_error.description}, status=HTTP_400_BAD_REQUEST + ) -httpsUrlValidator = URLValidator(message="Must be a valid HTTPS URI", schemes=['https']) +httpsUrlValidator = URLValidator(message="Must be a valid HTTPS URI", schemes=["https"]) class RegistrationSerializer(serializers.Serializer): - client_name = serializers.CharField(required=True, source='name') - client_uri = serializers.URLField(required=True, source='applicationinfo.website_url', validators=[httpsUrlValidator]) - logo_uri = serializers.URLField(required=True, source='applicationinfo.logo_uri', validators=[httpsUrlValidator]) - tos_uri = serializers.URLField(required=True, source='applicationinfo.terms_uri', validators=[httpsUrlValidator]) - policy_uri = serializers.URLField(required=True, source='applicationinfo.policy_uri', validators=[httpsUrlValidator]) - software_id = serializers.CharField(required=True, source='applicationinfo.software_id') - software_version = serializers.CharField(required=True, source='applicationinfo.software_version') - redirect_uris = serializers.ListField(child=serializers.URLField(validators=[httpsUrlValidator]), required=True) - token_endpoint_auth_method = serializers.CharField(required=False, default='client_secret_basic') - grant_types = serializers.ListField(child=serializers.CharField(), required=False, default=['authorization_code']) - response_types = serializers.ListField(child=serializers.CharField(), required=False, default=['code']) + client_name = serializers.CharField(required=True, source="name") + client_uri = serializers.URLField( + required=True, + source="applicationinfo.website_url", + validators=[httpsUrlValidator], + ) + logo_uri = serializers.URLField( + required=True, source="applicationinfo.logo_uri", validators=[httpsUrlValidator] + ) + tos_uri = serializers.URLField( + required=True, + source="applicationinfo.terms_uri", + validators=[httpsUrlValidator], + ) + policy_uri = serializers.URLField( + required=True, + source="applicationinfo.policy_uri", + validators=[httpsUrlValidator], + ) + software_id = serializers.CharField( + required=True, source="applicationinfo.software_id" + ) + software_version = serializers.CharField( + required=True, source="applicationinfo.software_version" + ) + redirect_uris = serializers.ListField( + child=serializers.URLField(validators=[httpsUrlValidator]), required=True + ) + token_endpoint_auth_method = serializers.CharField( + required=False, default="client_secret_basic" + ) + grant_types = serializers.ListField( + child=serializers.CharField(), required=False, default=["authorization_code"] + ) + response_types = serializers.ListField( + child=serializers.CharField(), required=False, default=["code"] + ) scope = serializers.CharField( - required=False, source='applicationinfo.allowed_scopes', default=' '.join(BADGE_CONNECT_SCOPES) + required=False, + source="applicationinfo.allowed_scopes", + default=" ".join(BADGE_CONNECT_SCOPES), ) client_id = serializers.CharField(read_only=True) @@ -176,49 +254,49 @@ class RegistrationSerializer(serializers.Serializer): def get_client_id_issued_at(self, obj): try: - return int(obj.created.strftime('%s')) + return int(obj.created.strftime("%s")) except AttributeError: return None def validate_grant_types(self, val): - if 'authorization_code' not in val: - raise serializers.ValidationError( - "Missing authorization_code grant type" - ) + if "authorization_code" not in val: + raise serializers.ValidationError("Missing authorization_code grant type") for grant_type in val: - if grant_type not in ['authorization_code', 'refresh_token']: + if grant_type not in ["authorization_code", "refresh_token"]: raise serializers.ValidationError( "Invalid grant types. Only authorization_code and refresh_token supported" ) return val def validate_response_types(self, val): - if val != ['code']: + if val != ["code"]: raise serializers.ValidationError("Invalid response type") return val def validate_scope(self, val): if val: - scopes = val.split(' ') + scopes = val.split(" ") included = [] for scope in scopes: if scope in BADGE_CONNECT_SCOPES: included.append(scope) if len(included): - return ' '.join(set(included)) + return " ".join(set(included)) raise serializers.ValidationError( "No supported Badge Connect scopes requested. See manifest for supported scopes." ) else: # If no scopes provided, we assume they want all scopes - return ' '.join(BADGE_CONNECT_SCOPES) + return " ".join(BADGE_CONNECT_SCOPES) def validate_token_endpoint_auth_method(self, val): - if val != 'client_secret_basic': - raise serializers.ValidationError("Invalid token authentication method. Only client_secret_basic allowed.") + if val != "client_secret_basic": + raise serializers.ValidationError( + "Invalid token authentication method. Only client_secret_basic allowed." + ) return val def validate(self, data): @@ -232,15 +310,15 @@ def parse_uri(uri): uris.add(parsed.netloc) schemes.add(parsed.scheme) - parse_uri(data['applicationinfo']['website_url']) - parse_uri(data['applicationinfo']['logo_uri']) - parse_uri(data['applicationinfo']['terms_uri']) - parse_uri(data['applicationinfo']['policy_uri']) + parse_uri(data["applicationinfo"]["website_url"]) + parse_uri(data["applicationinfo"]["logo_uri"]) + parse_uri(data["applicationinfo"]["terms_uri"]) + parse_uri(data["applicationinfo"]["policy_uri"]) # if ApplicationInfo.objects.filter(website_url=data.get('client_uri')).exists(): # raise serializers.ValidationError("Client already registered") - for redirect in data.get('redirect_uris'): + for redirect in data.get("redirect_uris"): # if app_model.objects.filter(redirect_uris__contains=redirect): # raise serializers.ValidationError("Redirect URI already registered") parse_uri(redirect) @@ -252,36 +330,41 @@ def parse_uri(uri): return data def fetch_and_process_logo_uri(self, logo_uri): - return fetch_remote_file_to_storage(logo_uri, upload_to='remote/application', - allowed_mime_types=['image/png', 'image/svg+xml'], - resize_to_height=512) + return fetch_remote_file_to_storage( + logo_uri, + upload_to="remote/application", + allowed_mime_types=["image/png", "image/svg+xml"], + resize_to_height=512, + ) def create(self, validated_data): app_model = get_application_model() app = app_model.objects.create( - name=validated_data['name'], - redirect_uris=' '.join(validated_data['redirect_uris']), - authorization_grant_type=app_model.GRANT_AUTHORIZATION_CODE + name=validated_data["name"], + redirect_uris=" ".join(validated_data["redirect_uris"]), + authorization_grant_type=app_model.GRANT_AUTHORIZATION_CODE, ) saved_logo_uri = "" - if validated_data['applicationinfo']['logo_uri'] is not None: - logo_uri = validated_data['applicationinfo']['logo_uri'] + if validated_data["applicationinfo"]["logo_uri"] is not None: + logo_uri = validated_data["applicationinfo"]["logo_uri"] status_code, image = self.fetch_and_process_logo_uri(logo_uri) if status_code == 200: - saved_logo_uri = getattr(settings, 'HTTP_ORIGIN') + default_storage.url(image) + saved_logo_uri = getattr(settings, "HTTP_ORIGIN") + default_storage.url( + image + ) app_info = ApplicationInfo( application=app, - website_url=validated_data['applicationinfo']['website_url'], + website_url=validated_data["applicationinfo"]["website_url"], logo_uri=saved_logo_uri, - terms_uri=validated_data['applicationinfo']['terms_uri'], - policy_uri=validated_data['applicationinfo']['policy_uri'], - software_id=validated_data['applicationinfo']['software_id'], - software_version=validated_data['applicationinfo']['software_version'], - allowed_scopes=validated_data['applicationinfo']['allowed_scopes'], - issue_refresh_token='refresh_token' in validated_data.get('grant_types') + terms_uri=validated_data["applicationinfo"]["terms_uri"], + policy_uri=validated_data["applicationinfo"]["policy_uri"], + software_id=validated_data["applicationinfo"]["software_id"], + software_version=validated_data["applicationinfo"]["software_version"], + allowed_scopes=validated_data["applicationinfo"]["allowed_scopes"], + issue_refresh_token="refresh_token" in validated_data.get("grant_types"), ) app_info.save() @@ -290,13 +373,14 @@ def create(self, validated_data): def to_representation(self, instance): rep = super(RegistrationSerializer, self).to_representation(instance) - if ' ' in instance.redirect_uris: - rep['redirect_uris'] = ' '.split(instance.redirect_uris) + if " " in instance.redirect_uris: + rep["redirect_uris"] = " ".split(instance.redirect_uris) else: - rep['redirect_uris'] = [instance.redirect_uris] + rep["redirect_uris"] = [instance.redirect_uris] return rep +@extend_schema(exclude=True) class RegisterApiView(APIView): permission_classes = [] @@ -307,6 +391,251 @@ def post(self, request, **kwargs): return Response(serializer.data, status=HTTP_201_CREATED) +# this method allows users that are authorized (but do not have to be admins) to register client credentials for their account, currently the user can only choose the name +class PublicRegistrationSerializer(serializers.Serializer): + client_name = serializers.CharField(required=True, source="name") + grant_types = serializers.ListField( + child=serializers.CharField(), required=False, default=["client-credentials"] + ) + response_types = serializers.ListField( + child=serializers.CharField(), required=False, default=["code"] + ) + scope = serializers.CharField( + required=False, + source="applicationinfo.allowed_scopes", + default="rw:issuer rw:backpack rw:profile", + ) + + client_id = serializers.CharField(read_only=True) + client_secret = serializers.CharField(read_only=True) + client_id_issued_at = serializers.SerializerMethodField(read_only=True) + client_secret_expires_at = serializers.IntegerField(default=0, read_only=True) + + @extend_schema_field(OpenApiTypes.INT) + def get_client_id_issued_at(self, obj): + try: + return int(obj.created.strftime("%s")) + except AttributeError: + return None + + def validate_response_types(self, val): + if val != ["code"]: + raise serializers.ValidationError("Invalid response type") + return val + + def validate_scope(self, val): + if val: + scopes = val.split(" ") + included = [] + for scope in scopes: + if scope in ["rw:issuer", "rw:backpack", "rw:profile"]: + included.append(scope) + + if len(included): + return " ".join(set(included)) + raise serializers.ValidationError( + "No supported Badge Connect scopes requested. See manifest for supported scopes." + ) + + else: + # If no scopes provided, we assume they want all scopes + return " ".join(BADGE_CONNECT_SCOPES) + + def validate_token_endpoint_auth_method(self, val): + if val != "client_secret_basic": + raise serializers.ValidationError( + "Invalid token authentication method. Only client_secret_basic allowed." + ) + return val + + def create(self, validated_data): + app_model = get_application_model() + user = self.context["request"].user + app = app_model( + name=validated_data["name"], + user=user, + authorization_grant_type=app_model.GRANT_CLIENT_CREDENTIALS, + client_type=Application.CLIENT_CONFIDENTIAL, + ) + # the client_secret is hashed once saved, to return it + # it has to be stored here + cleartext_client_secret = app.client_secret + app.save() + app_info = ApplicationInfo( + application=app, + allowed_scopes=validated_data["applicationinfo"]["allowed_scopes"], + issue_refresh_token="refresh_token" in validated_data.get("grant_types"), + ) + app_info.save() + + # rewrite client_secret (see above) + app.client_secret = cleartext_client_secret + + return app + + def to_representation(self, instance): + rep = super(PublicRegistrationSerializer, self).to_representation(instance) + if " " in instance.redirect_uris: + rep["redirect_uris"] = " ".split(instance.redirect_uris) + else: + rep["redirect_uris"] = [instance.redirect_uris] + return rep + + +class PublicRegisterApiView(APIView): + permission_classes = [] + permission_classes = (permissions.IsAuthenticated,) + + @extend_schema(exclude=True) + def post(self, request, **kwargs): + serializer = PublicRegistrationSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=HTTP_201_CREATED) + + +def extract_oidc_access_token(request, scope): + """ + Extracts the OIDC access token from the request + + The scope is merely used for compatibility reasons; + Actually OIDC access tokens always have acess to all scopes. + """ + joined_scope = " ".join(scope) + access_token = request.session["oidc_access_token"] + refresh_token = request.session["oidc_refresh_token"] + return build_token( + access_token, get_expire_seconds(access_token), joined_scope, refresh_token + ) + + +def build_token(access_token, expires_in, scope, refresh_token): + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": expires_in, + "scope": scope, + "refresh_token": refresh_token, + } + + +def extract_oidc_refresh_token(request): + """ + Extracts the OIDC refresh token from the request + """ + if "refresh_token" in request.POST: + return request.POST.get("refresh_token") + return request.COOKIES["refresh_token"] + + +def request_renewed_oidc_access_token(self, refresh_token): + token_refresh_payload = { + "refresh_token": refresh_token, + "client_id": getattr(settings, "OIDC_RP_CLIENT_ID"), + "client_secret": getattr(settings, "OIDC_RP_CLIENT_SECRET"), + "grant_type": "refresh_token", + } + + try: + response = requests.post( + getattr(settings, "OIDC_OP_TOKEN_ENDPOINT"), data=token_refresh_payload + ) + response.raise_for_status() + except requests.exceptions.RequestException as e: + logger.error("Failed to refresh session: '%s'", e) + return None + return response.json() + + +def get_expire_seconds(access_token): + """ + Calculates how many more seconds the access token will be valid + + It first extracts the datetime (skipping signature verifications) + and then calculates the diff to the current datetime + """ + expire_datetime = datetime.datetime.fromtimestamp( + jwt.decode(access_token, options={"verify_signature": False})["exp"] + ) + now_datetime = datetime.datetime.now() + diff = expire_datetime - now_datetime + return diff.total_seconds() + + +def setTokenHttpOnly(response): + data = json.loads(response.content.decode("utf-8")) + # Add tokens as cookies + if "access_token" in data: + response.set_cookie( + "access_token", + value=data["access_token"], + httponly=True, + secure=not settings.DEBUG, + max_age=data["expires_in"], + ) + # Remove access token from body + # FIXME: keep for old clients + # del data['access_token'] + if "refresh_token" in data: + response.set_cookie( + "refresh_token", + value=data["refresh_token"], + httponly=True, + secure=not settings.DEBUG, + # Refresh tokens have the same max + # age as access tokens, since they + # should get renewed together with + # the access token. This is only + # relevant for OIDC which I can't + # test right now, so change it if + # needed. + max_age=data["expires_in"], + ) + # Remove refresh token from body + # FIXME: keep for old clients + # del data['refresh_token'] + response.content = json.dumps(data) + return + + +class RevokeTokenView(OAuth2ProviderRevokeTokenView): + def post(self, request, *args, **kwargs): + if "access_token" not in request.COOKIES: + return HttpResponse( + json.dumps({"error": "Access token must be contained in COOKIE"}), + status=HTTP_400_BAD_REQUEST, + ) + else: + # Add the access token to the request, as if it had always been there, + # since the oauth toolkit can't handle the access token in the cookie + access_token = request.COOKIES["access_token"] + body = request.body.decode("utf-8") + body = f"token={access_token}&{body}" + request._body = str.encode(body) + + request.POST._mutable = True + request.POST["token"] = [access_token] + request.POST._mutable = False + + request.META["CONTENT_LENGTH"] = str(len(request.body)) + + response = super().post(request, *args, **kwargs) + if response.status_code == 200: + # For some reason, (this version) does not actually delete / revoke the tokens. + # So I delete them manually, as long as the parent said everything's fine. + token_objects = AccessToken.objects.filter( + token=request.POST["token"][0] + if type(request.POST["token"]) is list + else request.POST["token"] + ) + token_objects.delete() + response.delete_cookie("access_token") + response.delete_cookie("refresh_token") + return response + + class TokenView(OAuth2ProviderTokenView): server_class = BadgrOauthServer validator_class = BadgrRequestValidator @@ -315,66 +644,128 @@ class TokenView(OAuth2ProviderTokenView): def post(self, request, *args, **kwargs): if len(request.GET): return HttpResponse( - json.dumps({"error": "Token grant parameters must be sent in post body, not query parameters"}), - status=HTTP_400_BAD_REQUEST + json.dumps( + { + "error": "Token grant parameters must be sent in post body, not query parameters" + } + ), + status=HTTP_400_BAD_REQUEST, ) - grant_type = request.POST.get('grant_type', 'password') - username = request.POST.get('username') + grant_type = request.POST.get("grant_type", "password") + username = request.POST.get("username") client_id = None try: - auth_header = request.META['HTTP_AUTHORIZATION'] - credentials = auth_header.split(' ') - if credentials[0] == 'Basic': - client_id, client_secret = base64.b64decode(credentials[1].encode('ascii')).decode('ascii').split(':') + auth_header = request.META["HTTP_AUTHORIZATION"] + credentials = auth_header.split(" ") + if credentials[0] == "Basic": + client_id, client_secret = ( + base64.b64decode(credentials[1].encode("ascii")) + .decode("ascii") + .split(":") + ) except (KeyError, IndexError, ValueError, TypeError): - client_id = request.POST.get('client_id', None) + client_id = request.POST.get("client_id", None) client_secret = None # pre-validate scopes requested - requested_scopes = [s for s in scope_to_list(request.POST.get('scope', '')) if s] + requested_scopes = [ + s for s in scope_to_list(request.POST.get("scope", "")) if s + ] oauth_app = None if client_id: try: oauth_app = Application.objects.get(client_id=client_id) - if client_secret and oauth_app.client_secret != client_secret: - return HttpResponse(json.dumps({"error": "invalid client_secret"}), status=HTTP_400_BAD_REQUEST) + if client_secret and not check_password( + client_secret, oauth_app.client_secret + ): + return HttpResponse( + json.dumps({"error": "invalid client_secret"}), + status=HTTP_400_BAD_REQUEST, + ) except Application.DoesNotExist: - return HttpResponse(json.dumps({"error": "invalid client_id"}), status=HTTP_400_BAD_REQUEST) + return HttpResponse( + json.dumps({"error": "invalid client_id"}), + status=HTTP_400_BAD_REQUEST, + ) try: allowed_scopes = oauth_app.applicationinfo.scope_list except ApplicationInfo.DoesNotExist: - allowed_scopes = ['r:profile'] + allowed_scopes = ["r:profile"] # handle rw:issuer:* scopes - if 'rw:issuer:*' in allowed_scopes: - issuer_scopes = [x for x in requested_scopes if x.startswith(r'rw:issuer:')] + if "rw:issuer:*" in allowed_scopes: + issuer_scopes = [ + x for x in requested_scopes if x.startswith(r"rw:issuer:") + ] allowed_scopes.extend(issuer_scopes) filtered_scopes = set(allowed_scopes) & set(requested_scopes) if len(filtered_scopes) < len(requested_scopes): - return HttpResponse(json.dumps({"error": "invalid scope requested"}), status=HTTP_400_BAD_REQUEST) + return HttpResponse( + json.dumps({"error": "invalid scope requested"}), + status=HTTP_400_BAD_REQUEST, + ) - # let parent method do actual authentication - response = super(TokenView, self).post(request, *args, **kwargs) + response = None + if grant_type == "oidc": + if not request.user.is_authenticated: + return HttpResponse( + json.dumps({"error": "User not authenticated in session!"}), + status=HTTP_401_UNAUTHORIZED, + ) + token = extract_oidc_access_token(request, requested_scopes) + app_authorized.send( + sender=self, request=request, token=token.get("access_token") + ) + response = HttpResponse(content=json.dumps(token), status=200) + # Log out of the django session, since from now on we use token authentication; the session authentication was + # only used to obtain the access token + logout(request) + elif grant_type == "refresh_token": + # Refreshes OIDC access tokens. + # Normal access tokens don't need to be refreshed, + # since they are valid for 24h + refresh_token = extract_oidc_refresh_token(request) + token = request_renewed_oidc_access_token(self, refresh_token) + if token is None: + return HttpResponse( + json.dumps({"error": "Token refresh failed!"}), + status=HTTP_401_UNAUTHORIZED, + ) + # Adding the scope for compatibility reasons, even though OIDC access tokens + # have access to everything + token["scope"] = requested_scopes + response = HttpResponse(content=json.dumps(token), status=200) + else: + # All other grant types our parent can handle + response = super(TokenView, self).post(request, *args, **kwargs) if oauth_app and not oauth_app.applicationinfo.issue_refresh_token: data = json.loads(response.content) try: - del data['refresh_token'] + del data["refresh_token"] except KeyError: pass response.content = json.dumps(data) if grant_type == "password" and response.status_code == 401: - badgrlogger.event(badgrlog.FailedLoginAttempt(request, username, endpoint='/o/token')) + logger.info( + "Failed login attempt from '%s' (response code: %s)", + username, + response.status_code, + ) + + if response.status_code == 200: + setTokenHttpOnly(response) return response +@extend_schema(exclude=True) class AuthCodeExchange(APIView): permission_classes = [] @@ -382,7 +773,7 @@ def post(self, request, **kwargs): def _error_response(): return Response({"error": "Invalid authcode"}, status=HTTP_400_BAD_REQUEST) - code = request.data.get('code') + code = request.data.get("code") if not code: return _error_response() @@ -391,9 +782,9 @@ def _error_response(): return _error_response() data = dict( - access_token=accesstoken.token, - token_type="Bearer", - scope=accesstoken.scope + access_token=accesstoken.token, token_type="Bearer", scope=accesstoken.scope ) - return Response(data, status=HTTP_200_OK) + response = Response(data, status=HTTP_200_OK) + setTokenHttpOnly(response) + return response diff --git a/apps/mainsite/oauth_validator.py b/apps/mainsite/oauth_validator.py index fac119ccd..b75e83c15 100644 --- a/apps/mainsite/oauth_validator.py +++ b/apps/mainsite/oauth_validator.py @@ -10,33 +10,47 @@ class BadgrOauthServer(Server): """ used for providing a default grant type """ + @property def default_grant_type(self): return "password" class BadgrRequestValidator(OAuth2Validator): - def authenticate_client(self, request, *args, **kwargs): # if a request doesnt include client_id or grant_type assume defaults if not (request.client_id and request.grant_type and request.client_secret): try: - auth_header = request.headers.get('HTTP_AUTHORIZATION', None) - credentials = auth_header.split(' ') - if credentials[0] == 'Basic': - request.client_id, request.client_secret = base64.b64decode( - credentials[1].encode('ascii') - ).decode('ascii').split(':') - - except (KeyError, IndexError, TypeError, ValueError, AttributeError,): - request.grant_type = 'password' - request.client_id = getattr(settings, 'OAUTH2_DEFAULT_CLIENT_ID', 'public') - request.client_secret = '' - request.scopes = ['rw:profile', 'rw:issuer', 'rw:backpack'] - return super(BadgrRequestValidator, self).authenticate_client(request, *args, **kwargs) + auth_header = request.headers.get("HTTP_AUTHORIZATION", None) + credentials = auth_header.split(" ") + if credentials[0] == "Basic": + request.client_id, request.client_secret = ( + base64.b64decode(credentials[1].encode("ascii")) + .decode("ascii") + .split(":") + ) + + except ( + KeyError, + IndexError, + TypeError, + ValueError, + AttributeError, + ): + request.grant_type = "password" + request.client_id = getattr( + settings, "OAUTH2_DEFAULT_CLIENT_ID", "public" + ) + request.client_secret = "" + request.scopes = ["rw:profile", "rw:issuer", "rw:backpack"] + return super(BadgrRequestValidator, self).authenticate_client( + request, *args, **kwargs + ) def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): - available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request) + available_scopes = get_scopes_backend().get_available_scopes( + application=client, request=request + ) for scope in scopes: if not self.is_scope_valid(scope, available_scopes): @@ -46,9 +60,9 @@ def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): def is_scope_valid(self, scope, available_scopes): for available_scope in available_scopes: - if available_scope.endswith(':*'): - base_available_scope, _ = available_scope.rsplit(':*', 1) - base_scope, _ = scope.rsplit(':', 1) + if available_scope.endswith(":*"): + base_available_scope, _ = available_scope.rsplit(":*", 1) + base_scope, _ = scope.rsplit(":", 1) if base_scope == base_available_scope: return True @@ -56,4 +70,3 @@ def is_scope_valid(self, scope, available_scopes): return True return False - diff --git a/apps/mainsite/openapi.py b/apps/mainsite/openapi.py new file mode 100644 index 000000000..e10b9c777 --- /dev/null +++ b/apps/mainsite/openapi.py @@ -0,0 +1,53 @@ +from drf_spectacular.extensions import OpenApiAuthenticationExtension + + +class BadgrOAuth2AuthenticationScheme(OpenApiAuthenticationExtension): + """ + Extension for BadgrOAuth2Authentication. + + This tells drf-spectacular that BadgrOAuth2Authentication uses OAuth2 + with the authorization code flow. It will appear in the Swagger UI + as an "Authorize" button where users can authenticate via OAuth2. + """ + + target_class = "mainsite.authentication.BadgrOAuth2Authentication" + name = "oauth2" + + def get_security_definition(self, auto_schema): + return { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "/o/authorize/", + "tokenUrl": "/o/token/", + "refreshUrl": "/o/token/", + "scopes": { + "read": "Read access to resources", + "write": "Write access to resources", + }, + } + }, + "description": "OAuth2 authentication using django-oauth-toolkit", + } + + +class LoggedLegacyTokenAuthenticationScheme(OpenApiAuthenticationExtension): + """ + Extension for LoggedLegacyTokenAuthentication. + + This is a wrapper around DRF's TokenAuthentication that logs usage + (because it's deprecated). We give it a unique name 'legacyToken' to + avoid collision with the standard 'tokenAuth' name. + """ + + target_class = "mainsite.authentication.LoggedLegacyTokenAuthentication" + name = "legacyToken" + + def get_security_definition(self, auto_schema): + return { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "Legacy token authentication. Format: `Token `. " + "This method is deprecated and logged for security auditing.", + } diff --git a/apps/mainsite/pagination.py b/apps/mainsite/pagination.py index fb2c4089e..8d16771b1 100644 --- a/apps/mainsite/pagination.py +++ b/apps/mainsite/pagination.py @@ -3,8 +3,8 @@ class BadgrCursorPagination(CursorPagination): - ordering = '-created_at' - page_size_query_param = 'num' + ordering = "-created_at" + page_size_query_param = "num" offset_cutoff = 15000 def __init__(self, ordering=None, page_size=None): @@ -21,12 +21,17 @@ def get_link_header(self): if self.has_previous: links.append('<{}>; rel="prev"'.format(self.get_previous_link())) if len(links): - return ', '.join(links) + return ", ".join(links) def get_page_info(self): - return OrderedDict([ - ('hasNext', self.has_next), - ('nextResults', self.get_next_link() if self.has_next else None), - ('hasPrevious', self.has_previous), - ('previousResults', self.get_previous_link() if self.has_previous else None), - ]) + return OrderedDict( + [ + ("hasNext", self.has_next), + ("nextResults", self.get_next_link() if self.has_next else None), + ("hasPrevious", self.has_previous), + ( + "previousResults", + self.get_previous_link() if self.has_previous else None, + ), + ] + ) diff --git a/apps/mainsite/permissions.py b/apps/mainsite/permissions.py index f6b12da5e..5583d9617 100644 --- a/apps/mainsite/permissions.py +++ b/apps/mainsite/permissions.py @@ -1,8 +1,6 @@ import oauth2_provider from rest_framework import permissions -from badgeuser.models import CachedEmailAddress - class IsOwner(permissions.BasePermission): """ @@ -17,6 +15,7 @@ class IsRequestUser(permissions.BasePermission): """ Allows users to be able to act on their own profile, but not on others. """ + def has_object_permission(self, request, view, obj): return obj == request.user @@ -25,6 +24,7 @@ class AuthenticatedWithVerifiedIdentifier(permissions.BasePermission): """ Allows access only to authenticated users who have verified email addresses. """ + message = "This function only available to authenticated users with confirmed email addresses." def has_permission(self, request, view): @@ -35,11 +35,15 @@ class IsServerAdmin(permissions.BasePermission): def check_permission(self, request): token = request.auth + if not token and request.user.is_superuser: + return True + if token is None or not isinstance(token, oauth2_provider.models.AccessToken): return False token_scopes = set(token.scope.split()) - return 'rw:serverAdmin' in token_scopes + + return "rw:serverAdmin" in token_scopes def has_permission(self, request, view): return self.check_permission(request) diff --git a/apps/mainsite/renderers.py b/apps/mainsite/renderers.py index b5ae56055..b003cf107 100644 --- a/apps/mainsite/renderers.py +++ b/apps/mainsite/renderers.py @@ -8,16 +8,17 @@ class JSONLDRenderer(renderers.JSONRenderer): """ A simple wrapper for JSONRenderer that declares that we're delivering LD. """ - media_type = 'application/ld+json' - format = 'ld+json' + + media_type = "application/ld+json" + format = "ld+json" class CSVDictRenderer(renderers.BaseRenderer): - media_type = 'text/csv' - format = 'csv' + media_type = "text/csv" + format = "csv" def render(self, data, media_type=None, renderer_context=None): - response = renderer_context.get('response', None) + response = renderer_context.get("response", None) if response is not None and response.exception: return None @@ -31,8 +32,8 @@ def render(self, data, media_type=None, renderer_context=None): # fieldnames = data.keys() # rows = [data] else: - fieldnames = data['fieldnames'] - rows = data['rowdicts'] + fieldnames = data["fieldnames"] + rows = data["rowdicts"] buff = io.StringIO() writer = csv.DictWriter(buff, fieldnames=fieldnames) diff --git a/apps/mainsite/serializers.py b/apps/mainsite/serializers.py index a113afb5f..8d133a003 100644 --- a/apps/mainsite/serializers.py +++ b/apps/mainsite/serializers.py @@ -1,32 +1,33 @@ -from collections import OrderedDict import json -import pytz +from collections import OrderedDict +import pytz from django.utils.html import strip_tags -from rest_framework import serializers -from rest_framework.exceptions import ValidationError -from rest_framework.authtoken.serializers import AuthTokenSerializer - -import badgrlog from entity.serializers import BaseSerializerV2 from mainsite.pagination import BadgrCursorPagination +from rest_framework import serializers +from rest_framework.authtoken.serializers import AuthTokenSerializer +from rest_framework.exceptions import ValidationError +import logging -badgrlogger = badgrlog.BadgrLogger() +logger = logging.getLogger("Badgr.Events") class HumanReadableBooleanField(serializers.BooleanField): - TRUE_VALUES = serializers.BooleanField.TRUE_VALUES | set(('on', 'On', 'ON')) - TRUE_VALUES = serializers.BooleanField.TRUE_VALUES | set(('on', 'On', 'ON')) - FALSE_VALUES = serializers.BooleanField.FALSE_VALUES | set(('off', 'Off', 'OFF')) + TRUE_VALUES = serializers.BooleanField.TRUE_VALUES | set(("on", "On", "ON")) + TRUE_VALUES = serializers.BooleanField.TRUE_VALUES | set(("on", "On", "ON")) + FALSE_VALUES = serializers.BooleanField.FALSE_VALUES | set(("off", "Off", "OFF")) class ReadOnlyJSONField(serializers.CharField): - def to_representation(self, value): if isinstance(value, (dict, list)): return value else: - raise serializers.ValidationError("WriteableJsonField: Did not get a JSON-serializable datatype from storage for this item: " + str(value)) + raise serializers.ValidationError( + "WriteableJsonField: Did not get a JSON-serializable datatype " + "from storage for this item: " + str(value) + ) class WritableJSONField(ReadOnlyJSONField): @@ -35,18 +36,23 @@ def to_internal_value(self, data): internal_value = json.loads(data) except Exception: # TODO: this is going to choke on dict input, when it should be allowed in addition to JSON. - raise serializers.ValidationError("WriteableJsonField: Could not process input into a python dict for storage " + str(data)) + raise serializers.ValidationError( + "WriteableJsonField: Could not process input into a python dict for storage " + + str(data) + ) return internal_value class LinkedDataEntitySerializer(serializers.Serializer): def to_representation(self, instance): - representation = super(LinkedDataEntitySerializer, self).to_representation(instance) - representation['@id'] = instance.jsonld_id + representation = super(LinkedDataEntitySerializer, self).to_representation( + instance + ) + representation["@id"] = instance.jsonld_id try: - representation['@type'] = self.jsonld_type + representation["@type"] = self.jsonld_type except AttributeError: pass @@ -59,8 +65,9 @@ class LinkedDataReferenceField(serializers.Serializer): Includes their @id by default and any additional identifier keys that are the named properties on the instance. """ + def __init__(self, keys=[], model=None, read_only=True, field_names=None, **kwargs): - kwargs.pop('many', None) + kwargs.pop("many", None) super(LinkedDataReferenceField, self).__init__(read_only=read_only, **kwargs) self.included_keys = keys self.model = model @@ -68,7 +75,7 @@ def __init__(self, keys=[], model=None, read_only=True, field_names=None, **kwar def to_representation(self, obj): output = OrderedDict() - output['@id'] = obj.jsonld_id + output["@id"] = obj.jsonld_id for key in self.included_keys: field_name = key @@ -80,7 +87,7 @@ def to_representation(self, obj): def to_internal_value(self, data): if not isinstance(data, str): - idstring = data.get('@id') + idstring = data.get("@id") else: idstring = data @@ -88,8 +95,8 @@ def to_internal_value(self, data): return self.model.cached.get_by_id(idstring) except AttributeError: raise TypeError( - "LinkedDataReferenceField model must be declared and use cache " + - "manager that implements get_by_id method." + "LinkedDataReferenceField model must be declared and use cache " + + "manager that implements get_by_id method." ) @@ -106,6 +113,7 @@ class JSONDictField(serializers.DictField): """ A DictField that also accepts JSON strings as input """ + def to_internal_value(self, data): try: data = json.loads(data) @@ -126,8 +134,10 @@ def get_url(self, obj, view_name, request, format): class StripTagsCharField(serializers.CharField): def __init__(self, *args, **kwargs): - self.strip_tags = kwargs.pop('strip_tags', True) - self.convert_null = kwargs.pop('convert_null', False) # Converts db nullable fields to empty strings + self.strip_tags = kwargs.pop("strip_tags", True) + self.convert_null = kwargs.pop( + "convert_null", False + ) # Converts db nullable fields to empty strings super(StripTagsCharField, self).__init__(*args, **kwargs) def to_internal_value(self, data): @@ -145,8 +155,8 @@ def get_attribute(self, instance): class MarkdownCharFieldValidator(object): def __call__(self, value): - if '![' in value: - raise ValidationError('Images not supported in markdown') + if "![" in value: + raise ValidationError("Images not supported in markdown") class MarkdownCharField(StripTagsCharField): @@ -156,46 +166,112 @@ class MarkdownCharField(StripTagsCharField): class LegacyVerifiedAuthTokenSerializer(AuthTokenSerializer): def validate(self, attrs): attrs = super(LegacyVerifiedAuthTokenSerializer, self).validate(attrs) - user = attrs.get('user') + user = attrs.get("user") - badgrlogger.event(badgrlog.DeprecatedApiAuthToken(self.context['request'], user.username, is_new_token=True)) + logger.warning("Deprecated new auth token") + logger.info("Username: '%s'", user.username) if not user.verified: try: email = user.cached_emails()[0] email.send_confirmation() - except IndexError as e: + except IndexError: pass - raise ValidationError('You must verify your primary email address before you can sign in.') + raise ValidationError( + "You must verify your primary email address before you can sign in." + ) return attrs class OriginalJsonSerializerMixin(serializers.Serializer): def to_representation(self, instance): - representation = super(OriginalJsonSerializerMixin, self).to_representation(instance) + representation = super(OriginalJsonSerializerMixin, self).to_representation( + instance + ) - if hasattr(instance, 'get_filtered_json'): + if hasattr(instance, "get_filtered_json"): # properties in original_json not natively supported extra_properties = instance.get_filtered_json() if extra_properties and len(extra_properties) > 0: for k, v in list(extra_properties.items()): - if k not in representation or v is not None and representation.get(k, None) is None: + if ( + k not in representation + or v is not None + and representation.get(k, None) is None + ): representation[k] = v return representation +class ExcludeFieldsMixin: + """ + A mixin to recursively exclude specific fields from the given request data. + + Use in a serializers `get_fields` method to enable it: + ``` + def get_fields(self): + fields = super().get_fields() + ... + # Use the mixin to exclude any fields that are unwantend in the final result + exclude_fields = self.context.get("exclude_fields", []) + self.exclude_fields(fields, exclude_fields) + return fields + ``` + + Then use the context of the serializer to enable it: + ``` + context["exclude_fields"] = [ + *context.get("exclude_fields", []), + "staff", + "created_by", + ] + ``` + + You can also hook into the `to_representation` method + to exclude fields from the final json (e.g. when extensions are present) + instead of using the get_fields method: + ``` + def to_representation(self, instance): + representation = super(BadgeClassSerializerV1, self).to_representation(instance) + exclude_fields = self.context.get("exclude_fields", []) + self.exclude_fields(representation, exclude_fields) + ... + return representation + ``` + """ + + def exclude_fields(self, data, fields_to_exclude): + """ + Exclude specified fields from the given request data recusively. + """ + for field in fields_to_exclude: + if isinstance(data, dict): + data.pop(field, None) + for key in data.keys(): + data[key] = self.exclude_fields(data[key], [field]) + elif isinstance(data, list): + for item in data: + self.exclude_fields(item, [field]) + + return data + + class CursorPaginatedListSerializer(serializers.ListSerializer): - def __init__(self, queryset, request, ordering='updated_at', *args, **kwargs): + def __init__(self, queryset, request, ordering="updated_at", *args, **kwargs): self.paginator = BadgrCursorPagination(ordering=ordering) self.page = self.paginator.paginate_queryset(queryset, request) - super(CursorPaginatedListSerializer, self).__init__(data=self.page, *args, **kwargs) + super(CursorPaginatedListSerializer, self).__init__( + data=self.page, *args, **kwargs + ) def to_representation(self, data): - representation = super(CursorPaginatedListSerializer, self).to_representation(data) - envelope = BaseSerializerV2.response_envelope(result=representation, - success=True, - description='ok') - envelope['pagination'] = self.paginator.get_page_info() + representation = super(CursorPaginatedListSerializer, self).to_representation( + data + ) + envelope = BaseSerializerV2.response_envelope( + result=representation, success=True, description="ok" + ) + envelope["pagination"] = self.paginator.get_page_info() return envelope @property @@ -208,12 +284,12 @@ class DateTimeWithUtcZAtEndField(serializers.DateTimeField): class ApplicationInfoSerializer(serializers.Serializer): - name = serializers.CharField(read_only=True, source='get_visible_name') - image = serializers.URLField(read_only=True, source='get_icon_url') + name = serializers.CharField(read_only=True, source="get_visible_name") + image = serializers.URLField(read_only=True, source="get_icon_url") website_url = serializers.URLField(read_only=True) - clientId = serializers.CharField(read_only=True, source='application.client_id') - policyUri = serializers.URLField(read_only=True, source='policy_uri') - termsUri = serializers.URLField(read_only=True, source='terms_uri') + clientId = serializers.CharField(read_only=True, source="application.client_id") + policyUri = serializers.URLField(read_only=True, source="policy_uri") + termsUri = serializers.URLField(read_only=True, source="terms_uri") class AuthorizationSerializer(serializers.Serializer): diff --git a/apps/mainsite/settings.py b/apps/mainsite/settings.py index cac1399f4..b05a746a0 100644 --- a/apps/mainsite/settings.py +++ b/apps/mainsite/settings.py @@ -1,9 +1,11 @@ -import sys +from cryptography.fernet import Fernet import os +from datetime import datetime +import pytz +import mainsite +from corsheaders.defaults import default_headers from mainsite import TOP_DIR -import logging - ## # @@ -12,74 +14,82 @@ ## INSTALLED_APPS = [ - 'mainsite', - - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.admin', - 'django_object_actions', - 'markdownify', - - 'badgeuser', - - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'badgrsocialauth', - 'badgrsocialauth.providers.facebook', - 'badgrsocialauth.providers.kony', - 'badgrsocialauth.providers.twitter', - 'allauth.socialaccount.providers.azure', - 'allauth.socialaccount.providers.auth0', - 'allauth.socialaccount.providers.google', - 'allauth.socialaccount.providers.linkedin_oauth2', - 'allauth.socialaccount.providers.oauth2', - 'corsheaders', - 'rest_framework', - 'rest_framework.authtoken', - 'django_celery_results', - + "mainsite", + "django.contrib.auth", + "mozilla_django_oidc", # Load after auth + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.admin", + "django_object_actions", + "django_prometheus", + "markdownify", + "badgeuser", + "allauth", + "allauth.account", + "allauth.socialaccount", + "badgrsocialauth", + "badgrsocialauth.providers.facebook", + "badgrsocialauth.providers.kony", + "badgrsocialauth.providers.twitter", + "allauth.socialaccount.providers.auth0", + "allauth.socialaccount.providers.linkedin_oauth2", + "allauth.socialaccount.providers.oauth2", + "corsheaders", + "rest_framework", + "rest_framework.authtoken", + "django_celery_results", + "dbbackup", # django-dbbackup # OAuth 2 provider - 'oauth2_provider', - - 'entity', - 'issuer', - 'backpack', - 'externaltools', - + "oauth2_provider", + "oidc", + "entity", + "issuer", + "backpack", # api docs - 'apispec_drf', - + "drf_spectacular", # deprecated - 'composition', + "composition", + "django_filters", + "lti_tool", + "mjml", ] MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'oauth2_provider.middleware.OAuth2TokenMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'mainsite.middleware.XframeExempt500Middleware', - 'mainsite.middleware.MaintenanceMiddleware', - 'badgeuser.middleware.InactiveUserMiddleware', + "django_prometheus.middleware.PrometheusBeforeMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + # It's important that CookieToBearerMiddleware comes before + # the Oauth2TokenMiddleware + "mainsite.middleware.CookieToBearerMiddleware", + "oauth2_provider.middleware.OAuth2TokenMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "mainsite.middleware.XframeExempt500Middleware", + "mainsite.middleware.MaintenanceMiddleware", + "badgeuser.middleware.InactiveUserMiddleware", # 'mainsite.middleware.TrailingSlashMiddleware', + "django_prometheus.middleware.PrometheusAfterMiddleware", + "lti_tool.middleware.LtiLaunchMiddleware", + "mainsite.middleware.ExceptionLoggingMiddleware", + "allauth.account.middleware.AccountMiddleware", ] -ROOT_URLCONF = 'mainsite.urls' +DBBACKUP_STORAGE = "django.core.files.storage.FileSystemStorage" +DBBACKUP_STORAGE_OPTIONS = {"location": "/backups/"} + +ROOT_URLCONF = "mainsite.urls" # Hosts/domain names that are valid for this site. # "*" matches anything, ".example.com" matches example.com and all subdomains # ALLOWED_HOSTS = ['', ] -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") ## @@ -90,31 +100,28 @@ TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', - - 'mainsite.context_processors.extra_settings' + "BACKEND": "django.template.backends.django.DjangoTemplates", + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.request", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + "mainsite.context_processors.extra_settings", ], - 'loaders': ( - 'django.template.loaders.app_directories.Loader', - 'django.template.loaders.filesystem.Loader', + "loaders": ( + "django.template.loaders.app_directories.Loader", + "django.template.loaders.filesystem.Loader", ), }, - }, ] - - ## # # Static Files @@ -124,14 +131,14 @@ HTTP_ORIGIN = "http://localhost:8000" STATICFILES_FINDERS = [ - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] -STATIC_ROOT = os.path.join(TOP_DIR, 'staticfiles') -STATIC_URL = HTTP_ORIGIN+'/static/' +STATIC_ROOT = os.path.join(TOP_DIR, "staticfiles") +STATIC_URL = HTTP_ORIGIN + "/static/" STATICFILES_DIRS = [ - os.path.join(TOP_DIR, 'apps', 'mainsite', 'static'), + os.path.join(TOP_DIR, "apps", "mainsite", "static"), ] ## @@ -140,70 +147,64 @@ # ## -AUTH_USER_MODEL = 'badgeuser.BadgeUser' -LOGIN_URL = '/accounts/login/' -LOGIN_REDIRECT_URL = '/docs' +AUTH_USER_MODEL = "badgeuser.BadgeUser" +LOGIN_URL = "/accounts/login/" +LOGIN_REDIRECT_URL = "/docs" AUTHENTICATION_BACKENDS = [ - 'oauth2_provider.backends.OAuth2Backend', - + "oidc.oeb_oidc_authentication_backend.OebOIDCAuthenticationBackend", + "oauth2_provider.backends.OAuth2Backend", # Object permissions for issuing badges - 'rules.permissions.ObjectPermissionBackend', - + "rules.permissions.ObjectPermissionBackend", # Needed to login by username in Django admin, regardless of `allauth` "badgeuser.backends.CachedModelBackend", - # `allauth` specific authentication methods, such as login by e-mail - "badgeuser.backends.CachedAuthenticationBackend" - + "badgeuser.backends.CachedAuthenticationBackend", ] -ACCOUNT_DEFAULT_HTTP_PROTOCOL = 'http' -ACCOUNT_ADAPTER = 'mainsite.account_adapter.BadgrAccountAdapter' -ACCOUNT_EMAIL_VERIFICATION = 'mandatory' +ACCOUNT_DEFAULT_HTTP_PROTOCOL = "http" +ACCOUNT_ADAPTER = "mainsite.account_adapter.BadgrAccountAdapter" +ACCOUNT_EMAIL_VERIFICATION = "mandatory" ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_USER_MODEL_USERNAME_FIELD = None ACCOUNT_CONFIRM_EMAIL_ON_GET = True ACCOUNT_LOGOUT_ON_GET = True -ACCOUNT_AUTHENTICATION_METHOD = 'email' -ACCOUNT_FORMS = { - 'add_email': 'badgeuser.account_forms.AddEmailForm' -} -ACCOUNT_SIGNUP_FORM_CLASS = 'badgeuser.forms.BadgeUserCreationForm' +ACCOUNT_AUTHENTICATION_METHOD = "email" +ACCOUNT_FORMS = {"add_email": "badgeuser.account_forms.AddEmailForm"} +ACCOUNT_SIGNUP_FORM_CLASS = "badgeuser.forms.BadgeUserCreationForm" SOCIALACCOUNT_EMAIL_REQUIRED = False -SOCIALACCOUNT_EMAIL_VERIFICATION = 'optional' +SOCIALACCOUNT_EMAIL_VERIFICATION = "optional" SOCIALACCOUNT_PROVIDERS = { - 'kony': { - 'environment': 'dev' - }, - 'azure': { - 'VERIFIED_EMAIL': True + "kony": {"environment": "dev"}, + "linkedin_oauth2": {"VERIFIED_EMAIL": True}, + "auth0": { + "AUTH0_URL": "https://mybadges.eu.auth0.com", }, - 'linkedin_oauth2': { - 'VERIFIED_EMAIL': True - }, - 'auth0': { - 'AUTH0_URL': 'https://mybadges.eu.auth0.com', - } } -SOCIALACCOUNT_ADAPTER = 'badgrsocialauth.adapter.BadgrSocialAccountAdapter' +SOCIALACCOUNT_ADAPTER = "badgrsocialauth.adapter.BadgrSocialAccountAdapter" AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - 'OPTIONS': { - 'min_length': 8, - } + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": { + "min_length": 8, + }, + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "mainsite.validators.ComplexityPasswordValidator", }, ] @@ -214,13 +215,12 @@ # ## -# CORS_ORIGIN_ALLOW_ALL = True -CORS_URLS_REGEX = r'^.*$' -BADGR_CORS_MODEL = 'mainsite.BadgrApp' +# Needed for authentication +CORS_ALLOW_CREDENTIALS = True -CORS_EXPOSE_HEADERS = ( - 'link', -) +CORS_EXPOSE_HEADERS = ("link",) + +CORS_ALLOW_HEADERS = [*default_headers, "x-altcha-spam-filter", "x-oeb-altcha"] ## # @@ -228,9 +228,9 @@ # ## -MEDIA_ROOT = os.path.join(TOP_DIR, 'mediafiles') -MEDIA_URL = '/media/' -ADMIN_MEDIA_PREFIX = STATIC_URL+'admin/' +MEDIA_ROOT = os.path.join(TOP_DIR, "mediafiles") +MEDIA_URL = "/media/" +ADMIN_MEDIA_PREFIX = STATIC_URL + "admin/" ## @@ -240,7 +240,7 @@ ## FIXTURE_DIRS = [ - os.path.join(TOP_DIR, 'etc', 'fixtures'), + os.path.join(TOP_DIR, "etc", "fixtures"), ] @@ -251,50 +251,32 @@ ## LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': [], - 'class': 'django.utils.log.AdminEmailHandler' - }, - - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'stream': sys.stdout, - + "version": 1, + "disable_existing_loggers": False, + "handlers": { + # Only logs to the console appear in the docker / grafana logs + "console": { + "level": "INFO", + "formatter": "default", + "class": "logging.StreamHandler", }, }, - 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, - }, - - # Badgr.Events emits all badge related activity - 'Badgr.Events': { - 'handlers': ['console'], - 'level': 'INFO', - 'propagate': False, - } - + "root": { + "handlers": ["console"], + "level": "INFO", }, - 'formatters': { - 'default': { - 'format': '%(asctime)s %(levelname)s %(module)s %(message)s' + "loggers": { + # Badgr.Events emits all badge related activity + "Badgr.Events": { + "handlers": ["console"], + "level": "DEBUG", }, - 'json': { - '()': 'mainsite.formatters.JsonFormatter', - 'format': '%(asctime)s', - 'datefmt': '%Y-%m-%dT%H:%M:%S%z', - } + }, + "formatters": { + "default": {"format": "%(asctime)s %(levelname)s %(module)s %(message)s"} }, } - ## # # Caching @@ -302,11 +284,11 @@ ## CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'KEY_PREFIX': 'badgr_', - 'VERSION': 10, - 'TIMEOUT': None, + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "KEY_PREFIX": "badgr_", + "VERSION": 10, + "TIMEOUT": None, } } @@ -317,7 +299,7 @@ ## MAINTENANCE_MODE = False -MAINTENANCE_URL = '/maintenance' +MAINTENANCE_URL = "/maintenance" ## @@ -332,7 +314,7 @@ # # Testing ## -TEST_RUNNER = 'mainsite.testrunner.BadgrRunner' +TEST_RUNNER = "mainsite.testrunner.BadgrRunner" ## @@ -344,25 +326,35 @@ REST_FRAMEWORK = { # Use Django's standard `django.contrib.auth` permissions, # or allow read-only access for unauthenticated users. - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly" ], - 'DEFAULT_RENDERER_CLASSES': ( - 'mainsite.renderers.JSONLDRenderer', - 'rest_framework.renderers.JSONRenderer', - 'rest_framework.renderers.BrowsableAPIRenderer', + "DEFAULT_RENDERER_CLASSES": ( + "mainsite.renderers.JSONLDRenderer", + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", ), - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'mainsite.authentication.BadgrOAuth2Authentication', - 'mainsite.authentication.LoggedLegacyTokenAuthentication', - 'entity.authentication.ExplicitCSRFSessionAuthentication', + "DEFAULT_AUTHENTICATION_CLASSES": ( + "mainsite.authentication.BadgrOAuth2Authentication", + "mainsite.authentication.LoggedLegacyTokenAuthentication", + "entity.authentication.ExplicitCSRFSessionAuthentication", + "mozilla_django_oidc.contrib.drf.OIDCAuthentication", ), - 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning', - 'DEFAULT_VERSION': 'v1', - 'ALLOWED_VERSIONS': ['v1', 'v2', 'bcv1', 'rfc7591'], - 'EXCEPTION_HANDLER': 'entity.views.exception_handler', - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 100, + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", + "DEFAULT_VERSION": "v1", + "ALLOWED_VERSIONS": ["v1", "v2", "v3", "bcv1", "rfc7591"], + "EXCEPTION_HANDLER": "entity.views.exception_handler", + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 100, +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Badgr API", + "DESCRIPTION": "Badgr API documentation", + "VERSION": "3.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, } @@ -372,8 +364,8 @@ # ## -REMOTE_DOCUMENT_FETCHER = 'badgeanalysis.utils.get_document_direct' -LINKED_DATA_DOCUMENT_FETCHER = 'badgeanalysis.utils.custom_docloader' +REMOTE_DOCUMENT_FETCHER = "badgeanalysis.utils.get_document_direct" +LINKED_DATA_DOCUMENT_FETCHER = "badgeanalysis.utils.custom_docloader" ## @@ -393,6 +385,24 @@ USE_TZ = True +## +# +# Deployment timestamp +# +## +try: + file = open("timestamp", "r") + mainsite.__timestamp__ = file.read() + print("Deployment timestamp:") + print(mainsite.__timestamp__) +except Exception as e: + print(e) + mainsite.__timestamp__ = datetime.now(pytz.timezone("Europe/Berlin")).strftime( + "%d.%m.%y %T (last restart)" + ) + print("ERROR in determining deployment timestamp; used current timestamp:") + print(mainsite.__timestamp__) + ## # # Markdownify @@ -400,105 +410,166 @@ ## MARKDOWNIFY_WHITELIST_TAGS = [ - 'h1','h2','h3','h4','h5','h6', - 'a', - 'abbr', - 'acronym', - 'b', - 'blockquote', - 'em', - 'i', - 'li', - 'ol', - 'p', - 'strong', - 'ul', - 'code', - 'pre', - 'hr' + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "a", + "abbr", + "acronym", + "b", + "blockquote", + "em", + "i", + "li", + "ol", + "p", + "strong", + "ul", + "code", + "pre", + "hr", ] OAUTH2_PROVIDER = { - 'SCOPES': { - 'r:profile': 'See who you are', - 'rw:profile': 'Update your own user profile', - 'r:backpack': 'List assertions in your backpack', - 'rw:backpack': 'Upload badges into a backpack', - 'rw:issuer': 'Create and update issuers, create and update badge classes, and award assertions', - + "SCOPES": { + "r:profile": "See who you are", + "rw:profile": "Update your own user profile", + "r:backpack": "List assertions in your backpack", + "rw:backpack": "Upload badges into a backpack", + "rw:issuer": "Create and update issuers, create and update badge classes, and award assertions", # private scopes used for integrations - 'rw:issuer:*': 'Create and update badge classes, and award assertions for a single issuer', - 'rw:serverAdmin': 'Superuser trusted operations on most objects', - 'r:assertions': 'Batch receive assertions', - + "rw:issuer:*": "Create and update badge classes, and award assertions for a single issuer", + "rw:serverAdmin": "Superuser trusted operations on most objects", + "r:assertions": "Batch receive assertions", # Badge Connect API Scopes - 'https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly': 'List assertions in a User\'s Backpack', - 'https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create': 'Add badges into a User\'s Backpack', - 'https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly': 'See who you are', + "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly": "List assertions in a User's Backpack", + "https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create": "Add badges into a User's Backpack", + "https://purl.imsglobal.org/spec/ob/v2p1/scope/profile.readonly": "See who you are", }, - 'DEFAULT_SCOPES': ['r:profile'], - - 'OAUTH2_VALIDATOR_CLASS': 'mainsite.oauth_validator.BadgrRequestValidator', - 'ACCESS_TOKEN_EXPIRE_SECONDS': 86400 - + "DEFAULT_SCOPES": ["r:profile"], + "OAUTH2_VALIDATOR_CLASS": "mainsite.oauth_validator.BadgrRequestValidator", + "ACCESS_TOKEN_EXPIRE_SECONDS": 86400, # 1 day + "REFRESH_TOKEN_EXPIRE_SECONDS": 604800, # 1 week } -OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' -OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' +OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" -OAUTH2_TOKEN_SESSION_TIMEOUT_SECONDS = OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] +OAUTH2_TOKEN_SESSION_TIMEOUT_SECONDS = OAUTH2_PROVIDER["ACCESS_TOKEN_EXPIRE_SECONDS"] -API_DOCS_EXCLUDED_SCOPES = ['rw:issuer:*', 'r:assertions', 'rw:serverAdmin', '*'] +API_DOCS_EXCLUDED_SCOPES = ["rw:issuer:*", "r:assertions", "rw:serverAdmin", "*"] BADGR_PUBLIC_BOT_USERAGENTS = [ - 'LinkedInBot', # 'LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/3.1 +http://www.linkedin.com)' - 'Twitterbot', # 'Twitterbot/1.0' - 'facebook', # https://developers.facebook.com/docs/sharing/webmasters/crawler - 'Facebot', - 'Slackbot', - 'Embedly', + # 'LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/3.1 +http://www.linkedin.com)' + "LinkedInBot", + "Twitterbot", # 'Twitterbot/1.0' + "facebook", # https://developers.facebook.com/docs/sharing/webmasters/crawler + "Facebot", + "Slackbot", + "Embedly", ] BADGR_PUBLIC_BOT_USERAGENTS_WIDE = [ - 'LinkedInBot', - 'Twitterbot', - 'facebook', - 'Facebot', + "LinkedInBot", + "Twitterbot", + "facebook", + "Facebot", ] # default celery to always_eager CELERY_ALWAYS_EAGER = True -# If enabled, notify badgerank about new badgeclasses -BADGERANK_NOTIFY_ON_BADGECLASS_CREATE = True -BADGERANK_NOTIFY_ON_FIRST_ASSERTION = True -BADGERANK_NOTIFY_URL = 'https://api.badgerank.org/v1/badgeclass/submit' - # Feature options -GDPR_COMPLIANCE_NOTIFY_ON_FIRST_AWARD = True # Notify recipients of first award on server even if issuer didn't opt to. +GDPR_COMPLIANCE_NOTIFY_ON_FIRST_AWARD = ( + True # Notify recipients of first award on server even if issuer didn't opt to. +) BADGR_APPROVED_ISSUERS_ONLY = False # Email footer operator information -PRIVACY_POLICY_URL = None -TERMS_OF_SERVICE_URL = None GDPR_INFO_URL = None OPERATOR_STREET_ADDRESS = None OPERATOR_NAME = None OPERATOR_URL = None # OVERRIDE THESE VALUES WITH YOUR OWN STABLE VALUES IN LOCAL SETTINGS -from cryptography.fernet import Fernet AUTHCODE_SECRET_KEY = Fernet.generate_key() -AUTHCODE_EXPIRES_SECONDS = 600 # needs to be long enough to fetch information from socialauth providers +AUTHCODE_EXPIRES_SECONDS = ( + 600 # needs to be long enough to fetch information from socialauth providers +) # SAML Settings -SAML_EMAIL_KEYS = ['Email', 'email', 'mail', 'emailaddress', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] -SAML_FIRST_NAME_KEYS = ['FirstName', 'givenName', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'] -SAML_LAST_NAME_KEYS = ['LastName', 'sn', 'surname', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'] +SAML_EMAIL_KEYS = [ + "Email", + "email", + "mail", + "emailaddress", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", +] +SAML_FIRST_NAME_KEYS = [ + "FirstName", + "givenName", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", +] +SAML_LAST_NAME_KEYS = [ + "LastName", + "sn", + "surname", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", +] # SVG to PNG Image Preview Generation Settings # You may use an HTTP service to convert SVG images to PNG for higher reliability than the built-in Python option. SVG_HTTP_CONVERSION_ENABLED = False -SVG_HTTP_CONVERSION_ENDPOINT = '' # Include scheme, e.g. 'http://example.com/convert-to-png' +SVG_HTTP_CONVERSION_ENDPOINT = ( + "" # Include scheme, e.g. 'http://example.com/convert-to-png' +) + +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +# OIDC Global settings +OIDC_RP_CLIENT_ID = "" +OIDC_RP_CLIENT_SECRET = "" +OIDC_OP_AUTHORIZATION_ENDPOINT = "" +OIDC_OP_TOKEN_ENDPOINT = "" +OIDC_OP_USER_ENDPOINT = "" +OIDC_OP_JWKS_ENDPOINT = "" +OIDC_OP_END_SESSION_ENDPOINT = "" +# The document specifies nbp-enmeshed-address to also be an option, but at least in the demo it doesn't work +# OIDC_RP_SCOPES = 'openid nbp-enmeshed-address' +OIDC_RP_SCOPES = "openid" +OIDC_RP_SIGN_ALGO = "RS256" +OIDC_USERNAME_ALGO = "badgeuser.utils.generate_badgr_username" +OIDC_USE_PKCE = True +# We store the access and refresh tokens because we use them +# for authentication +OIDC_STORE_ACCESS_TOKEN = True +OIDC_STORE_REFRESH_TOKEN = True +# TODO: Maybe we want to store the ID token and use it to prevent +# prompts in the logout sequence +OIDC_STORE_ID_TOKEN = False + +# Make the Django session expire after 1 minute, so that the UI has 1 minute to convert the session authentication +# into an access token +SESSION_COOKIE_AGE = 60 + +ALTCHA_SECRET = "" +ALTCHA_MINNUMBER = 10000 +ALTCHA_MAXNUMBER = 100000 + +# CMS contents +CMS_API_BASE_URL = "" +CMS_API_BASE_PATH = "" +CMS_API_KEY = "" + +# path to webcomponents assets build in badgr-ui +WEBCOMPONENTS_ASSETS_PATH = "/" + +MJML_BACKEND_MODE = "cmd" +# make sure to not load any fonts automatically +MJML_EXEC_CMD = ["mjml", "--config.fonts", "{}"] +# MJML_CHECK_CMD_ON_STARTUP = False diff --git a/apps/mainsite/settings_local.py.example b/apps/mainsite/settings_local.py.example index a18b3283a..55785716c 100644 --- a/apps/mainsite/settings_local.py.example +++ b/apps/mainsite/settings_local.py.example @@ -29,7 +29,8 @@ DATABASES = { 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. 'PORT': '', # Set to empty string for default. Not used with sqlite3. 'OPTIONS': { -# "SET character_set_connection=utf8mb3, collation_connection=utf8_unicode_ci", # Uncomment when using MySQL to ensure consistency across servers + 'charset': 'utf8mb4', + 'init_command': "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_general_ci'" }, } } @@ -89,23 +90,6 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # AWS_SES_REGION_NAME = 'us-east-1' # AWS_SES_REGION_ENDPOINT = 'email.us-east-1.amazonaws.com' - -### -# -# Static Files Configuration -# -### - -# Default localhost configuration is in mainsite/settings.py - -# Example: settings for an Azure-based cloud storage backend -# DEFAULT_FILE_STORAGE = 'storages.backends.azure_storage.AzureStorage' -# AZURE_ACCOUNT_NAME = 'azureaccountname' -# AZURE_ACCOUNT_KEY = '' -# MEDIA_URL = 'http://azureaccountname.blob.core.windows.net/' -# AZURE_CONTAINER = 'mediafiles' - - ### # # Celery Asynchronous Task Processing (Optional) @@ -118,8 +102,12 @@ CELERY_RESULT_BACKEND = None # CELERY_RESULTS_SERIALIZER = 'json' # CELERY_ACCEPT_CONTENT = ['json'] -# Run celery tasks in same thread as webserver (True means that asynchronous processing is OFF) -CELERY_ALWAYS_EAGER = True +# Using dedicated Celery workers for asynchronous tasks (required e.g. for asynchronous batch badge-awarding) +# Set to True to run celery tasks in same thread as webserver (True means that asynchronous processing is OFF) +CELERY_ALWAYS_EAGER = False + +CELERY_BROKER_URL = 'redis://redis:6379/0' +CELERY_RESULT_BACKEND = 'redis://redis:6379/0' ### @@ -137,7 +125,7 @@ BADGR_APPROVED_ISSUERS_ONLY = False GDPR_COMPLIANCE_NOTIFY_ON_FIRST_AWARD = True # For the browsable API documentation at '/docs' -# For local development environment: When you have a user you'd like to make API requests, +# For local development environment: When you have a user you'd like to make API requests, # as you can force the '/docs' endpoint to use particular credentials. # Get a token for your user at '/v1/user/auth-token' # SWAGGER_SETTINGS = { @@ -162,13 +150,14 @@ ALLOWED_HOSTS = ['', ] SECRET_KEY = 'QKQ9NKGJLXE8UVS3TXIB0DE7Q9W41J578C5FCRJL' # ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(40)) UNSUBSCRIBE_KEY = '8GGGDKOT4H4O7QU4GPGZ7ERY9GPE2FKALAO81WYP' # ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(40)) +CSRF_COOKIE_DOMAIN = 'localhost' +CSRF_TRUSTED_ORIGINS = ['localhost'] ### # # Logging # ### - LOGS_DIR = os.path.join(TOP_DIR, 'logs') if not os.path.exists(LOGS_DIR): os.makedirs(LOGS_DIR) @@ -176,45 +165,39 @@ LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': [], - 'class': 'django.utils.log.AdminEmailHandler' - }, - - # badgr events log to disk by default - 'badgr_events': { + # Only logs to the console appear in the docker / grafana logs + 'console': { 'level': 'INFO', - 'formatter': 'json', - 'class': 'logging.FileHandler', - 'filename': os.path.join(LOGS_DIR, 'badgr_events.log') - } + 'formatter': 'default', + 'class': 'logging.StreamHandler' + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", }, 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, - }, - # Badgr.Events emits all badge related activity 'Badgr.Events': { - 'handlers': ['badgr_events'], - 'level': 'INFO', - 'propagate': False, - - } - + 'handlers': ['console'], + 'level': 'DEBUG', + }, }, 'formatters': { 'default': { 'format': '%(asctime)s %(levelname)s %(module)s %(message)s' - }, - 'json': { - '()': 'mainsite.formatters.JsonFormatter', - 'format': '%(asctime)s', - 'datefmt': '%Y-%m-%dT%H:%M:%S%z', } }, } + +NOUNPROJECT_API_KEY = '' +NOUNPROJECT_SECRET = '' + +AISKILLS_API_KEY = '' +AISKILLS_ENDPOINT_CHATS = '' +AISKILLS_ENDPOINT_KEYWORDS = '' + +ALTCHA_API_KEY = '' +ALTCHA_SECRET = '' +ALTCHA_SPAMFILTER_ENDPOINT = "https://eu.altcha.org/api/v1/classify?" diff --git a/apps/mainsite/settings_tests.py b/apps/mainsite/settings_tests.py index afa8c6f80..a27b0bce7 100644 --- a/apps/mainsite/settings_tests.py +++ b/apps/mainsite/settings_tests.py @@ -1,24 +1,26 @@ # encoding: utf-8 - - from cryptography.fernet import Fernet -from .settings import * +from .settings import * # noqa: F403, F401 # disable logging for tests LOGGING = {} DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'badgr_server', - 'OPTIONS': { + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": "badgr", + "USER": "root", + "PASSWORD": "password", + "HOST": "db", + "PORT": "", + "OPTIONS": { "init_command": "SET default_storage_engine=InnoDB", }, } } CELERY_ALWAYS_EAGER = True -SECRET_KEY = 'aninsecurekeyusedfortesting' +SECRET_KEY = "aninsecurekeyusedfortesting" UNSUBSCRIBE_SECRET_KEY = str(SECRET_KEY) AUTHCODE_SECRET_KEY = Fernet.generate_key() diff --git a/apps/mainsite/settings_testserver.py b/apps/mainsite/settings_testserver.py index 57400b927..4f62676fd 100644 --- a/apps/mainsite/settings_testserver.py +++ b/apps/mainsite/settings_testserver.py @@ -1,34 +1,38 @@ -from mainsite.settings import * +# It doesn't seem as if this file is still being used. +# TODO: Remove if true +from mainsite.settings import * # noqa: F403 DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': 'badgr', - 'OPTIONS': { - # "init_command": "SET storage_engine=InnoDB", # Uncomment when using MySQL to ensure consistency across servers + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": "badgr", + "OPTIONS": { + # Uncomment when using MySQL to ensure consistency across servers + # "init_command": "SET storage_engine=InnoDB", }, } } CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', - 'LOCATION': '127.0.0.1:11211', - 'KEY_PREFIX': 'test_badgr_', - 'VERSION': 1, + "default": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "127.0.0.1:11211", + "KEY_PREFIX": "test_badgr_", + "VERSION": 1, } } # django test speedups -PASSWORD_HASHERS = ( - 'django.contrib.auth.hashers.MD5PasswordHasher', -) +PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",) DEBUG = False -logging.disable(logging.CRITICAL) +try: + logging.disable(logging.CRITICAL) # noqa: F405 +except NameError: + print("logging undefined!") # EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" CELERY_ALWAYS_EAGER = True CELERY_EAGER_PROPAGATES_EXCEPTIONS = True -BROKER_BACKEND = 'memory' +BROKER_BACKEND = "memory" diff --git a/apps/mainsite/signals.py b/apps/mainsite/signals.py index aff743768..626677245 100644 --- a/apps/mainsite/signals.py +++ b/apps/mainsite/signals.py @@ -1,19 +1,6 @@ -from urllib.parse import urlparse - -from django.apps import apps -from django.conf import settings - from mainsite.models import AccessTokenScope -from mainsite.utils import netloc_to_domain - -CorsModel = apps.get_model(getattr(settings, 'BADGR_CORS_MODEL')) def handle_token_save(sender, instance=None, **kwargs): for s in instance.scope.split(): AccessTokenScope.objects.get_or_create(token=instance, scope=s) - - -def cors_allowed_sites(sender, request, **kwargs): - origin = netloc_to_domain(urlparse(request.META['HTTP_ORIGIN']).netloc) - return CorsModel.objects.filter(cors=origin).exists() diff --git a/apps/mainsite/static/extensions/BasedOnExtension/context.json b/apps/mainsite/static/extensions/BasedOnExtension/context.json index f80fb9fe2..424ff7d00 100644 --- a/apps/mainsite/static/extensions/BasedOnExtension/context.json +++ b/apps/mainsite/static/extensions/BasedOnExtension/context.json @@ -7,7 +7,7 @@ "obi:validation": [ { "obi:validatesType": "extensions:BasedOnExtension", - "obi:validationSchema": "http://localhost:8000/static/extensions/BasedOnExtension/schema.json" + "obi:validationSchema": "https://api.openbadges.education/static/extensions/BasedOnExtension/schema.json" } ] } diff --git a/apps/mainsite/static/extensions/CategoryExtension/context.json b/apps/mainsite/static/extensions/CategoryExtension/context.json index 12a13010c..6d546b907 100644 --- a/apps/mainsite/static/extensions/CategoryExtension/context.json +++ b/apps/mainsite/static/extensions/CategoryExtension/context.json @@ -7,7 +7,7 @@ "obi:validation": [ { "obi:validatesType": "extensions:CategoryExtension", - "obi:validationSchema": "http://localhost:8000/static/extensions/CategoryExtension/schema.json" + "obi:validationSchema": "https://api.openbadges.education/static/extensions/CategoryExtension/schema.json" } ] } diff --git a/apps/mainsite/static/extensions/CompetencyExtension/context.json b/apps/mainsite/static/extensions/CompetencyExtension/context.json new file mode 100644 index 000000000..6ec0e2e35 --- /dev/null +++ b/apps/mainsite/static/extensions/CompetencyExtension/context.json @@ -0,0 +1,19 @@ +{ + "@context": { + "obi": "https://w3id.org/openbadges#", + "extensions": "https://w3id.org/openbadges/extensions#", + "category": "extensions:category", + "source": "extensions:source", + "framework": "extensions:framework", + "framework_identifier": "extensions:framework_identifier", + "studyLoad": "extensions:studyLoad", + "name": "extensions:name", + "description": "extensions:description" + }, + "obi:validation": [ + { + "obi:validatesType": "extensions:CompetencyExtension", + "obi:validationSchema": "https://api.openbadges.education/static/extensions/CompetencyExtension/schema.json" + } + ] +} diff --git a/apps/mainsite/static/extensions/CompetencyExtension/schema.json b/apps/mainsite/static/extensions/CompetencyExtension/schema.json new file mode 100644 index 000000000..564f9a641 --- /dev/null +++ b/apps/mainsite/static/extensions/CompetencyExtension/schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Competency", + "description": "This extension provides a reference to an array of competencies that a competency badge contains.", + "type": "object", + "definitions": { + "Category": { + "description": "Category of competency can either be skill or knowledge", + "type": "string", + "enum": ["skill", "knowledge"] + } + }, + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "type": "string" + }, + "framework": { + "type": "string" + }, + "framework_identifier": { + "type": "string" + }, + "studyLoad": { + "type": "number" + }, + "category": { + "$ref": "#/definitions/Category" + } + }, + "required": ["name", "description", "studyLoad", "category"] +} diff --git a/apps/mainsite/static/extensions/LevelExtension/context.json b/apps/mainsite/static/extensions/LevelExtension/context.json index eddb5a2c0..1b62f9b10 100644 --- a/apps/mainsite/static/extensions/LevelExtension/context.json +++ b/apps/mainsite/static/extensions/LevelExtension/context.json @@ -7,7 +7,7 @@ "obi:validation": [ { "obi:validatesType": "extensions:LevelExtension", - "obi:validationSchema": "http://localhost:8000/static/extensions/LevelExtension/schema.json" + "obi:validationSchema": "https://api.openbadges.education/static/extensions/LevelExtension/schema.json" } ] } diff --git a/apps/mainsite/static/extensions/LicenseExtension/context.json b/apps/mainsite/static/extensions/LicenseExtension/context.json new file mode 100644 index 000000000..808a3c766 --- /dev/null +++ b/apps/mainsite/static/extensions/LicenseExtension/context.json @@ -0,0 +1,22 @@ +{ + "@context": { + "obi": "https://w3id.org/openbadges#", + "extensions": "https://w3id.org/openbadges/extensions#", + "cc": "http://creativecommons.org/ns#", + "legalCode": "cc:legalcode", + "License": "cc:License", + "CC-0": "https://creativecommons.org/publicdomain/zero/1.0/", + "CC-BY": "http://creativecommons.org/licenses/by/4.0/", + "CC-BY-SA": "http://creativecommons.org/licenses/by-sa/4.0/", + "CC-BY-NC": "http://creativecommons.org/licenses/by-nc/4.0/", + "CC-BY-NC-SA": "http://creativecommons.org/licenses/by-nc-sa/4.0/", + "CC-BY-ND": "http://creativecommons.org/licenses/by-nd/4.0/", + "CC-BY-NC-ND": "https://creativecommons.org/licenses/by-nc-nd/4.0/" + }, + "obi:validation": [ + { + "obi:validatesType": "extensions:LicenseExtension", + "obi:validationSchema": "https://api.openbadges.education/static/extensions/LicenseExtension/schema.json" + } + ] +} diff --git a/apps/mainsite/static/extensions/LicenseExtension/schema.json b/apps/mainsite/static/extensions/LicenseExtension/schema.json new file mode 100644 index 000000000..995b0baa4 --- /dev/null +++ b/apps/mainsite/static/extensions/LicenseExtension/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "Open Badges License Extension", + "description": "An extension that allows issuers to describe the license of an Open Badges object.", + "type": "object", + "properties": { + "id": { + "description": "The canonical URI of the selected Creative Commons license. For convenience, aliases like 'CC-BY' are provided in the context.", + "type": "string", + "pattern": "^CC\\-" + }, + "name": { + "type": "string" + }, + "legalCode": { + "type": "string", + "format": "url" + } + }, + "required": ["id", "name"] +} diff --git a/apps/mainsite/static/extensions/OrgImageExtension/context.json b/apps/mainsite/static/extensions/OrgImageExtension/context.json index 471d1f16b..e08bdf547 100644 --- a/apps/mainsite/static/extensions/OrgImageExtension/context.json +++ b/apps/mainsite/static/extensions/OrgImageExtension/context.json @@ -7,7 +7,7 @@ "obi:validation": [ { "obi:validatesType": "extensions:OrgImageExtension", - "obi:validationSchema": "http://localhost:8000/static/extensions/OrgImageExtension/schema.json" + "obi:validationSchema": "https://api.openbadges.education/static/extensions/OrgImageExtension/schema.json" } ] } diff --git a/apps/mainsite/static/extensions/StudyLoadExtension/context.json b/apps/mainsite/static/extensions/StudyLoadExtension/context.json index c53f6d66c..0c4366bb0 100644 --- a/apps/mainsite/static/extensions/StudyLoadExtension/context.json +++ b/apps/mainsite/static/extensions/StudyLoadExtension/context.json @@ -7,7 +7,7 @@ "obi:validation": [ { "obi:validatesType": "extensions:StudyLoadExtension", - "obi:validationSchema": "http://localhost:8000/static/extensions/StudyLoadExtension/schema.json" + "obi:validationSchema": "https://api.openbadges.education/static/extensions/StudyLoadExtension/schema.json" } ] } diff --git a/apps/mainsite/static/extensions/recipientProfile/context.json b/apps/mainsite/static/extensions/recipientProfile/context.json new file mode 100644 index 000000000..a8c8f27f6 --- /dev/null +++ b/apps/mainsite/static/extensions/recipientProfile/context.json @@ -0,0 +1,12 @@ +{ + "@context": { + "obi": "https://w3id.org/openbadges#", + "extensions": "https://w3id.org/openbadges/extensions#" + }, + "obi:validation": [ + { + "obi:validatesType": "extensions:RecipientProfile", + "obi:validationSchema": "https://api.openbadges.education/static/extensions/recipientProfile/schema.json" + } + ] +} diff --git a/apps/mainsite/static/extensions/recipientProfile/schema.json b/apps/mainsite/static/extensions/recipientProfile/schema.json new file mode 100644 index 000000000..bfd036ed6 --- /dev/null +++ b/apps/mainsite/static/extensions/recipientProfile/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "RecipientProfile schema", + "description": "A Blockcerts extension allowing inclusion of additional recipient details, including recipient publicKey and name. Inclusion of the recipient publicKey allows the recipient to make a strong claim of ownership over the key, and hence the badge being awarded. In the future, publicKey will be deprecated in favor of a decentralized id (DID) in the `id` field.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "reserved for future use as DID" + }, + "name": { + "type": "string", + "description": "Name of recipient, http://schema.org/name" + }, + "publicKey": { + "type": "string", + "format": "uri", + "description": "In Blockcerts `publicKey` IRIs are typically represented with a `:` prefix. For Bitcoin transactions, this would be the recipient public Bitcoin address prefixed with `ecdsa-koblitz-pubkey:`; e.g. `ecdsa-koblitz-pubkey:14RZvYazz9H2DC2skBfpPVxax54g4yabxe`" + } + } +} diff --git a/apps/mainsite/static/fonts/Rubik-Bold.ttf b/apps/mainsite/static/fonts/Rubik-Bold.ttf new file mode 100644 index 000000000..1a9693d97 Binary files /dev/null and b/apps/mainsite/static/fonts/Rubik-Bold.ttf differ diff --git a/apps/mainsite/static/fonts/Rubik-Italic.ttf b/apps/mainsite/static/fonts/Rubik-Italic.ttf new file mode 100644 index 000000000..31c89f6f4 Binary files /dev/null and b/apps/mainsite/static/fonts/Rubik-Italic.ttf differ diff --git a/apps/mainsite/static/fonts/Rubik-Medium.ttf b/apps/mainsite/static/fonts/Rubik-Medium.ttf new file mode 100644 index 000000000..f0bd59588 Binary files /dev/null and b/apps/mainsite/static/fonts/Rubik-Medium.ttf differ diff --git a/apps/mainsite/static/fonts/Rubik-Regular.ttf b/apps/mainsite/static/fonts/Rubik-Regular.ttf new file mode 100644 index 000000000..8b7b632f9 Binary files /dev/null and b/apps/mainsite/static/fonts/Rubik-Regular.ttf differ diff --git a/apps/mainsite/static/fonts/Rubik-SemiBold.ttf b/apps/mainsite/static/fonts/Rubik-SemiBold.ttf new file mode 100644 index 000000000..8a95c22a1 Binary files /dev/null and b/apps/mainsite/static/fonts/Rubik-SemiBold.ttf differ diff --git a/apps/mainsite/static/fonts/Rubik.css b/apps/mainsite/static/fonts/Rubik.css new file mode 100644 index 000000000..3e80743b6 --- /dev/null +++ b/apps/mainsite/static/fonts/Rubik.css @@ -0,0 +1,27 @@ +@font-face { + font-family: "Rubik"; + src: url("Rubik-Regular.ttf") format("truetype"); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: "Rubik"; + src: url("Rubik-Bold.ttf") format("truetype"); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: "Rubik"; + src: url("Rubik-Italic.ttf") format("truetype"); + font-weight: normal; + font-style: italic; +} + +@font-face { + font-family: "Rubik"; + src: url("Rubik-Medium.ttf") format("truetype"); + font-weight: 500; + font-style: normal; +} diff --git a/apps/mainsite/static/images/Logo-Oeb.png b/apps/mainsite/static/images/Logo-Oeb.png new file mode 100644 index 000000000..c90769da2 Binary files /dev/null and b/apps/mainsite/static/images/Logo-Oeb.png differ diff --git a/apps/mainsite/static/images/arrow-qrcode-download.png b/apps/mainsite/static/images/arrow-qrcode-download.png new file mode 100644 index 000000000..ea336cbf6 Binary files /dev/null and b/apps/mainsite/static/images/arrow-qrcode-download.png differ diff --git a/apps/mainsite/static/images/badgeCreateThumbnail.png b/apps/mainsite/static/images/badgeCreateThumbnail.png new file mode 100644 index 000000000..809e6e606 Binary files /dev/null and b/apps/mainsite/static/images/badgeCreateThumbnail.png differ diff --git a/apps/mainsite/static/images/clock-icon.png b/apps/mainsite/static/images/clock-icon.png new file mode 100644 index 000000000..be431b9e6 Binary files /dev/null and b/apps/mainsite/static/images/clock-icon.png differ diff --git a/apps/mainsite/static/images/clock_icon.svg b/apps/mainsite/static/images/clock_icon.svg new file mode 100644 index 000000000..6b6e5dcd7 --- /dev/null +++ b/apps/mainsite/static/images/clock_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/mainsite/static/images/competency.svg b/apps/mainsite/static/images/competency.svg new file mode 100644 index 000000000..a21f0b0c1 --- /dev/null +++ b/apps/mainsite/static/images/competency.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mainsite/static/images/external_link.png b/apps/mainsite/static/images/external_link.png new file mode 100644 index 000000000..ab38a93b2 Binary files /dev/null and b/apps/mainsite/static/images/external_link.png differ diff --git a/apps/mainsite/static/images/learningpath.svg b/apps/mainsite/static/images/learningpath.svg new file mode 100644 index 000000000..5bd421ca3 --- /dev/null +++ b/apps/mainsite/static/images/learningpath.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/mainsite/static/images/logo-mbr.png b/apps/mainsite/static/images/logo-mbr.png new file mode 100644 index 000000000..64b389fab Binary files /dev/null and b/apps/mainsite/static/images/logo-mbr.png differ diff --git a/apps/mainsite/static/images/logo-square.png b/apps/mainsite/static/images/logo-square.png new file mode 100644 index 000000000..e151057d6 Binary files /dev/null and b/apps/mainsite/static/images/logo-square.png differ diff --git a/apps/mainsite/static/images/participation.svg b/apps/mainsite/static/images/participation.svg new file mode 100644 index 000000000..7764728e4 --- /dev/null +++ b/apps/mainsite/static/images/participation.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mainsite/static/images/rectangle.svg b/apps/mainsite/static/images/rectangle.svg new file mode 100644 index 000000000..3f4d8a0d3 --- /dev/null +++ b/apps/mainsite/static/images/rectangle.svg @@ -0,0 +1,57 @@ + + diff --git a/apps/mainsite/static/images/square.svg b/apps/mainsite/static/images/square.svg new file mode 100644 index 000000000..df01f6bcd --- /dev/null +++ b/apps/mainsite/static/images/square.svg @@ -0,0 +1,46 @@ + + + + + + diff --git a/apps/mainsite/static/images/sun.svg b/apps/mainsite/static/images/sun.svg new file mode 100644 index 000000000..b0748db9f --- /dev/null +++ b/apps/mainsite/static/images/sun.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mainsite/static/swagger-ui/API_DESCRIPTION_bcv1.md b/apps/mainsite/static/swagger-ui/API_DESCRIPTION_bcv1.md index 39d9584fc..6991ea891 100644 --- a/apps/mainsite/static/swagger-ui/API_DESCRIPTION_bcv1.md +++ b/apps/mainsite/static/swagger-ui/API_DESCRIPTION_bcv1.md @@ -3,26 +3,56 @@ Authenticate requests by including an Authorization header of type "Bearer". For example: ```bash -curl 'https://api.badgr.io/v2/users/self' -H "Authorization: Bearer YOURACCESSTOKEN" +curl 'https://api.badgr.io/v1/user/profile' -H "Authorization: Bearer YOURACCESSTOKEN" +``` + +Alternatively you can also pass the token as cookie, to utilize `HttpOnly` cookies. +To do this, set `withCredentials: true` in your request. +The cookie in your request would then look something like this: +```yaml +Cookie: csrftoken=YOUR_CSRF_TOKEN; access_token=YOUR_ACCESS_TOKEN ``` ## Access Tokens -To retrieve an access token, POST a username/password combination to /o/token. For example: +If you want to make requests to our API, you need to obtain an **Access token**. +These are tokens with a limited time of life (24 hours by default). To request tokens, you need to make a POST request to our API. +For security reasons in our application we store the access token in an [HttpOnly](https://owasp.org/www-community/HttpOnly) cookie. +That means that the browser cannot access the content, instead it's passed with the requests as a cookie. +That also means that we don't return the access token in the data section of the response, but in the cookie section. + +If you use `cURL` for example this might look like this: ```bash -curl -X POST 'https://api.badgr.io/o/token' -d "username=YOUREMAIL&password=YOURPASSWORD" +curl --request POST \ + --url 'https://api.openbadges.education/o/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=password' \ + --data 'username=YOUR_USERNAME' \ + --data 'password=YOUR_PASSWORD' \ + --data 'client_id=public' --verbose ``` -returns a response like: -```javascript -{ - "access_token": "YOURACCESSTOKEN", - "token_type": "Bearer", - "expires_in": 86400, - "refresh_token": "YOURREFRESHTOKEN", -} +Or with client ID and secret: +```bash +curl --request POST \ + --url 'https://api.openbadges.education/o/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=client_credentials' \ + --data 'client_id=YOUR_CLIENT_ID' \ + --data 'client_secret=YOUR_CLIENT_SECRET' --verbose ``` +The response will then look something like this: +```text + +< Set-Cookie: access_token=YOUR_ACCESS_TOKEN; expires=Tue, 19 Nov 2024 13:19:00 GMT; HttpOnly; Max-Age=86400; Path=/; Secure +< +* Connection #0 to host api.openbadges.education left intact +{"expires_in": 86400, "token_type": "Bearer", "scope": "r:profile"} +``` +Once again, note that the scope doesn't actually mean anything (yet). +You can read the access token from the `Set-Cookie` value. + ## Token Expiration Access tokens will expire, if an expired token is used a 403 status code will be returned. @@ -31,4 +61,3 @@ The refresh token can be used to automatically renew an access token without req ```bash curl -X POST 'https://api.badgr.io/o/token' -d "grant_type=refresh_token&refresh_token=YOURREFRESHTOKEN" ``` - diff --git a/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v1.md b/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v1.md index fe46d1b7b..d1ef69a22 100644 --- a/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v1.md +++ b/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v1.md @@ -6,22 +6,52 @@ Authenticate requests by including an Authorization header of type "Bearer". Fo curl 'https://api.badgr.io/v1/user/profile' -H "Authorization: Bearer YOURACCESSTOKEN" ``` +Alternatively you can also pass the token as cookie, to utilize `HttpOnly` cookies. +To do this, set `withCredentials: true` in your request. +The cookie in your request would then look something like this: +```yaml +Cookie: csrftoken=YOUR_CSRF_TOKEN; access_token=YOUR_ACCESS_TOKEN +``` + ## Access Tokens -To retrieve an access token, POST a username/password combination to /o/token. For example: +If you want to make requests to our API, you need to obtain an **Access token**. +These are tokens with a limited time of life (24 hours by default). To request tokens, you need to make a POST request to our API. +For security reasons in our application we store the access token in an [HttpOnly](https://owasp.org/www-community/HttpOnly) cookie. +That means that the browser cannot access the content, instead it's passed with the requests as a cookie. +Nevertheless we also return the access token in the data section of the response, in addition to the cookie section. + +If you use `cURL` for example this might look like this: +```bash +curl --request POST \ + --url 'https://api.openbadges.education/o/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=password' \ + --data 'username=YOUR_USERNAME' \ + --data 'password=YOUR_PASSWORD' \ + --data 'client_id=public' --verbose +``` + +Or with client ID and secret: ```bash -curl -X POST 'https://api.badgr.io/o/token' -d "username=YOUREMAIL&password=YOURPASSWORD" +curl --request POST \ + --url 'https://api.openbadges.education/o/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=client_credentials' \ + --data 'client_id=YOUR_CLIENT_ID' \ + --data 'client_secret=YOUR_CLIENT_SECRET' --verbose ``` -returns a response like: -```javascript -{ - "access_token": "YOURACCESSTOKEN", - "token_type": "Bearer", - "expires_in": 86400, - "refresh_token": "YOURREFRESHTOKEN", -} +The response will then look something like this: +```text + +< Set-Cookie: access_token=YOUR_ACCESS_TOKEN; expires=Tue, 19 Nov 2024 13:19:00 GMT; HttpOnly; Max-Age=86400; Path=/; Secure +< Set-Cookie: refresh_token=YOUR_REFRESH_TOKEN; expires=Wed, 19 Nov 2024 13:19:00 GMT; HttpOnly; Max-Age=86400; Path=/ +< +* Connection #0 to host api.openbadges.education left intact +{"access_token": "YOUR_ACCESS_TOKEN", "expires_in": 86400, "token_type": "Bearer", "scope": "r:profile", "refresh_token": "YOUR_REFRESH_TOKEN"} ``` +Once again, note that the scope doesn't actually mean anything (yet). ## Token Expiration Access tokens will expire, if an expired token is used a 403 status code will be returned. diff --git a/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v2.md b/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v2.md index 39d9584fc..d1ef69a22 100644 --- a/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v2.md +++ b/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v2.md @@ -3,26 +3,56 @@ Authenticate requests by including an Authorization header of type "Bearer". For example: ```bash -curl 'https://api.badgr.io/v2/users/self' -H "Authorization: Bearer YOURACCESSTOKEN" +curl 'https://api.badgr.io/v1/user/profile' -H "Authorization: Bearer YOURACCESSTOKEN" +``` + +Alternatively you can also pass the token as cookie, to utilize `HttpOnly` cookies. +To do this, set `withCredentials: true` in your request. +The cookie in your request would then look something like this: +```yaml +Cookie: csrftoken=YOUR_CSRF_TOKEN; access_token=YOUR_ACCESS_TOKEN ``` ## Access Tokens -To retrieve an access token, POST a username/password combination to /o/token. For example: +If you want to make requests to our API, you need to obtain an **Access token**. +These are tokens with a limited time of life (24 hours by default). To request tokens, you need to make a POST request to our API. +For security reasons in our application we store the access token in an [HttpOnly](https://owasp.org/www-community/HttpOnly) cookie. +That means that the browser cannot access the content, instead it's passed with the requests as a cookie. +Nevertheless we also return the access token in the data section of the response, in addition to the cookie section. + +If you use `cURL` for example this might look like this: ```bash -curl -X POST 'https://api.badgr.io/o/token' -d "username=YOUREMAIL&password=YOURPASSWORD" +curl --request POST \ + --url 'https://api.openbadges.education/o/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=password' \ + --data 'username=YOUR_USERNAME' \ + --data 'password=YOUR_PASSWORD' \ + --data 'client_id=public' --verbose ``` -returns a response like: -```javascript -{ - "access_token": "YOURACCESSTOKEN", - "token_type": "Bearer", - "expires_in": 86400, - "refresh_token": "YOURREFRESHTOKEN", -} +Or with client ID and secret: +```bash +curl --request POST \ + --url 'https://api.openbadges.education/o/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=client_credentials' \ + --data 'client_id=YOUR_CLIENT_ID' \ + --data 'client_secret=YOUR_CLIENT_SECRET' --verbose ``` +The response will then look something like this: +```text + +< Set-Cookie: access_token=YOUR_ACCESS_TOKEN; expires=Tue, 19 Nov 2024 13:19:00 GMT; HttpOnly; Max-Age=86400; Path=/; Secure +< Set-Cookie: refresh_token=YOUR_REFRESH_TOKEN; expires=Wed, 19 Nov 2024 13:19:00 GMT; HttpOnly; Max-Age=86400; Path=/ +< +* Connection #0 to host api.openbadges.education left intact +{"access_token": "YOUR_ACCESS_TOKEN", "expires_in": 86400, "token_type": "Bearer", "scope": "r:profile", "refresh_token": "YOUR_REFRESH_TOKEN"} +``` +Once again, note that the scope doesn't actually mean anything (yet). + ## Token Expiration Access tokens will expire, if an expired token is used a 403 status code will be returned. @@ -31,4 +61,3 @@ The refresh token can be used to automatically renew an access token without req ```bash curl -X POST 'https://api.badgr.io/o/token' -d "grant_type=refresh_token&refresh_token=YOURREFRESHTOKEN" ``` - diff --git a/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v3.md b/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v3.md new file mode 100644 index 000000000..d1ef69a22 --- /dev/null +++ b/apps/mainsite/static/swagger-ui/API_DESCRIPTION_v3.md @@ -0,0 +1,63 @@ +## Authentication + +Authenticate requests by including an Authorization header of type "Bearer". For example: + +```bash +curl 'https://api.badgr.io/v1/user/profile' -H "Authorization: Bearer YOURACCESSTOKEN" +``` + +Alternatively you can also pass the token as cookie, to utilize `HttpOnly` cookies. +To do this, set `withCredentials: true` in your request. +The cookie in your request would then look something like this: +```yaml +Cookie: csrftoken=YOUR_CSRF_TOKEN; access_token=YOUR_ACCESS_TOKEN +``` + +## Access Tokens + +If you want to make requests to our API, you need to obtain an **Access token**. +These are tokens with a limited time of life (24 hours by default). To request tokens, you need to make a POST request to our API. +For security reasons in our application we store the access token in an [HttpOnly](https://owasp.org/www-community/HttpOnly) cookie. +That means that the browser cannot access the content, instead it's passed with the requests as a cookie. +Nevertheless we also return the access token in the data section of the response, in addition to the cookie section. + +If you use `cURL` for example this might look like this: +```bash +curl --request POST \ + --url 'https://api.openbadges.education/o/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=password' \ + --data 'username=YOUR_USERNAME' \ + --data 'password=YOUR_PASSWORD' \ + --data 'client_id=public' --verbose +``` + +Or with client ID and secret: +```bash +curl --request POST \ + --url 'https://api.openbadges.education/o/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=client_credentials' \ + --data 'client_id=YOUR_CLIENT_ID' \ + --data 'client_secret=YOUR_CLIENT_SECRET' --verbose +``` + +The response will then look something like this: +```text + +< Set-Cookie: access_token=YOUR_ACCESS_TOKEN; expires=Tue, 19 Nov 2024 13:19:00 GMT; HttpOnly; Max-Age=86400; Path=/; Secure +< Set-Cookie: refresh_token=YOUR_REFRESH_TOKEN; expires=Wed, 19 Nov 2024 13:19:00 GMT; HttpOnly; Max-Age=86400; Path=/ +< +* Connection #0 to host api.openbadges.education left intact +{"access_token": "YOUR_ACCESS_TOKEN", "expires_in": 86400, "token_type": "Bearer", "scope": "r:profile", "refresh_token": "YOUR_REFRESH_TOKEN"} +``` +Once again, note that the scope doesn't actually mean anything (yet). + +## Token Expiration +Access tokens will expire, if an expired token is used a 403 status code will be returned. + +The refresh token can be used to automatically renew an access token without requiring the password again. For example: + +```bash +curl -X POST 'https://api.badgr.io/o/token' -d "grant_type=refresh_token&refresh_token=YOURREFRESHTOKEN" +``` diff --git a/apps/mainsite/tasks.py b/apps/mainsite/tasks.py new file mode 100644 index 000000000..b8fcbed5e --- /dev/null +++ b/apps/mainsite/tasks.py @@ -0,0 +1,33 @@ +from celery import shared_task +from issuer.models import LearningPath +from badgeuser.models import BadgeUser + +import logging +logger = logging.getLogger("Badgr.Events") + + +@shared_task +def process_learning_path_activation(pk): + """ + Process Micro-Degree-Badge issuance for all users when a learning path is activated. + """ + + try: + learning_path = LearningPath.objects.get(pk=pk) + + for user in BadgeUser.objects.all(): + for identifier in user.all_verified_recipient_identifiers: + if learning_path.user_should_have_badge(identifier): + learning_path.participationBadge.issue( + recipient_id=identifier, + notify=True, + microdegree_id=learning_path.entity_id, + ) + + return f"Successfully processed learning path activation for {pk}" + + except LearningPath.DoesNotExist: + return f"LearningPath with pk {pk} not found" + except Exception as e: + logger.error(f"Error processing learning path activation {pk}: {str(e)}") + raise diff --git a/apps/mainsite/templates/account/email/email_badge_request_message.html b/apps/mainsite/templates/account/email/email_badge_request_message.html new file mode 100644 index 000000000..0a353f023 --- /dev/null +++ b/apps/mainsite/templates/account/email/email_badge_request_message.html @@ -0,0 +1,26 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + QR-Code-Anfragen fÃŧr deinen Badge + + + Du hast {{ number_of_open_requests }} offene QR-Code-Anfrage(n) fÃŧr deinen Badge {{ badge_name }}. + + + Bestätige die Anfragen auf Open Educational Badges Ãŧber den folgenden Button: + + + {{ call_to_action_label }} + + + +{% endblock main %} +{% block button %} +{% endblock button %} +{% endmjml %} diff --git a/apps/mainsite/templates/account/email/email_badge_request_message.txt b/apps/mainsite/templates/account/email/email_badge_request_message.txt new file mode 100644 index 000000000..004a13e3b --- /dev/null +++ b/apps/mainsite/templates/account/email/email_badge_request_message.txt @@ -0,0 +1,6 @@ +QR-Code-Anfragen fÃŧr deinen Badge + +Du hast {{ number_of_open_requests }} offene QR-Code-Anfrage(n) fÃŧr deinen Badge {{ badge_name }}. + +Bestätige die Anfragen auf Open Educational Badges Ãŧber den folgenden Button: +{{ activate_url }} \ No newline at end of file diff --git a/apps/mainsite/templates/account/email/email_badge_request_subject.txt b/apps/mainsite/templates/account/email/email_badge_request_subject.txt new file mode 100644 index 000000000..6a1fa8afc --- /dev/null +++ b/apps/mainsite/templates/account/email/email_badge_request_subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Badge-Anfragen per QR-Code{% endblocktrans %} +{% endautoescape %} diff --git a/apps/mainsite/templates/account/email/email_confirmation_message.html b/apps/mainsite/templates/account/email/email_confirmation_message.html index 3800a9564..3b8532716 100644 --- a/apps/mainsite/templates/account/email/email_confirmation_message.html +++ b/apps/mainsite/templates/account/email/email_confirmation_message.html @@ -1,63 +1,30 @@ {% extends "email/base.html" %} {% load i18n %} -{% block beforebutton %} -
    - - - - - - -
    - -
    - - - - - - -
    - - - - - - - - - -
    -
    - Bitte bestätige Deine zusätzliche E-Mail-Adresse. -
    -
    -
    - {% blocktrans with site_name=site.name site_domain=site.domain %} - Ein {{ site_name }} Benutzer hat angefordert, diese E-Mail zu seinem Konto hinzuzufÃŧgen oder sie in einer - verknÃŧpften Anwendung zu verwenden. Wenn dies - bestätigt wird, kansnt Du Badges auf Dein {{ site_name }}-Konto hochladen, die fÃŧr diese Email-Adresse ausgestellt wurden - oder verwende Features innerhalb verknÃŧpften Anwendungen. - {% endblocktrans %} -
    -
    -
    -
    - -
    -
    - -{% endblock %} -{% block button_url %}{{ activate_url }}{% endblock %} -{% block button_url_copy %}{{ activate_url }}{% endblock %} -{% block button_text %}Confirm Now{% endblock %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + Bitte bestätige Deine zusätzliche E-Mail-Adresse. + + + {% blocktrans with site_name=current_site.name site_domain=current_site.domain %} + Ein {{ site_name }} Benutzer hat angefordert, diese E-Mail zu seinem Konto hinzuzufÃŧgen oder sie in einer verknÃŧpften Anwendung zu verwenden. + Wenn dies bestätigt wird, kannst Du Badges auf Dein {{ site_name }} Konto hochladen, die fÃŧr diese Email-Adresse ausgestellt wurden oder + verwende Features innerhalb verknÃŧpften Anwendungen. + {% endblocktrans %} + + + +{% endblock main %} +{% block button_url %} + {{ activate_url }} +{% endblock button_url %} +{% block button_alt_link %} +
    {{ activate_url }} +{% endblock button_alt_link %} +{% block button_text %} + Jetzt bestätigen +{% endblock button_text %} +{% endmjml %} diff --git a/apps/mainsite/templates/account/email/email_confirmation_message.txt b/apps/mainsite/templates/account/email/email_confirmation_message.txt index 1ac4c0bab..40878ecc7 100644 --- a/apps/mainsite/templates/account/email/email_confirmation_message.txt +++ b/apps/mainsite/templates/account/email/email_confirmation_message.txt @@ -1,10 +1,11 @@ -Bitte bestätige Deine Email-Adresse auf myBadges, -{{ email.email }} +{% load i18n %} +Bitte bestätige Deine zusätzliche E-Mail-Adresse. -Ein*e myBadges Nutzer*in hat angefragt diese Email-Adresse zu ihrem/seinem Account hinzuzufÃŧgen oder sie in einer verknÃŧpften Anwendung zu nutzen. -Wenn bestätigt, kannst Du Badges zu Deinem myBadges Account hinzufÃŧgen, die zu dieser Adresse ausgestellt wurden oder features in verknÃŧpften Anwendungen nutzen. +{% blocktrans with site_name=current_site.name site_domain=current_site.domain %} +Ein {{ site_name }} Benutzer hat angefordert, diese E-Mail zu seinem Konto hinzuzufÃŧgen oder sie in einer verknÃŧpften Anwendung zu verwenden. +Wenn dies bestätigt wird, kannst Du Badges auf Dein {{ site_name }} Konto hochladen, die fÃŧr diese Email-Adresse ausgestellt wurden oder +verwende Features innerhalb verknÃŧpften Anwendungen. +{% endblocktrans %} +Jetzt bestätigen: {{ activate_url }} - -Wenn Du dies nicht angefragt hast, kannst Du sicher diese Email ignorieren und -es wird nichts weiter passieren. diff --git a/apps/mainsite/templates/account/email/email_confirmation_signup_message.html b/apps/mainsite/templates/account/email/email_confirmation_signup_message.html index a8e47bd8a..a8939ea48 100644 --- a/apps/mainsite/templates/account/email/email_confirmation_signup_message.html +++ b/apps/mainsite/templates/account/email/email_confirmation_signup_message.html @@ -1,48 +1,33 @@ {% extends "email/base.html" %} +{% load static %} {% load i18n %} -{% block beforebutton %} -
    - - - - -
    - -
    - - - - - - -
    - - - - - - -
    -
    - {% blocktrans with site_name=site.name site_domain=site.domain %}Bitte bestätige Deinen - {{site_name}} Account.{% endblocktrans %} -
    -
    - - -
    -
    -{% endblock %} -{% block button_url %}{{ activate_url }}{% endblock %} -{% block button_url_copy %}{{ activate_url }}{% endblock %} -{% block button_text %}Confirm Now{% endblock %} -{% block unsubscribe %}{% endblock %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + {% blocktrans with site_name=site.name site_domain=site.domain %} + Herzlich Willkommen bei
    Open Educational Badges. + {% endblocktrans %} +
    + + {% blocktrans with site_name=site.name site_domain=site.domain %} + Bitte bestätige deinen Account, um Badges
    + zu sammeln, zu erstellen und zu vergeben. + {% endblocktrans %} +
    +
    +
    +{% endblock main %} +{% block button_url %} + {{ activate_url }} +{% endblock button_url %} +{% block button_alt_link %} + {{ activate_url }} +{% endblock button_alt_link %} +{% block button_text %} + Meinen Account bestätigen +{% endblock button_text %} +{% endmjml %} diff --git a/apps/mainsite/templates/account/email/email_confirmation_signup_message.txt b/apps/mainsite/templates/account/email/email_confirmation_signup_message.txt index 695dab78a..f6dac96fd 100644 --- a/apps/mainsite/templates/account/email/email_confirmation_signup_message.txt +++ b/apps/mainsite/templates/account/email/email_confirmation_signup_message.txt @@ -1,9 +1,7 @@ -Bitte verifiziere Deine Email-Adresse auf myBadges, -{{ email.email }} +Herzlich Willkommen bei Open Educational Badges. -Bestätige Deine Email-Adresse, um Deinen myBadges Account zu aktivieren. -Klicke auf den Link oder kopiere ihn in die Adresszeile Deines Browsers. +Bitte bestätige deinen Account, um Badges zu sammeln, +zu erstellen und zu vergeben. -{{ activate_url }} - -Wenn Du das nicht angefragt hast, dann kannst Du diese Email sicher ignorieren, und es wird nichts weiter passieren. +Meinen Account bestätigen: +{{ activate_url }} \ No newline at end of file diff --git a/apps/mainsite/templates/account/email/email_confirmation_signup_subject.txt b/apps/mainsite/templates/account/email/email_confirmation_signup_subject.txt index 7e46a61e7..f098f36df 100644 --- a/apps/mainsite/templates/account/email/email_confirmation_signup_subject.txt +++ b/apps/mainsite/templates/account/email/email_confirmation_signup_subject.txt @@ -1,4 +1,4 @@ {% load i18n %} {% autoescape off %} -{% blocktrans %}Bestätige Deinen myBadges Account{% endblocktrans %} +{% blocktrans %}Bestätige Deinen Account bei OpenEducationalBadges{% endblocktrans %} {% endautoescape %} diff --git a/apps/mainsite/templates/account/email/email_confirmation_subject.txt b/apps/mainsite/templates/account/email/email_confirmation_subject.txt index 4d71e8c5e..870b972b5 100644 --- a/apps/mainsite/templates/account/email/email_confirmation_subject.txt +++ b/apps/mainsite/templates/account/email/email_confirmation_subject.txt @@ -1,4 +1,4 @@ {% load i18n %} {% autoescape off %} -{% blocktrans %}Bestätige Deine myBadges Email-Addresse{% endblocktrans %} +{% blocktrans %}Bestätige Deine OpenEducationalBadges Email-Addresse{% endblocktrans %} {% endautoescape %} diff --git a/apps/mainsite/templates/account/email/email_staff_request_message.html b/apps/mainsite/templates/account/email/email_staff_request_message.html new file mode 100644 index 000000000..a6f946765 --- /dev/null +++ b/apps/mainsite/templates/account/email/email_staff_request_message.html @@ -0,0 +1,32 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Mitgliedsanfrage fÃŧr deine Institution + + + {{ user.first_name }} {{ user.last_name }} + hat eine Mitgliedsanfrage fÃŧr deine Institution + {{ issuer.name }} gestellt. + + + Du kannst die Anfrage Ãŧber folgenden Button auf Open Educational Badges bestätigen: + + + +{% endblock main %} +{% block button_url %} + {{ activate_url }} +{% endblock button_url %} +{% block button_alt_link %} + {{ activate_url }} +{% endblock button_alt_link %} +{% block button_text %} + {{ call_to_action_label }} +{% endblock button_text %} +{% endmjml %} diff --git a/apps/mainsite/templates/account/email/email_staff_request_messagt.txt b/apps/mainsite/templates/account/email/email_staff_request_messagt.txt new file mode 100644 index 000000000..7707a6520 --- /dev/null +++ b/apps/mainsite/templates/account/email/email_staff_request_messagt.txt @@ -0,0 +1,7 @@ +Mitgliedsanfrage fÃŧr deine Institution + +{{ user.first_name }} {{ user.last_name }} hat eine Mitgliedsanfrage +fÃŧr deine Institution {{ issuer.name }} gestellt. + +Du kannst die Anfrage Ãŧber folgenden Button auf Open Educational Badges bestätigen: +{{ activate_url }} \ No newline at end of file diff --git a/apps/mainsite/templates/account/email/email_staff_request_subject.txt b/apps/mainsite/templates/account/email/email_staff_request_subject.txt new file mode 100644 index 000000000..768f1a84d --- /dev/null +++ b/apps/mainsite/templates/account/email/email_staff_request_subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Mitgliedsanfrage bestätigen{% endblocktrans %} +{% endautoescape %} diff --git a/apps/mainsite/templates/account/email/password_reset_confirmation_message.html b/apps/mainsite/templates/account/email/password_reset_confirmation_message.html index 9a43c27f3..7c4af2a9b 100644 --- a/apps/mainsite/templates/account/email/password_reset_confirmation_message.html +++ b/apps/mainsite/templates/account/email/password_reset_confirmation_message.html @@ -1,60 +1,24 @@ {% extends "email/base.html" %} {% load i18n %} -{% block beforebutton %} -
    - - - - - - -
    - -
    - - - - - - -
    - - - - - - - - - -
    -
    - {% blocktrans with site_name=site.name site_domain=site.domain %} - Das Passwort fÃŧr Dein {{ site_name }} Konto wurde erfolgreich zurÃŧckgesetzt. - {% endblocktrans %} -
    -
    -
    - Du kannst Dich nun mit Deinem neuen Passwort anmelden. Wenn Du diese Passwortänderung nicht angefordert hast, - benutze bitte den Passwort vergessen-Link auf der Login - Seite, um es wieder zurÃŧckzusetzen, und kontaktiere uns umgehend unter {{ help_email }}. Vielen Dank - fÃŧr die Verwendung von myBadges! -
    -
    -
    -
    - -
    -
    -{% endblock %} -{% block button %}{% endblock %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Das Passwort fÃŧr Dein openbadges.education Konto wurde erfolgreich zurÃŧckgesetzt. + + + Du kannst Dich nun mit Deinem neuen Passwort anmelden. Wenn Du diese Passwortänderung nicht angefordert hast, + benutze bitte den Passwort vergessen-Link auf der Login Seite, um es wieder zurÃŧckzusetzen, + und kontaktiere uns umgehend unter {{ help_email }}. + Vielen Dank fÃŧr die Verwendung von OpenEducationalBadges! + + + +{% endblock main %} +{% block button %} +{% endblock button %} +{% endmjml %} diff --git a/apps/mainsite/templates/account/email/password_reset_confirmation_message.txt b/apps/mainsite/templates/account/email/password_reset_confirmation_message.txt index 2e14f16b0..dfd453bdf 100644 --- a/apps/mainsite/templates/account/email/password_reset_confirmation_message.txt +++ b/apps/mainsite/templates/account/email/password_reset_confirmation_message.txt @@ -1,10 +1,7 @@ -{% load i18n %}{% blocktrans with site_name=site.name site_domain=site.domain %}Hello from {{ site_name }}! +Das Passwort fÃŧr Dein openbadges.education Konto wurde erfolgreich zurÃŧckgesetzt. -Das Passwort fÃŧr Dein Konto unter {{ site_domain }} wurde erfolgreich zurÃŧckgesetzt. Du kannst Dich nun mit Deinem neuen Passwort anmelden. Wenn Du diese Passwortänderung nicht angefordert hast, -verwende den Link Passwort vergessen auf der Anmeldeseite, um es wieder zurÃŧckzusetzen +benutze bitte den Passwort vergessen-Link auf der Login Seite, um es wieder zurÃŧckzusetzen, und kontaktiere uns umgehend unter {{ help_email }}. -{% endblocktrans %} - -{% blocktrans with site_name=site.name site_domain=site.domain %}Vielen Dank, dass Du {{ site_name }} nutzt!{% endblocktrans %} +Vielen Dank fÃŧr die Verwendung von OpenEducationalBadges! diff --git a/apps/mainsite/templates/account/email/password_reset_key_message.html b/apps/mainsite/templates/account/email/password_reset_key_message.html index 20683fbb6..352b43660 100644 --- a/apps/mainsite/templates/account/email/password_reset_key_message.html +++ b/apps/mainsite/templates/account/email/password_reset_key_message.html @@ -1,65 +1,26 @@ {% extends "email/base.html" %} {% load i18n %} -{% block beforebutton %} -
    - - - - - - -
    - -
    - - - - - - -
    - - - - - - - - - -
    -
    - {% blocktrans with site_name=site.name site_domain=site.domain %} - Bitte bestätige Deine Anfrage zum ZurÃŧcksetzen Deines {{ site_name }}-Passworts. - {% endblocktrans %} -
    -
    -
    - {% blocktrans with site_name=site.name site_domain=site.domain %} - Du erhältst diese E-Mail, weil Du oder jemand anderes ein Passwort fÃŧr Dein - Benutzerkonto bei {{ site_domain }} angefordert hat. - Du kannst sie getrost ignorieren, wenn Du dieses ZurÃŧcksetzen nicht angefordert hast. Wenn Du das doch warst und Du - Dein Passwort ändern mÃļchtest, klicke auf die Schaltfläche unten. - - {% endblocktrans %} -
    -
    -
    -
    - -
    -
    -{% endblock %} -{% block button_url %}{{ password_reset_url }}{% endblock %} -{% block button_url_copy %}{{ password_reset_url }}{% endblock %} -{% block button_text %}Choose a new password{% endblock %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + {% blocktrans with site_name=site.name site_domain=site.domain %}Bitte bestätige Deine Anfrage zum ZurÃŧcksetzen Deines {{ site_name }}-Passworts.{% endblocktrans %} + + + {% blocktrans with site_name=site.name site_domain=site.domain %}Du erhältst diese E-Mail, weil Du oder jemand anderes ein Passwort fÃŧr Dein Benutzerkonto bei {{ site_domain }} angefordert hat. Du kannst sie getrost ignorieren, wenn Du dieses ZurÃŧcksetzen nicht angefordert hast. Wenn Du das doch warst und Dein Passwort ändern mÃļchtest, klicke auf die Schaltfläche unten.{% endblocktrans %} + + + +{% endblock main %} +{% block button_url %} + {{ password_reset_url }} +{% endblock button_url %} +{% block button_alt_link %} + {{ password_reset_url }} +{% endblock button_alt_link %} +{% block button_text %} + Neues Passwort setzen +{% endblock button_text %} +{% endmjml %} diff --git a/apps/mainsite/templates/account/email/password_reset_key_message.txt b/apps/mainsite/templates/account/email/password_reset_key_message.txt index b0968be3e..7852b880c 100644 --- a/apps/mainsite/templates/account/email/password_reset_key_message.txt +++ b/apps/mainsite/templates/account/email/password_reset_key_message.txt @@ -1,9 +1,7 @@ -{% load i18n %}{% blocktrans with site_name=site.name site_domain=site.domain %}Hallo von {{ site_name }}! +{% load i18n %} -Du erhältst diese E-Mail, weil Du oder eine andere Person ein Passwort fÃŧr Dein Benutzerkonto auf {{ site_domain }} angefordert hat. -Sie kann getrost ignoriert werden, wenn Du dieses ZurÃŧcksetzen nicht angefordert hast. Wenn Du es doch warst, und Du Dein Passwort ändern mÃļchtest, klicke auf den unten stehenden Link. -{% endblocktrans %} +{% blocktrans with site_name=site.name site_domain=site.domain %}Bitte bestätige Deine Anfrage zum ZurÃŧcksetzen Deines {{ site_name }}-Passworts.{% endblocktrans %} -{{ password_reset_url }} +{% blocktrans with site_name=site.name site_domain=site.domain %}Du erhältst diese E-Mail, weil Du oder jemand anderes ein Passwort fÃŧr Dein Benutzerkonto bei {{ site_domain }} angefordert hat. Du kannst sie getrost ignorieren, wenn Du dieses ZurÃŧcksetzen nicht angefordert hast. Wenn Du das doch warst und Dein Passwort ändern mÃļchtest, klicke auf die Schaltfläche unten.{% endblocktrans %} -{% blocktrans with site_name=site.name site_domain=site.domain %}Danke, dass Du {{ site_name }} nutzt!{% endblocktrans %} +{{ password_reset_url }} diff --git a/apps/mainsite/templates/account/email/staff_request_confirmed_message.html b/apps/mainsite/templates/account/email/staff_request_confirmed_message.html new file mode 100644 index 000000000..90573694e --- /dev/null +++ b/apps/mainsite/templates/account/email/staff_request_confirmed_message.html @@ -0,0 +1,32 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Bestätigung deiner Mitgliedsanfrage + + + {{ issuer.name }} hat dich als Mitglied hinzugefÃŧgt. + + + Du kannst nun im Namen der Institution Badges vergeben. +
    + Viel Spaß auf OEB! +
    +
    +
    +{% endblock main %} +{% block button_url %} + {{ activate_url }} +{% endblock button_url %} +{% block button_alt_link %} + {{ activate_url }} +{% endblock button_alt_link %} +{% block button_text %} + {{ call_to_action_label }} +{% endblock button_text %} +{% endmjml %} diff --git a/apps/mainsite/templates/account/email/staff_request_confirmed_message.txt b/apps/mainsite/templates/account/email/staff_request_confirmed_message.txt new file mode 100644 index 000000000..618a0823b --- /dev/null +++ b/apps/mainsite/templates/account/email/staff_request_confirmed_message.txt @@ -0,0 +1,9 @@ +Bestätigung deiner Mitgliedsanfrage + +{{ issuer.name }} hat dich als Mitglied hinzugefÃŧgt. +Du kannst nun im Namen der Institution Badges vergeben. + +Viel Spaß auf OEB! + +{{ call_to_action_label }}: +{{ activate_url }} \ No newline at end of file diff --git a/apps/mainsite/templates/account/email/staff_request_confirmed_subject.txt b/apps/mainsite/templates/account/email/staff_request_confirmed_subject.txt new file mode 100644 index 000000000..c50eb186e --- /dev/null +++ b/apps/mainsite/templates/account/email/staff_request_confirmed_subject.txt @@ -0,0 +1 @@ +Bestätigung deiner Mitgliedsanfrage \ No newline at end of file diff --git a/apps/mainsite/templates/admin/base_site.html b/apps/mainsite/templates/admin/base_site.html index 31c19ce81..cf42fd6ec 100644 --- a/apps/mainsite/templates/admin/base_site.html +++ b/apps/mainsite/templates/admin/base_site.html @@ -1,50 +1,57 @@ -{% extends "admin/base.html" %} -{% load staticfiles %} +{% extends "admin/base.html" %} +{% load static %} -{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }} +{% endblock %} -{% block extrastyle %} +{% block extrastyle %} {{ block.super }} {% endblock %} -{% block branding %} -
    - - - -
    -{% endblock %} +{% block base_content %}{% endblock base_content %} {% block branding %} +
    + + + +
    +{% endblock %} -{% block nav-global %}{% endblock %} +{% block nav-global %}{% endblock %} {% block userlinks %} - Sitewide Actions / - {{ block.super }} -{% endblock %} \ No newline at end of file + Sitewide Actions / + {{block.super }} +{% endblock %} diff --git a/apps/mainsite/templates/email/base.html b/apps/mainsite/templates/email/base.html index 324ec92bb..228096f5b 100644 --- a/apps/mainsite/templates/email/base.html +++ b/apps/mainsite/templates/email/base.html @@ -1,371 +1,70 @@ {% load account %} -{% load staticfiles %} +{% load static %} {% load i18n %} - - - - - - {% firstof email_title "Badgr" %} - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - - - -
    - -
    - - - - -
    - - - - - - -
    -
    -
    -
    - -
    -
    - -
    - - - {% block main %} - {% block beforebutton %}{% endblock beforebutton %} - {% block button %} -
    -
    - - - - - -
    - -
    - - - - - - -
    - - - - - - - - - -
    - - - - - - -
    - - {% block button_text %}{% endblock %} - -
    -
    -
    - Or, you can copy/paste this link into your browser:
    - {% block button_url_copy %}{% endblock %} -
    -
    -
    -
    - -
    -
    - {% endblock button %} - {% block afterbutton %}{% endblock %} - {% endblock main %} -
    -
    - - - -
    -
    - - - - - - -
    - -
    - - - {% block beforeabout %}{% endblock %} - - - - -
    - - - - - - - - {% if GDPR_INFO_URL %} - - - - - - - {% endif %} - - - - {% if OPERATOR_ADDRESS and OPERATOR_NAME %} - - - - {% endif %} - - - -
    -
    - New to Badging? -
    -
    - -
    -
    - We’re GDPR-compliant -
    -
    - -
    -

    -

    - -
    -
    - Sent from {% if OPERATOR_URL %} - {{ OPERATOR_NAME }} - {% else %}{{ OPERATOR_NAME }}{% endif %}
    - {{ OPERATOR_ADDRESS }} -
    -
    -
    - {% block unsubscribe %} - Unsubscribe - {% endblock %} - {% if PRIVACY_POLICY_URL %} â€ĸ Privacy - Policy {% endif %}{% if TERMS_OF_SERVICE_URL %}â€ĸ - Terms{% endif %} -
    -
    -
    -
    - -
    -
    -
    - - - - \ No newline at end of file +{% load mjml %} +{% get_current_language as LANGUAGE_CODE %} +{% mjml %} + + + + + + +{% firstof email_title 'openbadges.education' %} + + + + + + + + +a { +color: #1400FF; +} + + + + +{% block main %} + + +{% endblock main %} +{% block button %} + + + + {% block button_text %} + {% endblock button_text %} + + + Alternativ kannst du diesen Link kopieren und in deinen Browser einfÃŧgen: +
    + + {% block button_alt_link %} + {% endblock button_alt_link %} + +
    +
    +
    +{% endblock button %} + + + + + + +Du hast Fragen? + +Hier geht es zu unseren FAQ. + + + + +
    +
    +
    +{% endmjml %} diff --git a/apps/mainsite/templates/error/base.html b/apps/mainsite/templates/error/base.html index 80fcf9140..f165eaf92 100644 --- a/apps/mainsite/templates/error/base.html +++ b/apps/mainsite/templates/error/base.html @@ -1,13 +1,16 @@ -{% extends "public/base.html" %} -{% load staticfiles %} +{% extends "public/base.html" %} {% load static %} {% block content %} -{% block content %} - -
    - Badgr is sorry :( - {% block error_content %} -

    We're sorry, an error has occurred.

    - {% endblock %} -

    +
    + Badgr is sorry :( + {% block error_content %} +

    We're sorry, an error has occurred.

    +

    {% endblock %}

    +
    {% endblock %} diff --git a/apps/mainsite/templates/iframes/backpack/index.html b/apps/mainsite/templates/iframes/backpack/index.html new file mode 100644 index 000000000..412f962f8 --- /dev/null +++ b/apps/mainsite/templates/iframes/backpack/index.html @@ -0,0 +1,39 @@ +{% load static %} + + + + + Open Educational Badges Learners Backpack + + + + + + + + + + + diff --git a/apps/mainsite/templates/iframes/badge-edit/index.html b/apps/mainsite/templates/iframes/badge-edit/index.html new file mode 100644 index 000000000..b4518416c --- /dev/null +++ b/apps/mainsite/templates/iframes/badge-edit/index.html @@ -0,0 +1,52 @@ +{% load static %} + + + + + Open Educational Badges Badge editieren + + + + + + + + + + + diff --git a/apps/mainsite/templates/iframes/badges/index.html b/apps/mainsite/templates/iframes/badges/index.html new file mode 100644 index 000000000..788e21a32 --- /dev/null +++ b/apps/mainsite/templates/iframes/badges/index.html @@ -0,0 +1,34 @@ +{% load static %} + + + + + Open Educational Badges Learners Badges + + + + + + + + + + + diff --git a/apps/mainsite/templates/iframes/competencies/index.html b/apps/mainsite/templates/iframes/competencies/index.html new file mode 100644 index 000000000..185f59b18 --- /dev/null +++ b/apps/mainsite/templates/iframes/competencies/index.html @@ -0,0 +1,33 @@ +{% load static %} + + + + + Open Educational Badges Learners Competencies + + + + + + + + + + + diff --git a/apps/mainsite/templates/iframes/learningpaths/index.html b/apps/mainsite/templates/iframes/learningpaths/index.html new file mode 100644 index 000000000..8d7c66ca0 --- /dev/null +++ b/apps/mainsite/templates/iframes/learningpaths/index.html @@ -0,0 +1,36 @@ +{% load static %} + + + + + Open Educational Badges Learners Learningpaths + + + + + + + + + + + diff --git a/apps/mainsite/templates/iframes/profile/index.html b/apps/mainsite/templates/iframes/profile/index.html new file mode 100644 index 000000000..30a04a573 --- /dev/null +++ b/apps/mainsite/templates/iframes/profile/index.html @@ -0,0 +1,32 @@ +{% load static %} + + + + + Open Educational Badges Learners Profile + + + + + + + + + + + + diff --git a/apps/mainsite/templates/issuer/email/base_notify_award.html b/apps/mainsite/templates/issuer/email/base_notify_award.html index 23f402ee5..9954c0e2a 100644 --- a/apps/mainsite/templates/issuer/email/base_notify_award.html +++ b/apps/mainsite/templates/issuer/email/base_notify_award.html @@ -1,463 +1,132 @@ {% extends "email/base.html" %} - -{% load staticfiles %} - +{% load static %} +{% load mjml %} +{% mjml %} {% block main %} - -
    - - - - - - - -
    - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - Herzlichen GlÃŧckwunsch, Du hast ein Badge erhalten! -
    - -
    - - - - - - - -
    - - - -
    - -
    - -
    - {{ badge_name }} -
    - -
    - -
    - {{ badge_description }} -
    - -
    - -

    -

    - - - - -
    - -
    - Issued by: -
    - -
    - -
    - - -
    - -
    - - - - - -
    - - - - - - - -
    - - -
    - - - - - - - -
    - - - - - - - -
    - {% if issuer_image_url %} - - {% endif %} -
    - -
    - -
    - - - -
    - - - - - - - -
    - - - -
    - -
    - - -
    - -
    - - - - - -
    - - - - - - - -
    - - -
    - - - - - + + + + + + {% endfor %} + + + {% endif %} + + {% if issuer_image_url %} + + + + {% endif %} + + Vergeben von: + {{ issuer_name }} + + + {% if badge_category == "learningpath" %} + + + {{ call_to_action_label }} + + Im Anhang dieser Mail findest du neben der Micro-Degree-Datei ein PDF-Zertifikat, das deinen Lernerfolg ebenfalls ausweist. + + + + {% else %} + + + + Im Anhang dieser Mail findest du deine Badge-Datei und das zugehÃļrige PDF-Zertifikat zum Herunterladen. + + + + + + Alle deine erhaltenen Badges kannst du auf + Open Educational Badges + in deinem Rucksack sammeln und mit anderen teilen. Lege dafÃŧr einfach kostenlos einen Account an: + {{ call_to_action_label }} + + {% endif %} - - - - - -
    - -
    - {% if issued_on %} - Ausgestellt am {{issued_on}} + + + + + Herzlichen GlÃŧckwunsch + {% if name %} +
    + {{ name }}, + {% else %} + , + {% endif %} +
    + {% if issuer_name %} + du hast von {{ issuer_name }} einen + {% else %} + du hast einen + {% endif %} + + {% if badge_category == "learningpath" %} + MICRO DEGREE + {% else %} + BADGE + {% endif %} + erhalten! +
    +
    +
    + + + + + + {% if badge_category != "learningpath" %} + + + + Du hast erfolgreich an dem Lernangebot + {{ badge_name }} teilgenommen. + + + + {% endif %} + {% if badge_competencies %} + + + + Dabei hast du folgende Kompetenzen gestärkt: + + + + + + {% for item in badge_competencies %} + +
    + {{ item.name }} + {% if item.framework == "esco" %} + [E] + {% endif %} + {{ item.studyLoad }} + Clock +
    - -
    - - -
    - -
    - - - - - -
    - - - - - - - -
    - - -
    - - - - - - - - - - - -
    - - - - - -
    - - {% block call_to_action_label %} - {% endblock %} - -
    - -
    - - - - - -
    - - Download - -
    - -
    - -
    - - -
    - -
    - - - - - -
    - - - - - - - -
    - - -
    - - - - - - - -
    - - - - - - - -
    - -
    - - - - -
    - -
    - -
    - -
    - - -
    - -
    - - - {% endblock main %} +{% block button %} +{% endblock button %} +{% endmjml %} diff --git a/apps/mainsite/templates/issuer/email/notify_account_holder_message.html b/apps/mainsite/templates/issuer/email/notify_account_holder_message.html deleted file mode 100644 index 853bfdf5d..000000000 --- a/apps/mainsite/templates/issuer/email/notify_account_holder_message.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "issuer/email/base_notify_award.html" %} - -{% load staticfiles %} - -{% block call_to_action_label %} -Sign In -{% endblock %} diff --git a/apps/mainsite/templates/issuer/email/notify_account_holder_message.txt b/apps/mainsite/templates/issuer/email/notify_account_holder_message.txt deleted file mode 100644 index d3af41aec..000000000 --- a/apps/mainsite/templates/issuer/email/notify_account_holder_message.txt +++ /dev/null @@ -1,49 +0,0 @@ -*************************************************** -Herzlichen GlÃŧckwunsch, Du hast ein Badge erhalten! -*************************************************** - ---------- -{{ badge_name }} ---------- - -{{ badge_description }} - ---------- -Institution ---------- - -{{ issuer_name }} -{{ issuer_url }} - ----------- -Dein Badge ist fertig ----------- - - -Dieses Badge wurde automatisch Deinem Konto {{ site_name }} ( {{ site_url }} ) hinzugefÃŧgt. Wo auch immer Du -Dein Badge teilen oder zeigen mÃļchtest, die Information Ãŧber Deine Leistung wird darin kodiert sein. -Bei Fragen zu diesem Badge wende Dich bitte unter {{ issuer_email }} an die ausstellende Institution. - - ----------- -Download ----------- - - -Du kannst diese Badge-Bilddatei auch fÃŧr Deine Unterlagen speichern oder in einen beliebiges Open Badges -kompatiblen Service hochladen -{{ download_url }} - ------------ -Open Badges: Portable Digitale Abbildung Deiner Kompetenzen ------------ - -Mit Open Badges kannst Du Deine erworbenen Kompetenzen von vielen Lernorten abrufen -und kombinieren, um eine Story Deines Lernens zu erzählen. - - -Erfahre mehr auf http://openbadges.org - -Abmelden: Wenn Du nicht mehr Ãŧber zukÃŧnftigen Erwerb von Badges informiert werden mÃļchtest, klicke auf den folgenden Link: - -{{ unsubscribe_url }} diff --git a/apps/mainsite/templates/issuer/email/notify_account_holder_subject.txt b/apps/mainsite/templates/issuer/email/notify_account_holder_subject.txt deleted file mode 100644 index 9a7245e0f..000000000 --- a/apps/mainsite/templates/issuer/email/notify_account_holder_subject.txt +++ /dev/null @@ -1 +0,0 @@ -{% if renotify %}Erinnerung: {% endif %}Herzlichen GlÃŧckwunsch, Du hast ein Badge erhalten! diff --git a/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_message.html b/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_message.html new file mode 100644 index 000000000..d147dda2a --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_message.html @@ -0,0 +1,30 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Eine Institution wurde automatisch verifiziert! + + + Hallo Admins, eine neue Institution "{{ issuer_name }}" wurde + erstellt und automatisch verifiziert. + + + +{% endblock main %} +{% block button_url %} + {{ HTTP_ORIGIN + }}/staff/issuer/issuer/ +{% endblock button_url %} +{% block button_text %} + Zur + Admin Übersicht +{% endblock button_text %} +{% block button_alt_link %} + {{ HTTP_ORIGIN }}/staff/issuer/issuer/ +{% endblock button_alt_link %} +{% endmjml %} diff --git a/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_message.txt b/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_message.txt new file mode 100644 index 000000000..ff73f0cd5 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_message.txt @@ -0,0 +1,6 @@ +Eine Institution wurde automatisch verifiziert! + +Hallo Admins, +eine neue Institution "{{ issuer_name }}" wurde erstellt und automatisch verifiziert. + +Zur Admin Übersicht: {{ HTTP_ORIGIN }}/staff/issuer/issuer/ diff --git a/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_subject.txt b/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_subject.txt new file mode 100644 index 000000000..f73b94152 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_admins_issuer_verified_subject.txt @@ -0,0 +1 @@ +Eine neue Institution (Issuer) wurde erstellt und automatisch verifiziert \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_admins_message.html b/apps/mainsite/templates/issuer/email/notify_admins_message.html index 881874f20..a9f254c8d 100644 --- a/apps/mainsite/templates/issuer/email/notify_admins_message.html +++ b/apps/mainsite/templates/issuer/email/notify_admins_message.html @@ -1,3 +1,29 @@ -Hallo Admins, - -eine neue Institution ("{{ issuer_name }}") wurde erstellt. Bitte verifiziereren Sie diese. +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Eine neue Institution benÃļtigt Verifikation! + + + Hallo Admins, + eine neue Institution "{{ issuer_name }}" wurde erstellt. + Bitte verifiziereren Sie diese. + + + +{% endblock main %} +{% block button_url %} + {{ HTTP_ORIGIN }}/staff/issuer/issuer/ +{% endblock button_url %} +{% block button_text %} + Zur Verifikation +{% endblock button_text %} +{% block button_alt_link %} + {{ HTTP_ORIGIN }}/staff/issuer/issuer/ +{% endblock button_alt_link %} +{% endmjml %} diff --git a/apps/mainsite/templates/issuer/email/notify_admins_message.txt b/apps/mainsite/templates/issuer/email/notify_admins_message.txt new file mode 100644 index 000000000..d9bf565b2 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_admins_message.txt @@ -0,0 +1,7 @@ +Eine neue Institution benÃļtigt Verifikation! + +Hallo Admins, +eine neue Institution "{{ issuer_name }}" wurde erstellt. +Bitte verifiziereren Sie diese. + +Zur Verifikation: {{ HTTP_ORIGIN }}/staff/issuer/issuer/ \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_admins_subject.txt b/apps/mainsite/templates/issuer/email/notify_admins_subject.txt index d4bdf414e..1728bfa4b 100644 --- a/apps/mainsite/templates/issuer/email/notify_admins_subject.txt +++ b/apps/mainsite/templates/issuer/email/notify_admins_subject.txt @@ -1 +1 @@ -Eine neue Institution (Issuer) wurde erstellt +Eine neue Institution (Issuer) benÃļtigt Verifikation diff --git a/apps/mainsite/templates/issuer/email/notify_earner_message.html b/apps/mainsite/templates/issuer/email/notify_earner_message.html index a21d9fe54..03c6c2b43 100644 --- a/apps/mainsite/templates/issuer/email/notify_earner_message.html +++ b/apps/mainsite/templates/issuer/email/notify_earner_message.html @@ -1,7 +1,2 @@ {% extends "issuer/email/base_notify_award.html" %} - -{% load staticfiles %} - -{% block call_to_action_label %} -Account erstellen -{% endblock %} +{% load static %} diff --git a/apps/mainsite/templates/issuer/email/notify_earner_message.txt b/apps/mainsite/templates/issuer/email/notify_earner_message.txt index 094959b07..7c31ab1c7 100644 --- a/apps/mainsite/templates/issuer/email/notify_earner_message.txt +++ b/apps/mainsite/templates/issuer/email/notify_earner_message.txt @@ -1,57 +1,25 @@ -*************************************************** -Herzlichen GlÃŧckwunsch, Du hast ein Badge erhalten! -*************************************************** +Herzlichen GlÃŧckwunsch{% if name %} {{ name }}{% endif %}, +du hast einen {% if badge_category == "learningpath" %}MICRO DEGREE{% else %}BADGE{% endif %}erhalten! -{{ badge_name }} - - -{{ badge_description }} - ---------- -Dieses Badge wurde vergeben von: ---------- - -{{ issuer_name }} -{{ issuer_url }} - ----------- -Download ----------- - -Speicher dieses Badge Bild fÃŧr Deine Unterlagen oder zum Hochladen in einen Open Badges-kompatiblen Dienst: -{{ download_url }} - ----------- -Badge Speicherung und Zugriff ----------- - -Wohin auch immer Du Dein Badge mitnimmst, Informationen Ãŧber Deine Leistung werden -darin kodiert sein. Du kannst jeden Open Badges Rucksackservice nutzen, aber wenn Du -einen {{ site_name }} ( {{ site_url }} ) Account erstellst, werden die von dieser Institution vergebenen Badges -automatisch hinzugefÃŧgt. -{% if GDPR_INFO_URL %} ------------ -Kenne Deine Rechte ------------ -Die Institution, die dieses Abzeichen vergibt, hat {{ site_name }} Deine E-Mail-Adresse und die -Daten Ãŧber Deine Leistung, die in diesem Badge enthalten sind, bereitgestellt. Wenn Du Fragen zu diesem Badge hast, -kontaktiere die ausstellende Institution unter {{ issuer_email }}. Du kannst auch eine Kopie Deiner Daten anfordern, -die Entfernung der Daten Ãŧber Dich oder die Aktualisierung falscher Informationen. Erfahre mehr unter -{{ GDPR_INFO_URL }} +Vergeben von: {{ issuer_name }} +{% if badge_category != "learningpath" %} +Du hast erfolgreich an dem Lernangebot {{ badge_name }} teilgenommen. {% endif %} ------------ -Open Badges: Übertragbare Digitale Abzeichen ------------ - - -Open Badges ermÃļglichen Dir Abzeichen von den vielen Orten mitzunehmen, wo Du lernst. -Kombinieren sie, um eine zusammenhängende Geschichte Ãŧber Dein Lernen zu erzählen. - -Erfahre mehr unter http://openbadges.org - -Abmelden: Wenn Du nicht Ãŧber zukÃŧnftige Vergaben von Badges von diesem Dienst informiert werden mÃļchtest, -klicke auf den folgenden Link: -{{ unsubscribe_url }} - -{% if PRIVACY_POLICY_URL %}Privacy Policy: {{ PRIVACY_POLICY_URL }}{% endif %} -{% if TERMS_OF_SERVICE_URL %}Terms of Service: {{ TERMS_OF_SERVICE_URL }}{% endif %} +{% if badge_competencies %} +Dabei hast du folgende Kompetenzen gestärkt: +{% for item in badge_competencies %} + {{ item.name }} - {{ item.studyLoad }} +{% endfor %} +{% endif %} +{% if badge_category == "learningpath" %} +{{ call_to_action_label }}: {{ activate_url }} + +Im Anhang dieser Mail findest du neben der Micro-Degree-Datei ein PDF-Zertifikat, das deinen Lernerfolg ebenfalls ausweist. +{% else %} +Im Anhang dieser Mail findest du deine Badge-Datei und das zugehÃļrige PDF-Zertifikat zum Herunterladen. + +Alle deine erhaltenen Badges kannst du auf Open Educational Badges +in deinem Rucksack sammeln und mit anderen teilen. +Lege dafÃŧr einfach kostenlos einen Account an: +{{ activate_url }} +{% endif %} \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_earner_subject.txt b/apps/mainsite/templates/issuer/email/notify_earner_subject.txt index 4157ad3ed..468b7baa1 100644 --- a/apps/mainsite/templates/issuer/email/notify_earner_subject.txt +++ b/apps/mainsite/templates/issuer/email/notify_earner_subject.txt @@ -1 +1 @@ -{% if renotify %}Reminder: {% endif %}Herzlichen GlÃŧckwunsch, Du hast ein Badge erhalten! +{% if renotify %}Reminder: {% endif %}{% if issuer_name %}{{ issuer_name }} sendet dir einen Badge.{% else %}Herzlichen GlÃŧckwunsch, du hast einen Badge erhalten!{% endif %} diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_message.html b/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_message.html new file mode 100644 index 000000000..8af01be38 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_message.html @@ -0,0 +1,30 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Netzwerk-Einladung + + + Deine Institution {{ issuer.name }} wurde zum Netzwerk {{ network.name }} eingeladen. + + + Bitte bestätige die Anfrage auf Open Educational Badges Ãŧber den folgenden Button. + + + +{% endblock main %} +{% block button_url %} + {{ activate_url }} +{% endblock button_url %} +{% block button_alt_link %} + {{ activate_url }} +{% endblock button_alt_link %} +{% block button_text %} + {{ call_to_action_label }} +{% endblock button_text %} +{% endmjml %} diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_message.txt b/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_message.txt new file mode 100644 index 000000000..861a23b1e --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_message.txt @@ -0,0 +1,7 @@ +Netzwerk-Einladung + + Deine Institution {{ issuer.name }} wurde zum Netzwerk {{ network.name }} eingeladen. + + Bitte bestätige die Anfrage auf Open Educational Badges Ãŧber den folgenden Button. + + {{ call_to_action_label }}: {{ activate_url }} diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_subject.txt b/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_subject.txt new file mode 100644 index 000000000..463c3c688 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_network_invitation_subject.txt @@ -0,0 +1 @@ +Netzwerk-Einladung auf OEB \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_network_update_message.html b/apps/mainsite/templates/issuer/email/notify_issuer_network_update_message.html new file mode 100644 index 000000000..fd8324b25 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_network_update_message.html @@ -0,0 +1,20 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Netzwerk-Update + + + Deine Institution {{ issuer.name }} wurde aus dem Netzwerk {{ network.name }} entfernt. + + + +{% endblock main %} +{% block button %} +{% endblock button %} +{% endmjml %} diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_network_update_message.txt b/apps/mainsite/templates/issuer/email/notify_issuer_network_update_message.txt new file mode 100644 index 000000000..137361bf7 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_network_update_message.txt @@ -0,0 +1,3 @@ +Netzwerk-Update + +Deine Institution {{ issuer.name }} wurde aus dem Netzwerk {{ network.name }} entfernt. \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_network_update_subject.txt b/apps/mainsite/templates/issuer/email/notify_issuer_network_update_subject.txt new file mode 100644 index 000000000..ccf5bda98 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_network_update_subject.txt @@ -0,0 +1 @@ +Netzwerk-Update auf OEB \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_unverified_message.html b/apps/mainsite/templates/issuer/email/notify_issuer_unverified_message.html new file mode 100644 index 000000000..0f4e4fc5b --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_unverified_message.html @@ -0,0 +1,35 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Verifiziere deine Institution + + + Super, dass du deine Institution {{ issuer_name }} auf OEB angelegt hast! + + + Leider konnten wir sie nicht automatisch verifizieren, da die verwendete E-Mail-Adresse nicht zur Domain der angegebenen Webseite passt. + + + So kannst du die Verifizierung abschließen: + + + â€ĸ FÃŧge die offizielle E-Mail-Adresse der Institution zu deinem Account hinzu oder +
    + â€ĸ antworte an support@openbadges.education mit deinem Namen, einem Link zur Webseite deiner Organisation sowie einer kurzen Bestätigung deiner ZugehÃļrigkeit. +
    + Bei Fragen helfen wir gerne weiter. + Beste GrÃŧße +
    + dein OEB-Team
    +
    +
    +{% endblock main %} +{% block button %} +{% endblock button %} +{% endmjml %} diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_unverified_message.txt b/apps/mainsite/templates/issuer/email/notify_issuer_unverified_message.txt new file mode 100644 index 000000000..f0b2e7ed7 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_unverified_message.txt @@ -0,0 +1,18 @@ +Verifiziere deine Institution + + Super, dass du deine Institution {{ issuer_name }} auf OEB angelegt hast! + + Leider konnten wir sie nicht automatisch verifizieren, da die verwendete E-Mail-Adresse nicht zur Domain der angegebenen Webseite passt. + + So kannst du die Verifizierung abschließen: + + â€ĸ FÃŧge die offizielle E-Mail-Adresse der Institution zu deinem Account hinzu oder + â€ĸ antworte an support@openbadges.education mit deinem Namen, + einem Link zur Webseite deiner Organisation + sowie einer kurzen Bestätigung deiner ZugehÃļrigkeit. + + Bei Fragen helfen wir gerne weiter. + + Beste GrÃŧße + + dein OEB-Team \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_unverified_subject.txt b/apps/mainsite/templates/issuer/email/notify_issuer_unverified_subject.txt new file mode 100644 index 000000000..2b83c50e2 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_unverified_subject.txt @@ -0,0 +1 @@ +Verifiziere deine Institution auf OEB \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_verified_message.html b/apps/mainsite/templates/issuer/email/notify_issuer_verified_message.html new file mode 100644 index 000000000..a052f7a2f --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_verified_message.html @@ -0,0 +1,28 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + + Deine Institution {{ issuer_name }} wurde verifiziert! + + + Du kannst direkt loslegen, Badges zu erstellen und zu vergeben. Hier zeigen wir dir, wie du in minutenschnelle deinen ersten Badge anlegst: + + + + +{% endblock main %} +{% block button_text %} + Zur OEB-Plattform +{% endblock button_text %} +{% block button_url %} + https://openbadges.education/auth/login +{% endblock button_url %} +{% block button_alt_link %} + https://openbadges.education/auth/login +{% endblock button_alt_link %} +{% endmjml %} diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_verified_message.txt b/apps/mainsite/templates/issuer/email/notify_issuer_verified_message.txt new file mode 100644 index 000000000..e195858e3 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_verified_message.txt @@ -0,0 +1,7 @@ + Deine Institution {{ issuer_name }} wurde verifiziert! + + Du kannst direkt loslegen, Badges zu erstellen und zu vergeben. + Hier zeigen wir dir, wie du in minutenschnelle deinen ersten Badge anlegst: + https://www.youtube.com/watch?v=hL8SBqpTAag + + Zur OEB-Plattform: https://openbadges.education/auth/login diff --git a/apps/mainsite/templates/issuer/email/notify_issuer_verified_subject.txt b/apps/mainsite/templates/issuer/email/notify_issuer_verified_subject.txt new file mode 100644 index 000000000..0e8e753ba --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_issuer_verified_subject.txt @@ -0,0 +1 @@ +Jetzt Badges erstellen! diff --git a/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_message.html b/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_message.html new file mode 100644 index 000000000..03c6c2b43 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_message.html @@ -0,0 +1,2 @@ +{% extends "issuer/email/base_notify_award.html" %} +{% load static %} diff --git a/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_message.txt b/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_message.txt new file mode 100644 index 000000000..7c31ab1c7 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_message.txt @@ -0,0 +1,25 @@ +Herzlichen GlÃŧckwunsch{% if name %} {{ name }}{% endif %}, +du hast einen {% if badge_category == "learningpath" %}MICRO DEGREE{% else %}BADGE{% endif %}erhalten! + +Vergeben von: {{ issuer_name }} +{% if badge_category != "learningpath" %} +Du hast erfolgreich an dem Lernangebot {{ badge_name }} teilgenommen. +{% endif %} +{% if badge_competencies %} +Dabei hast du folgende Kompetenzen gestärkt: +{% for item in badge_competencies %} + {{ item.name }} - {{ item.studyLoad }} +{% endfor %} +{% endif %} +{% if badge_category == "learningpath" %} +{{ call_to_action_label }}: {{ activate_url }} + +Im Anhang dieser Mail findest du neben der Micro-Degree-Datei ein PDF-Zertifikat, das deinen Lernerfolg ebenfalls ausweist. +{% else %} +Im Anhang dieser Mail findest du deine Badge-Datei und das zugehÃļrige PDF-Zertifikat zum Herunterladen. + +Alle deine erhaltenen Badges kannst du auf Open Educational Badges +in deinem Rucksack sammeln und mit anderen teilen. +Lege dafÃŧr einfach kostenlos einen Account an: +{{ activate_url }} +{% endif %} \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_subject.txt b/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_subject.txt new file mode 100644 index 000000000..5abc4a8a3 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_micro_degree_earner_subject.txt @@ -0,0 +1 @@ +Dein Micro Degree \ No newline at end of file diff --git a/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_message.html b/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_message.html new file mode 100644 index 000000000..db3abae1e --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_message.html @@ -0,0 +1,29 @@ +{% extends "email/base.html" %} +{% load static %} +{% load mjml %} +{% mjml %} +{% block main %} + + + + QR-Code-Anfrage fÃŧr deinen Badge + + + Dein Badge {{ badgeclass }} wurde von {{ email }} per QR-Code angefragt. + + + Bitte bestätige die Anfrage auf Open Educational Badges Ãŧber den folgenden Button. + + + +{% endblock main %} +{% block button_text %} + Anfrage bestätigen +{% endblock button_text %} +{% block button_url %} + {{ activate_url }} +{% endblock button_url %} +{% block button_alt_link %} + {{ activate_url }} +{% endblock button_alt_link %} +{% endmjml %} diff --git a/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_message.txt b/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_message.txt new file mode 100644 index 000000000..0f3ea85b7 --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_message.txt @@ -0,0 +1,7 @@ +QR-Code-Anfrage fÃŧr deinen Badge + +Dein Badge {{ badgeclass }} wurde von {{ email }} per QR-Code angefragt. + +Bitte bestätige die Anfrage auf Open Educational Badges Ãŧber den folgenden Button. + +Anfrage bestätigen: {{ activate_url }} diff --git a/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_subject.txt b/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_subject.txt new file mode 100644 index 000000000..e2c4ab1ec --- /dev/null +++ b/apps/mainsite/templates/issuer/email/notify_staff_badge_request_via_qrcode_subject.txt @@ -0,0 +1 @@ +Badge-Anfrage Ãŧber QR-Code \ No newline at end of file diff --git a/apps/mainsite/templates/lti/not_logged_in.html b/apps/mainsite/templates/lti/not_logged_in.html new file mode 100644 index 000000000..54e646e88 --- /dev/null +++ b/apps/mainsite/templates/lti/not_logged_in.html @@ -0,0 +1,9 @@ + + + + Not logged in + + +

    This LTI integration needs a user login

    + + diff --git a/apps/mainsite/templates/lti/user_not_found.html b/apps/mainsite/templates/lti/user_not_found.html new file mode 100644 index 000000000..a7638bd2b --- /dev/null +++ b/apps/mainsite/templates/lti/user_not_found.html @@ -0,0 +1,9 @@ + + + + User not found + + +

    User not found, this LTI integration needs a valid user login

    + + diff --git a/apps/mainsite/templates/public/base.html b/apps/mainsite/templates/public/base.html index 25a61d70e..eaedf625f 100644 --- a/apps/mainsite/templates/public/base.html +++ b/apps/mainsite/templates/public/base.html @@ -1,65 +1,95 @@ -{% load staticfiles %} +{% load static %} - + - - - - {% block page_title %}{% endblock %} - Badgr - - + + + + {% block page_title %}{% endblock %} - Badgr + + - - - + + + - + - {% block extra_head %} - {% endblock %} - + {% block extra_head %} + {% endblock %} + - + + - + +
    + + + + Badgr logo + + + Main Navigation +
    - +
    + {% block content %} + {% endblock %} +
    -
    - - - - Badgr logo - - - Main Navigation -
    + -
    - {% block content %} - {% endblock %} -
    - - - - - -
    + +
    {% block footer_extra_scripts %}{% endblock %} - - + diff --git a/apps/mainsite/templates/rest_framework/api.html b/apps/mainsite/templates/rest_framework/api.html index ec1bff2f1..964f53e14 100644 --- a/apps/mainsite/templates/rest_framework/api.html +++ b/apps/mainsite/templates/rest_framework/api.html @@ -1,33 +1,29 @@ -{% extends "rest_framework/base.html" %} - -{% load staticfiles %} -{% load rest_framework %} - -{% block title %}Badgr REST API{% endblock %} - -{% block meta %} - - - - -{% endblock %} - -{% block navbar %} -
    - - - - Badgr logo - - - {% block userlinks %} - {% if user.is_authenticated %} - {% optional_logout request user %} - {% else %} - {% optional_login request %} - {% endif %} - {% endblock %} -
    +{% extends "rest_framework/base.html" %} {% load static %} {% load +rest_framework %} {% block title %}Badgr REST API{% endblock %} {% block meta %} + + + + +{% endblock %} {% block navbar %} +
    + + + + Badgr logo + + + {% block userlinks %} {% if user.is_authenticated %} + + {% endif %} {% endblock %} +
    {% endblock %} diff --git a/apps/mainsite/testrunner.py b/apps/mainsite/testrunner.py index 820a69f03..322523b6d 100644 --- a/apps/mainsite/testrunner.py +++ b/apps/mainsite/testrunner.py @@ -7,10 +7,13 @@ class BadgrRunner(DiscoverRunner): def run_tests(self, test_labels, extra_tests=None, **kwargs): - if not test_labels and extra_tests is None and 'badgebook' in getattr(settings, 'INSTALLED_APPS', []): - badgebook_suite = self.build_suite(('badgebook',)) + if ( + not test_labels + and extra_tests is None + and "badgebook" in getattr(settings, "INSTALLED_APPS", []) + ): + badgebook_suite = self.build_suite(("badgebook",)) extra_tests = badgebook_suite._tests - return super(BadgrRunner, self).run_tests(test_labels, extra_tests=extra_tests, **kwargs) - - - + return super(BadgrRunner, self).run_tests( + test_labels, extra_tests=extra_tests, **kwargs + ) diff --git a/apps/mainsite/tests/__init__.py b/apps/mainsite/tests/__init__.py index 773cfc466..e69de29bb 100644 --- a/apps/mainsite/tests/__init__.py +++ b/apps/mainsite/tests/__init__.py @@ -1 +0,0 @@ -from .base import * \ No newline at end of file diff --git a/apps/mainsite/tests/base.py b/apps/mainsite/tests/base.py index c4e19dc92..19f280718 100644 --- a/apps/mainsite/tests/base.py +++ b/apps/mainsite/tests/base.py @@ -1,83 +1,42 @@ -# encoding: utf-8 - - from datetime import timedelta import os import random import time - from django.core.cache import cache -from django.core.cache.backends.filebased import FileBasedCache -from django.test import override_settings, TransactionTestCase +from django.test import TransactionTestCase, override_settings from django.utils import timezone -from oauth2_provider.models import Application +from django.core.cache.backends.filebased import FileBasedCache from rest_framework.test import APITransactionTestCase - -from badgeuser.models import BadgeUser, TermsVersion, UserRecipientIdentifier -from issuer.models import Issuer, BadgeClass +from badgeuser.models import BadgeUser, TermsVersion from mainsite import TOP_DIR -from mainsite.models import BadgrApp, ApplicationInfo, AccessTokenProxy - -from openbadges.verifier.openbadges_context import OPENBADGES_CONTEXT_V2_URI - - -class SetupOAuth2ApplicationHelper(object): - def setup_oauth2_application(self, - client_id=None, - client_secret=None, - name='test client app', - allowed_scopes=None, - trust_email=False, - **kwargs): - if client_id is None: - client_id = "test" - if client_secret is None: - client_secret = "secret" - - if 'authorization_grant_type' not in kwargs: - kwargs['authorization_grant_type'] = Application.GRANT_CLIENT_CREDENTIALS - - application = Application.objects.create( - name=name, - client_id=client_id, - client_secret=client_secret, - **kwargs - ) - - if allowed_scopes: - application_info = ApplicationInfo.objects.create( - application=application, - name=name, - allowed_scopes=allowed_scopes, - trust_email_verification=trust_email - ) - - return application +from mainsite.admin import Application +from mainsite.models import AccessTokenProxy, BadgrApp class SetupUserHelper(object): - - def setup_user(self, - email=None, - first_name='firsty', - last_name='lastington', - password='secret', - authenticate=False, - create_email_address=True, - verified=True, - primary=True, - send_confirmation=False, - token_scope=None, - terms_version=1 - ): - + def setup_user( + self, + email=None, + first_name="firsty", + last_name="lastington", + password="secret", + authenticate=False, + create_email_address=True, + verified=True, + primary=True, + send_confirmation=False, + token_scope=None, + terms_version=1, + ): if email is None: - email = 'setup_user_{}@email.test'.format(random.random()) - user = BadgeUser.objects.create(email=email, - first_name=first_name, - last_name=last_name, - create_email_address=create_email_address, - send_confirmation=send_confirmation) + email = "setup_user_{}@email.test".format(random.random()) + user = BadgeUser.objects.create( + email=email, + first_name=first_name, + last_name=last_name, + create_email_address=create_email_address, + send_confirmation=send_confirmation, + ) if terms_version is not None: # ensure there are terms and the user agrees to them to ensure there are no cache misses during tests @@ -86,7 +45,7 @@ def setup_user(self, user.save() if password is None: - user.password = None + user.password = None # type: ignore else: user.set_password(password) user.save() @@ -98,103 +57,36 @@ def setup_user(self, if token_scope: app = Application.objects.create( - client_id='test', client_secret='testsecret', authorization_grant_type='client-credentials', # 'authorization-code' - user=user) + client_id="test", + client_secret="testsecret", + authorization_grant_type="client-credentials", # 'authorization-code' + user=user, + ) token = AccessTokenProxy.objects.create( - user=user, scope=token_scope, expires=timezone.now() + timedelta(hours=1), - token='prettyplease', application=app + user=user, + scope=token_scope, + expires=timezone.now() + timedelta(hours=1), + token="prettyplease", + application=app, ) - self.client.credentials(HTTP_AUTHORIZATION='Bearer {}'.format(token.token)) + self.client.credentials(HTTP_AUTHORIZATION="Bearer {}".format(token.token)) elif authenticate: self.client.force_authenticate(user=user) return user -class SetupIssuerHelper(object): - def setup_issuer(self, - name='Test Issuer', - description='test case Issuer', - owner=None): - issuer = Issuer.objects.create( - name=name, description=description, created_by=owner, email=owner.email, - url='http://example.com' - ) - return issuer - - def get_testfiles_path(self, *args): - return os.path.join(TOP_DIR, 'apps', 'issuer', 'testfiles', *args) - - def get_test_image_path(self): - return os.path.join(self.get_testfiles_path(), 'guinea_pig_testing_badge.png') - - def get_test_image_800x800(self): - return os.path.join(self.get_testfiles_path(), '800x800.png') - - def get_test_png_image_path(self): - return self.get_test_image_path() - - def get_test_png_with_no_extension_image_path(self): - return os.path.join(self.get_testfiles_path(), 'test_badge_png_with_no_extension') - - def get_test_jpeg_image_path(self): - return os.path.join(self.get_testfiles_path(), 'test_jpeg.jpeg') - - def get_test_jpeg_with_no_extension_image_path(self): - return os.path.join(self.get_testfiles_path(), 'test_jpeg_no_extension') - - def get_hacked_svg_image_path(self): - return os.path.join(self.get_testfiles_path(), 'hacked-svg-with-embedded-script-tags.svg') - - def get_test_image_data_uri(self): - return os.path.join(self.get_testfiles_path(), 'test_image_data_uri') - - def get_test_svg_image_path(self): - return os.path.join(self.get_testfiles_path(), 'test_badgeclass.svg') - - def get_test_svg_with_no_extension_image_path(self): - return os.path.join(self.get_testfiles_path(), 'test_badgeclass_with_no_svg_extension') - - def setup_badgeclass(self, - issuer, - name=None, - image=None, - description='test case badgeclass', - criteria_text='do something', - criteria_url=None): - - if name is None: - name = 'Test Badgeclass #{}'.format(random.random) - - if image is None: - image = open(self.get_test_image_path(), 'rb') - - badgeclass = BadgeClass.objects.create( - issuer=issuer, - image=image, - name=name, - description=description, - criteria_text=criteria_text, - criteria_url=criteria_url - ) - return badgeclass - - def setup_badgeclasses(self, how_many=3, **kwargs): - for i in range(0, how_many): - yield self.setup_badgeclass(**kwargs) - - @override_settings( CACHES={ - 'default': { - 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - 'LOCATION': os.path.join(TOP_DIR, 'test.cache'), + "default": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": os.path.join(TOP_DIR, "test.cache"), } }, ) class CachingTestCase(TransactionTestCase): @classmethod def tearDownClass(cls): - test_cache = FileBasedCache(os.path.join(TOP_DIR, 'test.cache'), {}) + test_cache = FileBasedCache(os.path.join(TOP_DIR, "test.cache"), {}) test_cache.clear() def setUp(self): @@ -205,8 +97,8 @@ def setUp(self): @override_settings( CELERY_ALWAYS_EAGER=True, - SESSION_ENGINE='django.contrib.sessions.backends.cache', - HTTP_ORIGIN="http://localhost:8000" + SESSION_ENGINE="django.contrib.sessions.backends.cache", + HTTP_ORIGIN="http://localhost:8000", ) class BadgrTestCase(SetupUserHelper, APITransactionTestCase, CachingTestCase): def setUp(self): @@ -216,75 +108,5 @@ def setUp(self): self.badgr_app = BadgrApp.objects.get(is_default=True) except BadgrApp.DoesNotExist: self.badgr_app = BadgrApp.objects.create( - is_default=True, - name='test cors', - cors='localhost:8000' + is_default=True, name="test cors", cors="localhost:8000" ) - - -class Ob2Generators(object): - def generate_issuer_obo2(self, **kwargs): - data = { - '@context': OPENBADGES_CONTEXT_V2_URI, - 'id': 'https://example.com/issuer/1', - 'type': 'Issuer', - 'name': 'Basic Issuer', - 'url': 'http://a.com/issuer/website' - } - data.update(kwargs) - return data - - def generate_badgeclass_ob2(self, **kwargs): - data = { - '@context': OPENBADGES_CONTEXT_V2_URI, - 'id': 'https://example.com/badgeclass/1', - 'type': 'BadgeClass', - 'name': 'Embedded badgeclass', - 'criteria': { - 'narrative': 'do it' - }, - 'image': 'http://example.com/badgeclass/1/image', - 'description': 'a beautiful bespoke badgeclass', - 'issuer': 'https://example.com/issuer/1' - } - data.update(kwargs) - return data - - def generate_assertion_ob2(self, **kwargs): - data = { - '@context': OPENBADGES_CONTEXT_V2_URI, - 'id': 'https://example.com/assertion/1', - 'type': 'Assertion', - 'issuedOn': '2017-06-29T21:50:14+00:00', - 'recipient': { - 'type': 'email', - 'hashed': False, - 'identity': 'test@example.com' - }, - 'verification': { - 'type': 'HostedBadge' - }, - 'badge': 'https://example.com/badgeclass/1', - } - data.update(kwargs) - return data - - def generate_ob2_report(self, **kwargs): - data = { - 'messages': [], - "warningCount": 0, - "valid": True, - "openBadgesVersion": "2.0", - "validationSubject": "https://example.com/badgeclass/1", - "errorCount": 0 - } - data.update(kwargs) - return data - - def generate_ob2_input(self, **kwargs): - data = { - "input_type": "url", - "value": "https://example.com/badgeclass/1" - } - data.update(kwargs) - return data diff --git a/apps/mainsite/tests/test_api_throttle.py b/apps/mainsite/tests/test_api_throttle.py deleted file mode 100644 index d9ac57239..000000000 --- a/apps/mainsite/tests/test_api_throttle.py +++ /dev/null @@ -1,70 +0,0 @@ -from django.test import override_settings -from django.utils import timezone -from django.utils.timezone import timedelta - -from mainsite.tests.base import BadgrTestCase -from mainsite.utils import (_expunge_stale_backoffs, clamped_backoff_in_seconds, clear_backoff_count_for_ip, - iterate_backoff_count,) - -SETTINGS_OVERRIDE = { - 'TOKEN_BACKOFF_MAXIMUM_SECONDS': 3600, - 'TOKEN_BACKOFF_PERIOD_SECONDS': 2 -} - - -class BackoffTests(BadgrTestCase): - @override_settings(**SETTINGS_OVERRIDE) - def test_clamped_backoff(self): - for m, n in [ - (0, 1), - (1, 2), - (2, 4), - (3, 8), - (4, 16), - (5, 32), - (6, 64), - (7, 128), - (8, 256), - (9, 512), - (10, 1024), - (11, 2048), - (12, 3600), - (13, 3600), - (400, 3600), - ]: - backoff = clamped_backoff_in_seconds(m) - self.assertEqual(backoff, n, "For count {}, backoff should = {} seconds, not {}".format(m, n, backoff)) - - @override_settings(**SETTINGS_OVERRIDE) - def test_iterate_backoff_count(self): - ip1 = '1.2.3.4' - backoff = iterate_backoff_count(None, ip1) - - new_backoff = iterate_backoff_count(backoff, ip1) - self.assertEqual(new_backoff[ip1]['count'], 2) - - def test_backoff_manipulation_for_client_ip(self): - ip1 = '1.2.3.4' - ip2 = '4.5.6.7' - backoff = iterate_backoff_count(None, ip1) - self.assertEqual(backoff[ip1]['count'], 1) - - backoff = iterate_backoff_count(backoff, ip1) - backoff = iterate_backoff_count(backoff, ip2) - self.assertEqual(backoff[ip1]['count'], 2) - self.assertEqual(backoff[ip2]['count'], 1) - - backoff = clear_backoff_count_for_ip(backoff, ip1) - self.assertIsNone(backoff.get(ip1)) - self.assertEqual(backoff[ip2]['count'], 1) - - def test_backoff_expunging_past_entries(self): - ip1 = '1.2.3.4' - ip2 = '4.5.6.7' - backoff = iterate_backoff_count(None, ip1) - backoff = iterate_backoff_count(backoff, ip2) - backoff = iterate_backoff_count(backoff, ip2) - backoff[ip2]['until'] = timezone.now() - timedelta(hours=2) - - backoff = iterate_backoff_count(backoff, ip1) - self.assertIsNone(backoff.get(ip2)) # The expired "until" has been expunged diff --git a/apps/mainsite/tests/test_badgrapp.py b/apps/mainsite/tests/test_badgrapp.py deleted file mode 100644 index 439a565df..000000000 --- a/apps/mainsite/tests/test_badgrapp.py +++ /dev/null @@ -1,110 +0,0 @@ -from mainsite.models import BadgrApp - -from mainsite.tests.base import APITransactionTestCase, CachingTestCase, SetupIssuerHelper, SetupUserHelper - - - -class TestBadgrApp(SetupUserHelper, SetupIssuerHelper, APITransactionTestCase, CachingTestCase): - def test_badgr_app_unique_default(self): - ba_one = BadgrApp.objects.create( - cors='one.example.com', - signup_redirect='https://one.example.com/start' - ) - ba_two = BadgrApp.objects.create( - cors='two.example.com', - signup_redirect='https://two.example.com/start' - ) - - self.assertTrue(ba_one.is_default, "The first BadgrApp created is going to be the default one") - - ba_two.is_default = True - ba_two.save() - - self.assertTrue(ba_two.is_default) - ba_one = BadgrApp.objects.get(pk=ba_one.pk) # re-fetch from database - self.assertFalse(ba_one.is_default, "After setting #2 to default, #1 is no longer default.") - - def test_badgr_app_default_settings_population(self): - ba_one = BadgrApp.objects.create( - cors='one.example.com', - signup_redirect='https://one.example.com/start' - ) - self.assertEqual(ba_one.ui_login_redirect, ba_one.signup_redirect) - - def test_default_badgrapp(self): - ba_one = BadgrApp.objects.create( - cors='one.example.com', - signup_redirect='https://one.example.com/start' - ) - ba_two = BadgrApp.objects.create( - cors='two.example.com', - signup_redirect='https://two.example.com/start' - ) - - self.assertEqual(BadgrApp.objects.get_current(None).id, ba_one.id) - self.assertEqual(BadgrApp.objects.get_by_id_or_default(ba_two.id).id, ba_two.id) - - def test_get_current_autocreates_first_app(self): - self.assertEqual(BadgrApp.objects.count(), 0) - app = BadgrApp.objects.get_current(None) - self.assertEqual(app.cors, 'localhost:4200') - self.assertEqual(BadgrApp.objects.count(), 1) - - def test_get_by_id_or_default_autocreates(self): - self.assertEqual(BadgrApp.objects.count(), 0) - app = BadgrApp.objects.get_by_id_or_default() - self.assertEqual(app.cors, 'localhost:4200') - self.assertEqual(BadgrApp.objects.count(), 1) - - app_again = BadgrApp.objects.get_by_id_or_default(str(app.id)) - self.assertEqual(app_again.id, app.id) - - def test_get_by_id_multiple_objects_failsafe(self): - ba_one = BadgrApp.objects.create( - cors='one.example.com', - signup_redirect='https://one.example.com/start' - ) - ba_two = BadgrApp.objects.create( - cors='two.example.com', - signup_redirect='https://two.example.com/start' - ) - - # Dangerous updating - BadgrApp.objects.all().update(is_default=True) - self.assertEqual(BadgrApp.objects.filter(is_default=True).count(), 2) - app = BadgrApp.objects.get_by_id_or_default() - self.assertEqual(app.id, ba_one.id) - self.assertEqual(BadgrApp.objects.filter(is_default=True).count(), 1) - - def test_get_by_id_stupid_datatypes(self): - # Test that autocreation fallback replaces deleted BadgrApp - for val in ['stupid', 0.5, '20']: - app = BadgrApp.objects.get_by_id_or_default(val) - self.assertEqual(BadgrApp.objects.count(), 1) - self.assertEqual(app.cors, 'localhost:4200') - app.delete() - - def test_get_by_id_or_pk(self): - ba_one = BadgrApp.objects.get_by_id_or_default() - ba_two = BadgrApp.objects.create( - name='The Original and Best', - cors='one.example.com', - signup_redirect='https://one.example.com/start', - is_default=False - ) - - user = self.setup_user() - issuer = self.setup_issuer(owner=user) - issuer.badgrapp = ba_two - issuer.save() - - cached_badgrapp_a = issuer.cached_badgrapp - - ba_two.name = "A Pale Imitation" - ba_two.save() - - cached_badgrapp_b = issuer.cached_badgrapp - cached_badgrapp_c = BadgrApp.objects.get_by_id_or_default(ba_two.id) - - self.assertNotEqual(cached_badgrapp_a.name, cached_badgrapp_b.name) # issuer.cached_badgrapp should have updated - self.assertEqual(cached_badgrapp_b.name, cached_badgrapp_c.name) diff --git a/apps/mainsite/tests/test_misc.py b/apps/mainsite/tests/test_misc.py deleted file mode 100644 index c495adcdd..000000000 --- a/apps/mainsite/tests/test_misc.py +++ /dev/null @@ -1,585 +0,0 @@ -import hashlib -from hashlib import sha256 -import mock -from PIL import Image -from operator import attrgetter -import os -import pytz -import re -import responses -import shutil -import urllib.request, urllib.parse, urllib.error -import urllib.parse -import warnings - -from django.conf import settings -from django.core import mail -from django.core.cache import cache, CacheKeyWarning -from django.core.exceptions import ValidationError -from django.core.files.storage import default_storage -from django.core.management import call_command -from django.test import override_settings, TransactionTestCase -from django.utils import timezone - -from rest_framework import serializers -from rest_framework.exceptions import UnsupportedMediaType -from oauth2_provider.models import AccessToken, Application - -from badgeuser.models import BadgeUser, CachedEmailAddress -from issuer.models import BadgeClass, Issuer, BadgeInstance -from mainsite.models import BadgrApp, AccessTokenProxy, AccessTokenScope -from mainsite import TOP_DIR, blacklist -from mainsite.serializers import DateTimeWithUtcZAtEndField -from mainsite.tests import SetupIssuerHelper -from mainsite.tests.base import BadgrTestCase -from mainsite.utils import fetch_remote_file_to_storage, verify_svg - - -class TestDateSerialization(BadgrTestCase): - class TestSerializer(serializers.Serializer): - the_date = DateTimeWithUtcZAtEndField(source="date_field") - - class TestHolder(object): - date_field = None - - def __init__(self, date_field): - self.date_field = date_field - - def test_date_serialization(self): - utc_date = self.TestHolder(timezone.datetime(2019, 12, 6, 12, 0, tzinfo=pytz.utc)) - la_date = self.TestHolder(pytz.timezone('America/Los_Angeles').localize(timezone.datetime(2019, 12, 6, 12, 0))) # -8 hours - ny_date = self.TestHolder(pytz.timezone('America/New_York').localize(timezone.datetime(2019, 12, 6, 12, 0))) # -5 hours - - utc_serializer = self.TestSerializer(utc_date) - la_serializer = self.TestSerializer(la_date) - ny_serializer = self.TestSerializer(ny_date) - - self.assertEqual(utc_serializer.data['the_date'], '2019-12-06T12:00:00Z') - self.assertEqual(la_serializer.data['the_date'], '2019-12-06T20:00:00Z') - self.assertEqual(ny_serializer.data['the_date'], '2019-12-06T17:00:00Z') - - -class TestTokenDenorm(BadgrTestCase, SetupIssuerHelper): - def test_scopes_created(self): - self.setup_user(email="foo@bar.com", authenticate=True, token_scope="rw:backpack r:profile") - self.assertEqual(2, AccessTokenScope.objects.all().count()) - - def test_library_access_token_scope_denormalization(self): - # Creating an AccessToken (library model) results in correct scopes - scope_string = 'foo bar' - scopes = sorted(scope_string.split(' ')) - app = Application.objects.create(client_id = "app",client_type = "public",authorization_grant_type = "implicit",) - token = AccessToken.objects.create( - application=app, - scope=scope_string, - expires=timezone.now() + timezone.timedelta(hours=1)) - qs = AccessTokenScope.objects.filter(token=token).order_by('scope') - self.assertQuerysetEqual(qs, scopes, attrgetter('scope')) - - def test_access_token_proxy_scope_denormalization(self): - # Creating an AccessTokenProxy (our model) results in correct scopes - scope_string = 'badgr is great' - scopes = sorted(scope_string.split(' ')) - app = Application.objects.create(client_id="app", client_type="public", authorization_grant_type="implicit", ) - proxy_token = AccessTokenProxy.objects.create( - application=app, - scope=scope_string, - expires=timezone.now() + timezone.timedelta(hours=1)) - qs = AccessTokenScope.objects.filter(token=proxy_token).order_by('scope') - self.assertQuerysetEqual(qs, scopes, attrgetter('scope')) - - -class TestCacheSettings(TransactionTestCase): - - def test_long_cache_keys_shortened(self): - cache_settings = { - 'default': { - 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - 'LOCATION': os.path.join(TOP_DIR, 'test.cache'), - } - } - long_key_string = "X" * 251 - - with override_settings(CACHES=cache_settings): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - # memcached limits key length to 250 - cache.set(long_key_string, "hello cached world") - - self.assertEqual(len(w), 1) - self.assertIsInstance(w[0].message, CacheKeyWarning) - - # Activate optional cache key length checker - cache_settings['default']['KEY_FUNCTION'] = 'mainsite.utils.filter_cache_key' - - with override_settings(CACHES=cache_settings): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - # memcached limits key length to 250 - cache.set(long_key_string, "hello cached world") - - self.assertEqual(len(w), 0) - - retrieved = cache.get(long_key_string) - - self.assertEqual(retrieved, "hello cached world") - - -class TestUtils(BadgrTestCase, SetupIssuerHelper): - def test_svg_verify(self): - with open(self.get_test_svg_image_path(), 'rb') as svg_badge_image: - self.assertTrue(verify_svg(svg_badge_image)) - - -@override_settings( - HTTP_ORIGIN='http://api.testserver', - ACCOUNT_EMAIL_CONFIRMATION_HMAC=True -) -class TestSignup(BadgrTestCase): - def test_user_signup_email_confirmation_redirect(self): - from django.conf import settings - http_origin = getattr(settings, 'HTTP_ORIGIN') - badgr_app = BadgrApp( - cors='frontend.ui', - email_confirmation_redirect='http://frontend.ui/login/', - forgot_password_redirect='http://frontend.ui/forgot-password/', - is_default=True - ) - badgr_app.save() - - post_data = { - 'first_name': 'Tester', - 'last_name': 'McSteve', - 'email': 'test12345@example.com', - 'password': 'secr3t4nds3cur3' - } - response = self.client.post('/v1/user/profile', post_data) - self.assertEqual(response.status_code, 201) - - user = BadgeUser.objects.get(entity_id=response.data.get('slug')) - - self.assertEqual(len(mail.outbox), 1) - url_match = re.search(r'{}(/v1/user/confirmemail.*)'.format(http_origin), mail.outbox[0].body) - self.assertIsNotNone(url_match) - confirm_url = url_match.group(1) - - expected_redirect_url = '{badgrapp_redirect}{first_name}?authToken={auth}&email={email}'.format( - badgrapp_redirect=badgr_app.email_confirmation_redirect, - first_name=post_data['first_name'], - email=urllib.parse.quote(post_data['email']), - auth=user.auth_token - ) - - response = self.client.get(confirm_url, follow=False) - self.assertEqual(response.status_code, 302) - - actual = urllib.parse.urlparse(response.get('location')) - expected = urllib.parse.urlparse(expected_redirect_url) - self.assertEqual(actual.netloc, expected.netloc) - self.assertEqual(actual.scheme, expected.scheme) - - actual_query = urllib.parse.parse_qs(actual.query) - expected_query = urllib.parse.parse_qs(expected.query) - self.assertEqual(actual_query.get('email'), expected_query.get('email')) - self.assertIsNotNone(actual_query.get('authToken')) - - -@override_settings( - ACCOUNT_EMAIL_CONFIRMATION_HMAC=False -) -class TestEmailCleanupCommand(BadgrTestCase): - def test_email_added_for_user_missing_one(self): - user = BadgeUser(email="newtest@example.com", first_name="Test", last_name="User") - user.save() - self.assertFalse(CachedEmailAddress.objects.filter(user=user).exists()) - - user2 = BadgeUser(email="newtest2@example.com", first_name="Test2", last_name="User") - user2.save() - email2 = CachedEmailAddress(user=user2, email="newtest2@example.com", verified=False, primary=True) - email2.save() - - call_command('clean_email_records') - - email_record = CachedEmailAddress.objects.get(user=user) - self.assertFalse(email_record.verified) - self.assertTrue(email_record.emailconfirmation_set.exists()) - self.assertEqual(len(mail.outbox), 1) - - def test_unverified_unprimary_email_sends_confirmation(self): - """ - If there is only one email, and it's not primary, set it as primary. - If it's not verified, send a verification. - """ - user = BadgeUser(email="newtest@example.com", first_name="Test", last_name="User") - user.save() - email = CachedEmailAddress(email=user.email, user=user, verified=False, primary=False) - email.save() - - user2 = BadgeUser(email="newtest@example.com", first_name="Error", last_name="User") - user2.save() - - self.assertEqual(BadgeUser.objects.count(), 2) - - call_command('clean_email_records') - - email_record = CachedEmailAddress.objects.get(user=user) - self.assertTrue(email_record.primary) - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(BadgeUser.objects.count(), 1) - - -class TestBlacklist(BadgrTestCase): - def setUp(self): - super(TestBlacklist, self).setUp() - self.user, _ = BadgeUser.objects.get_or_create(email='test@example.com') - self.cached_email, _ = CachedEmailAddress.objects.get_or_create(user=self.user, email='test@example.com', verified=True, primary=True) - self.issuer = Issuer.objects.create( - name="Open Badges", - created_at="2015-12-15T15:55:51Z", - created_by=None, - slug="open-badges", - source_url="http://badger.openbadges.org/program/meta/bda68a0b505bc0c7cf21bc7900280ee74845f693", - source="test-fixture", - image="" - ) - - self.badge_class = BadgeClass.objects.create( - name="MozFest Reveler", - created_at="2015-12-15T15:55:51Z", - created_by=None, - slug="mozfest-reveler", - criteria_text=None, - source_url="http://badger.openbadges.org/badge/meta/mozfest-reveler", - source="test-fixture", - image="", - issuer=self.issuer - ) - - Inputs = [('email', 'test@example.com'), - ('url', 'http://example.com'), - ('telephone', '+16175551212'), - ] - - @override_settings( - BADGR_BLACKLIST_API_KEY='123', - BADGR_BLACKLIST_QUERY_ENDPOINT='http://example.com', - ) - @responses.activate - def test_blacklist_api_query_is_in_blacklist(self): - id_type, id_value = self.Inputs[0] - - responses.add( - responses.GET, 'http://example.com?id='+blacklist.generate_hash(id_type, id_value), - body="{\"msg\": \"ok\"}", status=200 - ) - - in_blacklist = blacklist.api_query_is_in_blacklist(id_type, id_value) - self.assertTrue(in_blacklist) - - @override_settings( - BADGR_BLACKLIST_API_KEY='123', - BADGR_BLACKLIST_QUERY_ENDPOINT='http://example.com', - ) - @responses.activate - def test_blacklist_assertion_to_recipient_in_blacklist(self): - id_type, id_value = self.Inputs[0] - with mock.patch('mainsite.blacklist.api_query_is_in_blacklist', new=lambda a, b: True): - with self.assertRaises(ValidationError): - BadgeInstance.objects.create( - recipient_identifier="test@example.com", - badgeclass=self.badge_class, - issuer=self.issuer, - image="uploads/badges/local_badgeinstance_174e70bf-b7a8-4b71-8125-c34d1a994a7c.png", - acceptance=BadgeInstance.ACCEPTANCE_ACCEPTED - ) - self.assertIsNone(BadgeInstance.objects.first()) - - @override_settings( - BADGR_BLACKLIST_API_KEY='123', - BADGR_BLACKLIST_QUERY_ENDPOINT='http://example.com', - ) - @responses.activate - def test_blacklist_api_query_is_in_blacklist_false(self): - id_type, id_value = self.Inputs[1] - - responses.add( - responses.GET, 'http://example.com?id='+blacklist.generate_hash(id_type, id_value), - body="{\"msg\": \"no\"}", status=404 - ) - - in_blacklist = blacklist.api_query_is_in_blacklist(id_type, id_value) - self.assertFalse(in_blacklist) - - @override_settings( - BADGR_BLACKLIST_API_KEY='123', - BADGR_BLACKLIST_QUERY_ENDPOINT='http://example.com', - ) - def test_blacklist_not_configured_throws_exception(self): - id_type, id_value = self.Inputs[1] - with mock.patch('mainsite.blacklist.api_query_recipient_id', new=lambda a, b, c, d: None): - with self.assertRaises(Exception): - blacklist.api_query_is_in_blacklist(id_type, id_value) - - @override_settings( - BADGR_BLACKLIST_API_KEY='123', - BADGR_BLACKLIST_QUERY_ENDPOINT='http://example.com', - ) - def test_blacklistgenerate_hash(self): - # The generate_hash function implementation should not change; We risk contacting people on the blacklist - for (id_type, id_value) in self.Inputs: - got = blacklist.generate_hash(id_type, id_value) - expected = "{id_type}$sha256${hash}".format(id_type=id_type, hash=sha256(id_value.encode('utf-8')).hexdigest()) - self.assertEqual(got, expected) - - -class TestRemoteFileToStorage(SetupIssuerHelper, BadgrTestCase): - mime_types = ['image/png', 'image/svg+xml', 'image/jpeg'] - test_uploaded_path = os.path.join('testfiles') - path_to_mediafiles = getattr(settings, 'MEDIA_ROOT') - test_url = 'http://example.com/123abc' - - def tearDown(self): - dir = os.path.join('{base_url}/{upload_to}/cached/'.format( - base_url=default_storage.location, - upload_to=self.test_uploaded_path - )) - - try: - shutil.rmtree(dir) - except OSError as e: - print(("%s does not exist and was not deleted" % 'me')) - - def mimic_hashed_file_name(self, name, ext=''): - return hashlib.md5(name.encode('utf-8')).hexdigest() + ext - - @responses.activate - def test_remote_url_is_data_uri(self): - data_uri_as_url = open(self.get_test_image_data_uri()).read() - status_code, storage_name = fetch_remote_file_to_storage( - data_uri_as_url, - upload_to=self.test_uploaded_path, - allowed_mime_types=self.mime_types - ) - - self.assertEqual(status_code, 200) - - @responses.activate - def test_svg_without_extension(self): - expected_extension = '.svg' - expected_file_name = self.mimic_hashed_file_name(self.test_url, expected_extension) - - responses.add( - responses.GET, - self.test_url, - body=open(self.get_hacked_svg_image_path(), 'rb').read(), - status=200 - ) - - status_code, storage_name = fetch_remote_file_to_storage( - self.test_url, - upload_to=self.test_uploaded_path, - allowed_mime_types=self.mime_types - ) - - self.assertTrue(storage_name.endswith(expected_extension)) - self.assertTrue(default_storage.size(storage_name) > 0) - - @responses.activate - def test_svg_with_extension(self): - expected_extension = '.svg' - - responses.add( - responses.GET, - self.test_url, - body=open(self.get_test_svg_image_path(), 'rb').read(), - status=200 - ) - - status_code, storage_name = fetch_remote_file_to_storage( - self.test_url, - upload_to=self.test_uploaded_path, - allowed_mime_types=self.mime_types - ) - - self.assertTrue(storage_name.endswith(expected_extension)) - self.assertTrue(default_storage.size(storage_name) > 0) - - @responses.activate - def test_scrubs_hacked_svg(self): - hacked_svg = open(self.get_hacked_svg_image_path(), 'rb').read() - - responses.add( - responses.GET, - self.test_url, - body=hacked_svg, - status=200 - ) - - status_code, storage_name = fetch_remote_file_to_storage( - self.test_url, - upload_to=self.test_uploaded_path, - allowed_mime_types=self.mime_types - ) - - saved_svg_path = os.path.join('{base_url}/{file_name}'.format( - base_url= default_storage.location, - file_name=storage_name) - ) - - saved_svg = open(saved_svg_path, 'rb').read() - - self.assertNotIn(b'onload', saved_svg) - self.assertNotIn(b'