Merge pull request #4896 from mozilla/zendesk

This commit is contained in:
Leo McArdle 2021-09-14 13:39:45 +01:00 коммит произвёл GitHub
Родитель 7c783738a1 70f523b966
Коммит ed0f1c1851
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
27 изменённых файлов: 602 добавлений и 182 удалений

Просмотреть файл

@ -30,6 +30,7 @@ Part 2: Developer's Guide
search
frontend
browser_permissions
zendesk
notes

19
docs/zendesk.md Normal file
Просмотреть файл

@ -0,0 +1,19 @@
# Zendesk integration
## Using `requests` to query the API
During development being able to query the API manually to fetch details about field IDs,
user statuses,
and so on is very useful.
To do so use a snippet of code like the following in `./manage.py shell_plus`:
```python
import requests
base = f"https://{settings.ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/"
auth = requests.auth.HTTPBasicAuth(settings.ZENDESK_USER_EMAIL+"/token", settings.ZENDESK_API_TOKEN)
requests.get(base+"foobar", auth=auth).json()
requests.post(base+"barfoo", auth=auth, json={}).json()
```

Просмотреть файл

@ -3,3 +3,6 @@ from django.apps import AppConfig
class CustomerCareConfig(AppConfig):
name = "kitsune.customercare"
def ready(self):
from kitsune.customercare import signals # noqa

Просмотреть файл

@ -0,0 +1,28 @@
from django import forms
from django.utils.translation import ugettext_lazy as _lazy
from kitsune.customercare.zendesk import ZendeskClient, CATEGORY_CHOICES, OS_CHOICES
class ZendeskForm(forms.Form):
"""Form for submitting a ticket to Zendesk."""
product = forms.CharField(disabled=True, widget=forms.HiddenInput)
category = forms.ChoiceField(
label=_lazy("What do you need help with?"), choices=CATEGORY_CHOICES
)
os = forms.ChoiceField(
label=_lazy("What operating system does your device use?"),
choices=OS_CHOICES,
required=False,
)
subject = forms.CharField(label=_lazy("Subject"), required=False)
description = forms.CharField(label=_lazy("Description of issue"), widget=forms.Textarea())
def __init__(self, *args, product, **kwargs):
kwargs.update({"initial": {"product": product.slug}})
super().__init__(*args, **kwargs)
def send(self, user):
client = ZendeskClient()
return client.create_ticket(user, **self.cleaned_data)

Просмотреть файл

@ -0,0 +1,35 @@
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
from kitsune.customercare.tasks import update_zendesk_user
from kitsune.users.models import Profile
@receiver(
post_save, sender=User, dispatch_uid="customercare.signals.on_save_update_zendesk_user.User"
)
@receiver(
post_save,
sender=Profile,
dispatch_uid="customercare.signals.on_save_update_zendesk_user.Profile",
)
def on_save_update_zendesk_user(sender, instance, update_fields=None, **kwargs):
# TODO: dedupe signals, so calling
# ```
# user.profile.save()
# user.save()
# ```
# doesn't update the user in zendesk twice
user = instance
if sender == Profile:
user = instance.user
if update_fields and len(update_fields) == 1 and "zendesk_id" in update_fields:
# do nothing if the only thing updated is the zendesk_id
return
try:
if user.profile.zendesk_id:
update_zendesk_user.delay(user.pk)
except Profile.DoesNotExist:
pass

Просмотреть файл

@ -0,0 +1,23 @@
from celery import task
from django.contrib.auth.models import User
from kitsune.customercare.zendesk import ZendeskClient
@task
def update_zendesk_user(user_id: int) -> None:
user = User.objects.get(pk=user_id)
if user.profile.zendesk_id:
zendesk = ZendeskClient()
zendesk.update_user(user)
@task
def update_zendesk_identity(user_id: int, email: str) -> None:
user = User.objects.get(pk=user_id)
zendesk_user_id = user.profile.zendesk_id
# fetch identity id
if zendesk_user_id:
zendesk = ZendeskClient()
zendesk.update_primary_email(zendesk_user_id, email)

Просмотреть файл

@ -0,0 +1,109 @@
from django.conf import settings
from django.utils.translation import ugettext_lazy as _lazy
from zenpy import Zenpy
from zenpy.lib.api_objects import Identity as ZendeskIdentity
from zenpy.lib.api_objects import Ticket
from zenpy.lib.api_objects import User as ZendeskUser
TICKET_FORM_ID = 360000417171
# See docs/zendesk.md for details about getting the valid choice values for each field:
PRODUCT_FIELD_ID = 360047198211
CATEGORY_FIELD_ID = 360047206172
CATEGORY_CHOICES = [
(None, _lazy("Select a topic")),
("technical", _lazy("Technical")),
("accounts", _lazy("Accounts & Login")),
("payments", _lazy("Payment & Billing")),
("troubleshooting", _lazy("Troubleshooting")),
]
OS_FIELD_ID = 360018604871
OS_CHOICES = [
(None, _lazy("Select platform")),
("win10", _lazy("Windows")),
("android", _lazy("Android")),
("linux", _lazy("Linux")),
("mac", _lazy("Mac OS")),
("win8", _lazy("Windows 8")),
]
class ZendeskClient(object):
"""Client to connect to Zendesk API."""
def __init__(self, **kwargs):
"""Initialize Zendesk API client."""
creds = {
"email": settings.ZENDESK_USER_EMAIL,
"token": settings.ZENDESK_API_TOKEN,
"subdomain": settings.ZENDESK_SUBDOMAIN,
}
self.client = Zenpy(**creds)
def _user_to_zendesk_user(self, user, include_email=True):
fxa_uid = user.profile.fxa_uid
id_str = user.profile.zendesk_id
return ZendeskUser(
id=int(id_str) if id_str else None,
verified=True,
email=user.email if include_email else "",
name=user.profile.display_name,
locale=user.profile.locale,
user_fields={"user_id": fxa_uid},
external_id=fxa_uid,
)
def create_user(self, user):
"""Given a Django user, create a user in Zendesk."""
zendesk_user = self._user_to_zendesk_user(user)
# call create_or_update to avoid duplicating users FxA previously created
zendesk_user = self.client.users.create_or_update(zendesk_user)
user.profile.zendesk_id = str(zendesk_user.id)
user.profile.save(update_fields=["zendesk_id"])
return zendesk_user
def update_user(self, user):
"""Given a Django user, update a user in Zendesk."""
zendesk_user = self._user_to_zendesk_user(user, include_email=False)
zendesk_user = self.client.users.update(zendesk_user)
return zendesk_user
def get_primary_email_identity(self, zendesk_user_id):
"""Fetch the identity with the primary email from Zendesk"""
for identity in self.client.users.identities(id=zendesk_user_id):
if identity.primary and identity.type == "email":
return identity.id
def update_primary_email(self, zendesk_user_id, email):
"""Update the primary email of the user."""
identity_id = self.get_primary_email_identity(zendesk_user_id)
self.client.users.identities.update(
user=zendesk_user_id, identity=ZendeskIdentity(id=identity_id, value=email)
)
def create_ticket(
self, user, subject="", description="", product="", category="", os="", **kwargs
):
"""Create a ticket in Zendesk."""
ticket = Ticket(
subject=subject,
comment={"body": description},
ticket_form_id=TICKET_FORM_ID,
custom_fields=[
{"id": PRODUCT_FIELD_ID, "value": product},
{"id": CATEGORY_FIELD_ID, "value": category},
{"id": OS_FIELD_ID, "value": os},
],
)
if user.profile.zendesk_id:
# TODO: is this necessary if we're updating users as soon as they're updated locally?
ticket.requester_id = self.update_user(user).id
else:
ticket.requester_id = self.create_user(user).id
return self.client.tickets.create(ticket)

Просмотреть файл

@ -24,10 +24,10 @@
</div>
{%- endmacro %}
{% macro topic_metadata(topics, product_key=None) %}
{% macro topic_metadata(topics, product=None, product_key=None) %}
{% if not settings.READ_ONLY %}
<section class="support-callouts mzp-l-content sumo-page-section--inner">
<div class="card card--ribbon is-inverse">
<div class="card card--ribbon is-inverse heading-is-one-line">
<div class="card--details">
<h3 class="card--title">
<svg class="card--icon-sm" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@ -41,43 +41,29 @@
{{ _('Still need help?') }}
</h3>
<p class="card--desc">{{ _('Were here for you. Post a question to our support forums and get answers from our community of experts.') }}</p>
<p class="card--desc">
{% if product and product.has_subscriptions %}
{{ _('Were here for you. Send a message to our support team and we\'ll be glad to help.') }}
{% else %}
{{ _('Were here for you. Post a question to our support forums and get answers from our community of experts.') }}
{% endif %}
</p>
{% if product_key %}
{% set aaq_url=url('questions.aaq_step2', product_key=product_key) %}
{% else %}
{% set aaq_url=url('questions.aaq_step1') %}
{% endif %}
<a class="sumo-button primary-button button-lg"
href={{ aaq_url }} data-event-label="Get community support">
{{ _('Ask the Community') }}
href={{ aaq_url }} data-event-label="Contact support">
{% if product and product.has_subscriptions %}
{{ _('Contact Support') }}
{% else %}
{{ _('Ask the Community') }}
{% endif %}
</a>
</div>
</div>
</section>
{% if product.id in subscribed_products_ids %}
<div class="mzp-l-content sumo-page-section--inner text-center">
<h2 class="sumo-page-subheading">
<svg width="24px" height="24px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.44 19.5">
<path d="M18.88,0H19c.5.1.6.5.3,1.1L11.67,18.8c-.2.4-.39.7-.8.7-.2,0-.3,0-.4-.1s-.3-.2-.39-.4l-3-4.9c-.1-.2-.3-.4-.4-.6l-.6-.6c-.2-.2-.4-.3-.6-.5l-5-3.1a1.6,1.6,0,0,1-.4-.5.58.58,0,0,1,0-.6,1,1,0,0,1,.59-.5L18.38.2a1.59,1.59,0,0,1,.5-.2ZM6.47,10.9a2.65,2.65,0,0,1,.6.5l7.71-7.9-12,5.2,3.69,2.2ZM16,4.8l-7.7,7.8.1.1c.1.1.1.2.2.3s.1.2.2.3L11,16.8C10.88,16.7,16,4.8,16,4.8Z"/>
</svg>
{{ _('Contact Us') }}
</h2>
<p class="sumo-page-intro">{{ _('Contact the Support Team for help.') }}</p>
<a
class="sumo-button primary-button button-lg"
href="{{ settings.FXA_SUPPORT_FORM }}"
data-event-category="link click"
data-event-action="topic"
data-event-label="Contact Support">
{{ _('Ask a Question') }}
</a>
</div>
<div class="mzp-l-content">
<hr />
</div>
{% endif %}
{% endif %}
{%- endmacro %}

Просмотреть файл

@ -152,7 +152,7 @@
{{ help_topics(topics) }}
</div>
{{ topic_metadata(topics, product_key=product_key) }}
{{ topic_metadata(topics, product=product, product_key=product_key) }}
{% if featured %}
<section class="mzp-l-content mzp-l-content sumo-page-section--inner">

Просмотреть файл

@ -32,7 +32,6 @@ def _get_aaq_product_key(slug):
def product_landing(request, slug):
"""The product landing page."""
product = get_object_or_404(Product, slug=slug)
user = request.user
if request.is_ajax():
# Return a list of topics/subtopics for the product
@ -60,11 +59,6 @@ def product_landing(request, slug):
"topics": topics_for(product=product, parent=None),
"search_params": {"product": slug},
"latest_version": latest_version,
"subscribed_products_ids": (
user.profile.products.all().values_list("id", flat=True)
if user.is_authenticated
else []
),
"featured": get_featured_articles(product, locale=request.LANGUAGE_CODE),
},
)

Просмотреть файл

@ -2,7 +2,7 @@
{% from 'includes/common_macros.html' import featured_articles, scam_banner %}
{% macro select_product(products) -%}
<h1 class="sumo-page-heading">{{ _('Ask the Community') }}</h1>
<h1 class="sumo-page-heading">{{ _('Contact Support') }}</h1>
<h2 class="sumo-page-subheading">{{ _('Which product do you need help with?') }}</h2>
<div class="sumo-page-section--inner">
<div id="product-picker" class="sumo-card-grid stack-on-mobile" style="--cg-count: {{ products|length }};">
@ -77,7 +77,7 @@
<a class="progress--link" href="#" disabled>
<span class="progress--link-inner">
<span class="progress--dot"></span>
<span class="progress--label">{{ _('Ask Question') }}</span>
<span class="progress--label">{{ _('Get Support') }}</span>
</span>
</a>
</li>
@ -87,9 +87,22 @@
{% macro aaq_widget(request, location="aaq") %}
{% set aaq_context = request.session.get("aaq_context") %}
{% if aaq_context %}
<div class="aaq-widget card elevation-01 text-center radius-md">
<h2 class="card--title has-bottom-margin">{{ _('Ask the Community') }}</h2>
{% if request.user.is_authenticated %}
<div class="aaq-widget card is-inverse elevation-01 text-center radius-md">
<h2 class="card--title has-bottom-margin">
<svg class="card--icon-sm" width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g transform="translate(2.000000, 1.878680)" stroke="#FFFFFF" stroke-width="2">
<path d="M9,1.12132031 L2,1.12132031 C0.8954305,1.12132031 5.32907052e-15,2.01675081 5.32907052e-15,3.12132031 L5.32907052e-15,15.1213203 C5.32907052e-15,16.2258898 0.8954305,17.1213203 2,17.1213203 L11,17.1213203 L13,21.1213203 L15,17.1213203 L17,17.1213203 C18.1045695,17.1213203 19,16.2258898 19,15.1213203 L19,9.12132031"></path>
<path d="M15.5,0.621320312 C16.3284271,-0.207106783 17.6715729,-0.207106769 18.5,0.621320344 C19.3284271,1.44974746 19.3284271,2.79289318 18.5,3.62132031 L11,11.1213203 L7,12.1213203 L8,8.12132031 L15.5,0.621320312 Z"></path>
</g>
</g>
</svg>
{{ _('Still need help?') }}
</h2>
{% if aaq_context.has_subscriptions %}
<p>{{ _('Send a message to our support team.') }}</p>
{% elif request.user.is_authenticated %}
<p>{{ _('Continue to post your question and get help.') }}</p>
{% else %}
<p>{{ _('Sign in/up to post your question and get help.') }}</p>
@ -103,19 +116,27 @@
href="{{ next_step }}"
data-event-category="link click"
data-event-action="{{ location }}"
data-event-label="aaq widget">{{ _('Ask Now') }}</a>
data-event-label="aaq widget">
{% if aaq_context.has_subscriptions %}
{{ _('Contact Support') }}
{% else %}
{{ _('Ask Now') }}
{% endif %}
</a>
</div>
{% endif %}
{%- endmacro %}
{% macro explore_solutions(product, search_box, featured, topics, request) -%}
{% macro explore_solutions(product, search_box, featured, topics, request, has_subscriptions=True) -%}
{% set search_params = {'product': product.product} %}
<section class="sumo-page-section question-masthead shade-bg">
<div class="mzp-l-content">
{{ progress_bar(2) }}
{{ scam_banner() }}
{% if not has_subscriptions %}
{{ scam_banner() }}
{% endif %}
<div class="sumo-l-two-col sidebar-on-right align-center cols-on-medium">
<div class="sumo-l-two-col--main home-search-section--content">

Просмотреть файл

@ -22,9 +22,11 @@ we can compute the edit-title URL.
<article class="main mzp-l-content sumo-page-section--inner">
{% if current_step %}
{{ progress_bar(current_step, product_key=current_product.key) }}
{% endif %}
{{ scam_banner() }}
{% if current_step > 1 and not has_subscriptions %}
{{ scam_banner() }}
{% endif %}
{% endif %}
{% block formwrap %}
<img class="page-heading--logo" src="{{ image_for_product(current_product.get('product', '')) }}" alt="{{ current_product.get('name', '') }} logo" />
@ -46,23 +48,23 @@ we can compute the edit-title URL.
{% endblock %}
<div class="sumo-l-two-col">
<aside class="sumo-l-two-col--sidebar">
{% if form %}
<div class="card has-moz-headings is-in-sidebar is-callout-bg text-center large-only">
<img class="card--img" src="{{ STATIC_URL }}sumo/img/Mozilla-Heads-Keith-Negley-180628__400.png" alt="Illustration of community" />
<div class="card--details">
<h3 class="card--title">{{ _('Our Community is here to help') }}</h3>
<p class="card--desc">{{ _('Kindness is at the heart of our community. Our volunteers are happy to share their time and Firefox knowledge with you.') }}</p>
<p><strong><a href="{{ url('landings.get_involved') }}">{{ _('Learn More') }}</a></strong></p>
</div>
</div>
{% if form and not has_subscriptions %}
<div class="card has-moz-headings is-in-sidebar is-callout-bg text-center large-only">
<img class="card--img" src="{{ STATIC_URL }}sumo/img/Mozilla-Heads-Keith-Negley-180628__400.png" alt="Illustration of community" />
<div class="card--details">
<h3 class="card--title">{{ _('Our Community is here to help') }}</h3>
<p class="card--desc">{{ _('Kindness is at the heart of our community. Our volunteers are happy to share their time and Firefox knowledge with you.') }}</p>
<p><strong><a href="{{ url('landings.get_involved') }}">{{ _('Learn More') }}</a></strong></p>
</div>
</div>
<div class="large-only">
<h3 class="sumo-card-heading">
<img class="card--icon-sm" src="{{ STATIC_URL }}protocol/img/icons/highlight.svg" alt="Helpful Tip icon" />
{{ _('Helpful Tip!')}}
</h3>
<p>{{ _('Follow through. Sometimes, our volunteers would ask you for more information or to test out certain scenarios. The sooner you can do this, the sooner they would know how to fix it.')}}
</div>
<div class="large-only">
<h3 class="sumo-card-heading">
<img class="card--icon-sm" src="{{ STATIC_URL }}protocol/img/icons/highlight.svg" alt="Helpful Tip icon" />
{{ _('Helpful Tip!')}}
</h3>
<p>{{ _('Follow through. Sometimes, our volunteers would ask you for more information or to test out certain scenarios. The sooner you can do this, the sooner they would know how to fix it.')}}
</div>
{% endif %}
</aside>
<article class="sumo-l-two-col--main">
@ -77,21 +79,22 @@ we can compute the edit-title URL.
<form id="question-form" method="post">
{% csrf_token %}
<p class="sumo-page-intro">
{% trans %}
Be nice. Our volunteers are Mozilla users just like you,
who take the time out of their day to help.
{% endtrans %}
</p>
<div class="info card shade-bg highlight mb">
{% trans %}
Be descriptive. Saying &quot;Playing video on YouTube is
always choppy&quot; will help our volunteers identify
your problem better than saying &quot;Something is
wrong&quot; or &quot;Firefox is broken&quot;.
{% endtrans %}
</div>
{% if not has_subscriptions %}
<p class="sumo-page-intro">
{% trans %}
Be nice. Our volunteers are Mozilla users just like you,
who take the time out of their day to help.
{% endtrans %}
</p>
<div class="info card shade-bg highlight mb">
{% trans %}
Be descriptive. Saying &quot;Playing video on YouTube is
always choppy&quot; will help our volunteers identify
your problem better than saying &quot;Something is
wrong&quot; or &quot;Firefox is broken&quot;.
{% endtrans %}
</div>
{% endif %}
{% for field in form.hidden_fields() %}
{{ field|safe }}
@ -100,20 +103,22 @@ we can compute the edit-title URL.
{% set li_class='' %}
{% for field in form.visible_fields() if not field.name == 'notifications' %}
{% if field.name == 'ff_version' or field.name == 'device' %}
<li class="system-details-info show">
<p>
{{ _("We've made some educated guesses about your current browser and operating system.") }}
<a href="#show-details" class="show">{{ _('Show details &raquo;')|safe }}</a>
<a href="#hide-details" class="hide hide-until-expanded">{{ _('Hide details &raquo;')|safe }}</a>
</p>
</li>
{% endif %}
{% if field.name == 'ff_version' or field.name == 'device' or field.name == 'os' or field.name == 'plugins' %}
{% set li_class='details' %}
{% if not has_subscriptions %}
{% if field.name == 'ff_version' or field.name == 'device' %}
<li class="system-details-info show">
<p>
{{ _("We've made some educated guesses about your current browser and operating system.") }}
<a href="#show-details" class="show">{{ _('Show details &raquo;')|safe }}</a>
<a href="#hide-details" class="hide hide-until-expanded">{{ _('Hide details &raquo;')|safe }}</a>
</p>
</li>
{% endif %}
{% if field.name == 'ff_version' or field.name == 'device' or field.name == 'os' or field.name == 'plugins' %}
{% set li_class='details' %}
{% endif %}
{% endif %}
<li class="{{ li_class }} {% if field.errors %}has-error invalid{% endif %} cf">
{{ field.label_tag()|safe }}

Просмотреть файл

@ -1,7 +1,7 @@
{% extends "questions/includes/question_editing_frame.html" %}
{% from "questions/includes/aaq_macros.html" import explore_solutions %}
{% from "questions/includes/aaq_macros.html" import select_product %}
{% set title = _('Ask a Question') %}
{% set title = _('Get Support') %}
{% set no_headline = True %}
{% set hide_locale_switcher = True %}
{% set meta = [('robots', 'noindex')] %}
@ -20,15 +20,23 @@
{% block contentwrap %}
{% if current_step == 2 %}
{{ explore_solutions(current_product, search_box, featured, topics, request) }}
{{ explore_solutions(current_product, search_box, featured, topics, request, has_subscriptions) }}
{% else %}
{{ super() }}
{% endif %}
{% endblock %}
{% block major_detail_instructions %}
<h2 class="sumo-page-heading">{{ _('Ask your question') }}</h2>
<h2 class="sumo-page-heading">
{% if has_subscriptions %}
{{ _('Get support') }}
{% else %}
{{ _('Ask your question') }}
{% endif %}
</h2>
{% endblock %}
{% block submit_button_value %}{{ _('Post Question') }}{% endblock %}
{% block submit_button_value %}
{{ _('Submit') }}
{% endblock %}
{% block submit_button_attrs %}data-event-category="Ask A Question Flow" data-event-action="step 3 link"{% endblock %}

Просмотреть файл

@ -25,11 +25,14 @@ from django.utils.translation import ugettext_lazy as _lazy
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from django_user_agents.utils import get_user_agent
from sentry_sdk import capture_exception
from taggit.models import Tag
from tidings.events import ActivationRequestFailed
from tidings.models import Watch
from zenpy.lib.exception import APIException
from kitsune.access.decorators import login_required, permission_required
from kitsune.customercare.forms import ZendeskForm
from kitsune.flagit.models import FlaggedObject
from kitsune.products.models import Product, Topic
from kitsune.questions import config
@ -493,10 +496,11 @@ def aaq(request, product_key=None, category_key=None, step=1):
except Product.DoesNotExist:
raise Http404
has_public_forum = product.questions_enabled(locale=request.LANGUAGE_CODE)
has_subscriptions = product.has_subscriptions
request.session["aaq_context"] = {
"key": product_key,
"has_public_forum": has_public_forum,
"has_subscriptions": product.has_subscriptions,
"has_subscriptions": has_subscriptions,
}
context = {
@ -506,6 +510,9 @@ def aaq(request, product_key=None, category_key=None, step=1):
"host": Site.objects.get_current().domain,
}
if step > 1:
context["has_subscriptions"] = has_subscriptions
if step == 2:
context["featured"] = get_featured_articles(product, locale=request.LANGUAGE_CODE)
context["topics"] = topics_for(product, parent=None)
@ -526,6 +533,35 @@ def aaq(request, product_key=None, category_key=None, step=1):
return HttpResponseRedirect(path)
if has_subscriptions:
zendesk_form = ZendeskForm(data=request.POST or None, product=product)
context["form"] = zendesk_form
if zendesk_form.is_valid():
try:
zendesk_form.send(request.user)
messages.add_message(
request,
messages.SUCCESS,
_(
"Done! Your message was sent to Mozilla Support, "
"thank you for reaching out. "
"We'll contact you via email as soon as possible."
),
)
url = reverse("products.product", args=[product.slug])
return HttpResponseRedirect(url)
except APIException as err:
messages.add_message(
request, messages.ERROR, _("That didn't work. Please try again.")
)
capture_exception(err)
return render(request, template, context)
form = NewQuestionForm(
product=product_config,
data=request.POST or None,

Просмотреть файл

@ -610,8 +610,8 @@ else:
FXA_USERNAME_ALGO = config("FXA_USERNAME_ALGO", default=_username_algo)
FXA_STORE_ACCESS_TOKEN = config("FXA_STORE_ACCESS_TOKEN", default=False, cast=bool)
FXA_STORE_ID_TOKEN = config("FXA_STORE_ID_TOKEN", default=False, cast=bool)
FXA_SUPPORT_FORM = config(
"FXA_SUPPORT_FORM", default="https://accounts.firefox.com/support"
FXA_SUBSCRIPTIONS = config(
"FXA_SUBSCRIPTIONS", default="https://accounts.firefox.com/subscriptions"
)
FXA_SET_ISSUER = config("FXA_SET_ISSUER", default="https://accounts.firefox.com")
@ -1200,3 +1200,8 @@ if ES7_ENABLE_CONSOLE_LOGGING and DEV:
es_trace_logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
es_trace_logger.addHandler(handler)
# Zendesk Section
ZENDESK_SUBDOMAIN = config("ZENDESK_SUBDOMAIN", default="")
ZENDESK_API_TOKEN = config("ZENDESK_API_TOKEN", default="")
ZENDESK_USER_EMAIL = config("ZENDESK_USER_EMAIL", default="")

Просмотреть файл

@ -61,6 +61,11 @@
}
}
&.subscriptions {
margin: p.$spacing-lg 0;
min-height: initial;
}
&.is-shaded {
background: var(--color-shade-bg);
}
@ -101,6 +106,10 @@
}
}
&--subscriptions {
padding-top: p.$spacing-sm;
}
&.is-inverse {
background: var(--color-inverse-bg);
@ -161,6 +170,7 @@
flex-direction: column;
height: 100%;
}
}
&--product {

Просмотреть файл

@ -34,6 +34,11 @@
.aaq-widget {
background-color: var(--card-bg);
.card--icon-sm {
vertical-align: middle;
margin-bottom: 0;
}
}
aside .aaq-widget {

Просмотреть файл

@ -1,11 +1,22 @@
from django import forms
from django.contrib import admin
from django.db.models import Q
from kitsune.products.models import Product
from kitsune.users import monkeypatch
from kitsune.users.models import AccountEvent, Profile
class ProfileAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# limit the subscriptions choices to the appropriate products
associated_products = [product.id for product in self.initial.get("products", [])]
products = Product.objects.filter(Q(pk__in=associated_products) | ~Q(codename__exact=""))
self.fields["products"].queryset = products
self.fields["products"].required = False
delete_avatar = forms.BooleanField(
required=False, help_text=("Check to remove the user's avatar.")
)
@ -30,6 +41,7 @@ class ProfileAdmin(admin.ModelAdmin):
"is_fxa_migrated",
"fxa_uid",
"fxa_refresh_token",
"zendesk_id",
],
},
),
@ -53,6 +65,13 @@ class ProfileAdmin(admin.ModelAdmin):
"classes": ["collapse"],
},
),
(
"Subscriptions",
{
"fields": ["products"],
"classes": ["collapse"],
},
),
)
form = ProfileAdminForm
list_display = ["full_user", "name", "get_products"]
@ -60,7 +79,7 @@ class ProfileAdmin(admin.ModelAdmin):
list_filter = ["is_fxa_migrated", "country"]
search_fields = ["user__username", "user__email", "name", "fxa_uid"]
autocomplete_fields = ["user"]
readonly_fields = ["fxa_refresh_token"]
readonly_fields = ["fxa_refresh_token", "zendesk_id"]
def get_products(self, obj):
"""Get a list of products that a user is subscribed to."""

Просмотреть файл

@ -11,6 +11,7 @@ from django.utils.translation import activate
from django.utils.translation import ugettext as _
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
from kitsune.customercare.tasks import update_zendesk_identity
from kitsune.products.models import Product
from kitsune.sumo.urlresolvers import reverse
from kitsune.users.models import Profile
@ -210,6 +211,13 @@ class FXAAuthBackend(OIDCAuthenticationBackend):
if user_attr_changed:
user.save()
profile.save()
# If we have an updated email, let's update Zendesk too
# the check is repeated for now but it will save a few
# API calls if we trigger the task only when we know that we have new emails
if user_attr_changed:
update_zendesk_identity.delay(user.id, email)
return user
def authenticate(self, request, **kwargs):

Просмотреть файл

@ -8,6 +8,9 @@
<li class="sidebar-nav--item"><a {{ selected|class_selected('edit-settings') }} href="{{ url('users.edit_settings') }}">{{ _('Edit settings') }}</a></li>
<li class="sidebar-nav--item"><a {{ selected|class_selected('edit-watches') }} href="{{ url('users.edit_watch_list') }}">{{ _('Manage watch list') }}</a></li>
<li class="sidebar-nav--item"><a {{ selected|class_selected('user-questions') }} href="{{ url('users.questions', user.username) }}">{{ _('My questions') }}</a></li>
{% if user.profile.is_subscriber %}
<li class="sidebar-nav--item"><a {{ selected|class_selected('user-subscriptions') }} href="{{ url('users.subscriptions', user.username) }}">{{ _('My subscriptions') }}</a></li>
{% endif %}
</ul>
</nav>
{%- endmacro %}

Просмотреть файл

@ -0,0 +1,27 @@
{% extends "users/base.html" %}
{% set title = _("{user} | Subscriptions")|f(user=display_name(user)) %}
{% set canonical_url = canonicalize(viewname="users.subscriptions", username=user.username) %}
{% set active = "user-subscriptions" %}
{% block content %}
<article id="profile">
<h2 class="sumo-page-subheading">
{{ _("My Subscriptions") }}
</h2>
{% for product in products %}
<div class="card card--product subscriptions">
<img class="card--icon" src="{{ product.image_alternate_url }}" alt="{{ pgettext('DB: products.Product.title', product.title) }}">
<div class="card--details">
<h3 class="card--title">
<a class="title" href="{{ url('products.product', product.slug) }}" data-event-category="link click" data-event-action="product" data-event-label="Firefox">
{{ _('<strong>{product}</strong>')|fe(product=pgettext('DB: products.Product.title', product.title)) }}
</a>
</h3>
<div class="card--subscriptions">
<a href="{{ settings.FXA_SUBSCRIPTIONS }}">{{ _('Manage Subscriptions') }}</a>
</div>
</div>
</div>
{% endfor %}
</article>
{% endblock %}

Просмотреть файл

@ -0,0 +1,18 @@
# Generated by Django 2.2.24 on 2021-09-02 06:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0026_profile_fxa_refresh_token'),
]
operations = [
migrations.AddField(
model_name='profile',
name='zendesk_id',
field=models.CharField(blank=True, default='', max_length=1024),
),
]

Просмотреть файл

@ -102,6 +102,7 @@ class Profile(ModelBase):
products = models.ManyToManyField(Product, related_name="subscribed_users")
fxa_password_change = models.DateTimeField(blank=True, null=True)
fxa_refresh_token = models.CharField(blank=True, default="", max_length=128)
zendesk_id = models.CharField(blank=True, default="", max_length=1024)
updated_column_name = "user__date_joined"
@ -197,6 +198,10 @@ class Profile(ModelBase):
return AnswerVote.objects.filter(answer__creator=self.user, helpful=True).count()
@property
def is_subscriber(self):
return self.products.exists()
class Setting(ModelBase):
"""User specific value per setting"""

Просмотреть файл

@ -18,8 +18,7 @@ detail_patterns = [
url(r"^/answers$", views.answers_contributed, name="users.answers"),
url(r"^/documents$", views.documents_contributed, name="users.documents"),
url(r"^/edit$", views.edit_profile, name="users.edit_profile"),
# TODO:
# url('^abuse', views.report_abuse, name='users.abuse'),
url(r"^/subscriptions$", views.subscribed_products, name="users.subscriptions"),
]
if settings.DEV and settings.ENABLE_DEV_LOGIN:

Просмотреть файл

@ -41,14 +41,14 @@ from kitsune.questions.utils import mark_content_as_spam, num_answers, num_quest
from kitsune.sumo.decorators import ssl_required
from kitsune.sumo.templatetags.jinja_helpers import urlparams
from kitsune.sumo.urlresolvers import reverse
from kitsune.sumo.utils import get_next_url, simple_paginate, paginate
from kitsune.sumo.utils import get_next_url, paginate, simple_paginate
from kitsune.users.forms import ProfileForm, SettingsForm, UserForm
from kitsune.users.models import SET_ID_PREFIX, AccountEvent, Deactivation, Profile
from kitsune.users.tasks import (
process_event_delete_user,
process_event_password_change,
process_event_subscription_state_change,
process_event_profile_change,
process_event_subscription_state_change,
)
from kitsune.users.templatetags.jinja_helpers import profile_url
from kitsune.users.utils import (
@ -214,6 +214,19 @@ def answers_contributed(request, username):
)
@login_required
@require_GET
def subscribed_products(request, username):
# plus sign (+) is converted to space
username = username.replace(" ", "+")
user = get_object_or_404(User, username=username, is_active=True)
if user != request.user:
raise Http404
context = {"user": user, "products": user.profile.products.all()}
return render(request, "users/user_subscriptions.html", context)
@require_GET
def documents_contributed(request, username):
# plus sign (+) is converted to space

Просмотреть файл

@ -72,3 +72,4 @@ timeout-decorator
translate-toolkit~=2.5.1
twython~=3.8.0
whitenoise~=3.3.1
zenpy

Просмотреть файл

@ -1,4 +1,4 @@
# SHA1:a18c4b32b036077466b07df74766ba227261a40d
# SHA1:0168ed530b4f334d1334aab4f5023a67f02609a6
#
# This file is autogenerated by pip-compile-multi
# To update, run:
@ -31,15 +31,15 @@ bleach==3.3.1 \
# via
# -r requirements/default.in
# py-wikimarkup
boto3==1.18.16 \
--hash=sha256:23e55b7cde2b35c79c63d4d52c761fdc2141f70f02df76f68882776a33dfcb63 \
--hash=sha256:806111acfb70715dfbe9d9f0d6089e7a9661a6d6bb422b17e035fc32e17f3f37
boto3==1.18.41 \
--hash=sha256:44f73009506dba227e0d421e4fc44a863d8ff315aaa47d9a7be6c549a6a88a12 \
--hash=sha256:aaa6ba286d92fb03f27dd619220c6c1de2c010f39cac7afa72f505f073a31db1
# via
# -r requirements/default.in
# django-storages
botocore==1.21.16 \
--hash=sha256:697b577d62a8893bce56c74ee53e54f04e69b14e42a6591e109c49b5675c19ed \
--hash=sha256:b0e342b8c554f34f9f1cb028fbc20aff535fefe0c86a4e2cae7201846cd6aa4a
botocore==1.21.41 \
--hash=sha256:b877f9175843939db6fde3864ffc47611863710b85dc0336bb2433e921dc8790 \
--hash=sha256:efad68a52ee2d939618e0fcb3da0a46dff10cb2e0e128c1e2749bbfc58953a12
# via
# boto3
# s3transfer
@ -49,6 +49,7 @@ cachetools==4.2.2 \
# via
# google-auth
# premailer
# zenpy
celery==4.4.3 \
--hash=sha256:5147662e23dc6bc39c17a2cbc9a148debe08ecfb128b0eded14a0d9c81fc5742 \
--hash=sha256:df2937b7536a2a9b18024776a3a46fd281721813636c03a5177fa02fe66078f6
@ -121,21 +122,24 @@ commonware==0.6.0 \
--hash=sha256:0e9520986e292f2bf8cdf80b32f21ef01e4058fd7baa61d2d282d21ed7085b1f \
--hash=sha256:f596962fd11bc53b5453ffa766dc99f297895021946096d3e6b4826f9ae075ea
# via -r requirements/default.in
cryptography==3.4.7 \
--hash=sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d \
--hash=sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959 \
--hash=sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6 \
--hash=sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873 \
--hash=sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2 \
--hash=sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713 \
--hash=sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1 \
--hash=sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177 \
--hash=sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250 \
--hash=sha256:b01fd6f2737816cb1e08ed4807ae194404790eac7ad030b34f2ce72b332f5586 \
--hash=sha256:bf40af59ca2465b24e54f671b2de2c59257ddc4f7e5706dbd6930e26823668d3 \
--hash=sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca \
--hash=sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d \
--hash=sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9
cryptography==3.4.8 \
--hash=sha256:0a7dcbcd3f1913f664aca35d47c1331fce738d44ec34b7be8b9d332151b0b01e \
--hash=sha256:1eb7bb0df6f6f583dd8e054689def236255161ebbcf62b226454ab9ec663746b \
--hash=sha256:21ca464b3a4b8d8e86ba0ee5045e103a1fcfac3b39319727bc0fc58c09c6aff7 \
--hash=sha256:34dae04a0dce5730d8eb7894eab617d8a70d0c97da76b905de9efb7128ad7085 \
--hash=sha256:3520667fda779eb788ea00080124875be18f2d8f0848ec00733c0ec3bb8219fc \
--hash=sha256:3fa3a7ccf96e826affdf1a0a9432be74dc73423125c8f96a909e3835a5ef194a \
--hash=sha256:5b0fbfae7ff7febdb74b574055c7466da334a5371f253732d7e2e7525d570498 \
--hash=sha256:8695456444f277af73a4877db9fc979849cd3ee74c198d04fc0776ebc3db52b9 \
--hash=sha256:94cc5ed4ceaefcbe5bf38c8fba6a21fc1d365bb8fb826ea1688e3370b2e24a1c \
--hash=sha256:94fff993ee9bc1b2440d3b7243d488c6a3d9724cc2b09cdb297f6a886d040ef7 \
--hash=sha256:9965c46c674ba8cc572bc09a03f4c649292ee73e1b683adb1ce81e82e9a6a0fb \
--hash=sha256:a00cf305f07b26c351d8d4e1af84ad7501eca8a342dedf24a7acb0e7b7406e14 \
--hash=sha256:a305600e7a6b7b855cd798e00278161b681ad6e9b7eca94c721d5f588ab212af \
--hash=sha256:cd65b60cfe004790c795cc35f272e41a3df4631e2fb6b35aa7ac6ef2859d554e \
--hash=sha256:d2a6e5ef66503da51d2110edf6c403dc6b494cc0082f85db12f54e9c5d4c3ec5 \
--hash=sha256:d9ec0e67a14f9d1d48dd87a2531009a9b251c02ea42851c060b25c782516ff06 \
--hash=sha256:f44d141b8c4ea5eb4dbc9b3ad992d45580c1d22bf5e24363f2fbf50c2d7ae8a7
# via
# -r requirements/default.in
# josepy
@ -314,17 +318,17 @@ faker==4.1.8 \
# via
# -r requirements/default.in
# factory-boy
google-api-core==1.31.1 \
--hash=sha256:108cf94336aed7e614eafc53933ef02adf63b9f0fd87e8f8212acaa09eaca456 \
--hash=sha256:1d63e2b28057d79d64795c9a70abcecb5b7e96da732d011abf09606a39b48701
google-api-core==1.31.2 \
--hash=sha256:384459a0dc98c1c8cd90b28dc5800b8705e0275a673a7144a513ae80fc77950b \
--hash=sha256:8500aded318fdb235130bf183c726a05a9cb7c4b09c266bd5119b86cdb8a4d10
# via google-api-python-client
google-api-python-client==1.8.4 \
--hash=sha256:bbe212611fdc05364f3d20271cae53971bf4d485056e6c0d40748eddeeda9a19 \
--hash=sha256:e7980ba66288f815b41f10c4561b37f45cd568d302b0d801709e51f75b21f61b
# via -r requirements/default.in
google-auth==1.34.0 \
--hash=sha256:bd6aa5916970a823e76ffb3d5c3ad3f0bedafca0a7fa53bc15149ab21cb71e05 \
--hash=sha256:f1094088bae046fb06f3d1a3d7df14717e8d959e9105b79c57725bd4e17597a2
google-auth==1.35.0 \
--hash=sha256:997516b42ecb5b63e8d80f5632c1a61dddf41d2a4c2748057837e06e00014258 \
--hash=sha256:b7033be9028c188ee30200b204ea00ed82ea1162e8ac1df4aa6ded19a191d88e
# via
# google-api-core
# google-api-python-client
@ -391,9 +395,9 @@ jmespath==0.10.0 \
# via
# boto3
# botocore
josepy==1.8.0 \
--hash=sha256:6d632fcdaf0bed09e33f81f13b10575d4f0b7c37319350b725454e04a41e6a49 \
--hash=sha256:a5a182eb499665d99e7ec54bb3fe389f9cbc483d429c9651f20384ba29564269
josepy==1.9.0 \
--hash=sha256:49798be66a467e7c81f071fe5ff03ac5e37c6d7081933612259028496bb05a68 \
--hash=sha256:51cce8d97ced0556aae0ce3161b26d5f0f54bc42c749d3c606edc6d97d9802dc
# via mozilla-django-oidc
kombu==4.6.9 \
--hash=sha256:ab0afaa5388dd2979cbc439d3623b86a4f7a58d41f621096bef7767c37bc2505 \
@ -421,6 +425,7 @@ lxml==4.6.3 \
--hash=sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83 \
--hash=sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04 \
--hash=sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16 \
--hash=sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4 \
--hash=sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791 \
--hash=sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a \
--hash=sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51 \
@ -435,6 +440,7 @@ lxml==4.6.3 \
--hash=sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa \
--hash=sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106 \
--hash=sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d \
--hash=sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d \
--hash=sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617 \
--hash=sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4 \
--hash=sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92 \
@ -458,30 +464,50 @@ markupsafe==2.0.1 \
--hash=sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b \
--hash=sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567 \
--hash=sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff \
--hash=sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724 \
--hash=sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74 \
--hash=sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646 \
--hash=sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35 \
--hash=sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6 \
--hash=sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6 \
--hash=sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad \
--hash=sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26 \
--hash=sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38 \
--hash=sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac \
--hash=sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7 \
--hash=sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6 \
--hash=sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75 \
--hash=sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f \
--hash=sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135 \
--hash=sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8 \
--hash=sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a \
--hash=sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a \
--hash=sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9 \
--hash=sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864 \
--hash=sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914 \
--hash=sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18 \
--hash=sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8 \
--hash=sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2 \
--hash=sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d \
--hash=sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b \
--hash=sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b \
--hash=sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f \
--hash=sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb \
--hash=sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833 \
--hash=sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28 \
--hash=sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415 \
--hash=sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902 \
--hash=sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d \
--hash=sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9 \
--hash=sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d \
--hash=sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145 \
--hash=sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066 \
--hash=sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c \
--hash=sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1 \
--hash=sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f \
--hash=sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53 \
--hash=sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134 \
--hash=sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85 \
--hash=sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5 \
--hash=sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94 \
--hash=sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509 \
@ -662,6 +688,7 @@ python-dateutil==2.8.2 \
# botocore
# elasticsearch-dsl
# faker
# zenpy
python-decouple==3.4 \
--hash=sha256:2e5adb0263a4f963b58d7407c4760a2465d464ee212d733e2a2c179e54c08d8f \
--hash=sha256:a8268466e6389a639a20deab9d880faee186eb1eb6a05e54375bdf158d691981
@ -681,7 +708,7 @@ pytz==2020.5 \
# django
# django-timezone-field
# google-api-core
# tzlocal
# zenpy
redis==3.5.3 \
--hash=sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2 \
--hash=sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24
@ -699,6 +726,7 @@ requests==2.23.0 \
# premailer
# requests-oauthlib
# twython
# zenpy
requests-oauthlib==1.3.0 \
--hash=sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d \
--hash=sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a
@ -717,45 +745,53 @@ sentry-sdk==0.14.4 \
--hash=sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c \
--hash=sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c
# via -r requirements/default.in
simplejson==3.17.3 \
--hash=sha256:02bc0b7b643fa255048862f580bb4b7121b88b456bc64dabf9bf11df116b05d7 \
--hash=sha256:02c04b89b0a456a97d5313357dd9f2259c163a82c5307e39e7d35bb38d7fd085 \
--hash=sha256:05cd392c1c9b284bda91cf9d7b6f3f46631da459e8546fe823622e42cf4794bb \
--hash=sha256:1331a54fda3c957b9136402943cf8ebcd29c0c92101ba70fa8c2fc9cdf1b8476 \
--hash=sha256:18302970ce341c3626433d4ffbdac19c7cca3d6e2d54b12778bcb8095f695473 \
--hash=sha256:1ebbaa48447b60a68043f58e612021e8893ebcf1662a1b18a2595ca262776d7e \
--hash=sha256:2104475a0263ff2a3dffca214c9676eb261e90d06d604ac7063347bd289ac84c \
--hash=sha256:23169d78f74fd25f891e89c779a63fcb857e66ab210096f4069a5b1c9e2dc732 \
--hash=sha256:32edf4e491fe174c54bf6682d794daf398736158d1082dbcae526e4a5af6890b \
--hash=sha256:3904b528e3dc0facab73a4406ebf17f007f32f0a8d7f4c6aa9ed5cbad3ea0f34 \
--hash=sha256:391a8206e698557a4155354cf6996c002aa447a21c5c50fb94a0d26fd6cca586 \
--hash=sha256:3c80b343503da8b13fa7d48d1a2395be67e97b67a849eb79d88ad3b12783e7da \
--hash=sha256:3dddd31857d8230aee88c24f485ebca36d1d875404b2ef11ac15fa3c8a01dc34 \
--hash=sha256:56f57c231cdd01b6a1c0532ea9088dff2afe7f4f4bda61c060bcb1a853e6b564 \
--hash=sha256:5b080be7de4c647fa84252cf565298a13842658123bd1a322a8c32b6359c8f1e \
--hash=sha256:6285b91cfa37e024f372b9b77d14f279380eebc4f709db70c593c069602e1926 \
--hash=sha256:6510e886d9e9006213de2090c55f504b12f915178a2056b94840ed1d89abe68e \
--hash=sha256:6ff6710b824947ef5a360a5a5ae9809c32cedc6110df3b64f01080c1bc1a1f08 \
--hash=sha256:79545a6d93bb38f86a00fbc6129cb091a86bb858e7d53b1aaa10d927d3b6732e \
--hash=sha256:88a69c7e8059a4fd7aa2a31d2b3d89077eaae72eb741f18a32cb57d04018ff4c \
--hash=sha256:8f174567c53413383b8b7ec2fbe88d41e924577bc854051f265d4c210cd72999 \
--hash=sha256:a52b80b9d1085db6e216980d1d28a8f090b8f2203a8c71b4ea13441bd7a2e86e \
--hash=sha256:b25748e71c5df3c67b5bda2cdece373762d319cb5f773f14ae2f90dfb4320314 \
--hash=sha256:b45b5f6c9962953250534217b18002261c5b9383349b95fb0140899cdac2bf95 \
--hash=sha256:b4ed7b233e812ef1244a29fb0dfd3e149dbc34a2bd13b174a84c92d0cb580277 \
--hash=sha256:b60f48f780130f27f8d9751599925c3b78cf045f5d62dd918003effb65b45bda \
--hash=sha256:c69a213ae72b75e8948f06a87d3675855bccb3037671222ffd235095e62f5a61 \
--hash=sha256:c91d0f2fc2ee1bd376f5a991c24923f12416d8c31a9b74a82c4b38b942fc2640 \
--hash=sha256:d61fb151be068127a0ce7758341cbe778495819622bc1e15eadf59fdb3a0481e \
--hash=sha256:da72a452bcf4349fc467a12b54ab0e63e654a571cacc44084826d52bde12b6ee \
--hash=sha256:dbcd6cd1a9abb5a13c5df93cdc5687f6877efcfefdc9350c22d4094dc4a7dd86 \
--hash=sha256:e056056718246c9cdd82d1e3d4ad854a7ceb057498bf994b529750a190a6bd98 \
--hash=sha256:e3aa10cce4053f3c1487aaf847a0faa4ae208e11f85a8e6f98de2291713a6616 \
--hash=sha256:e7433c604077a17dd71e8b29c96a15e486a70a97f4ed9c7f5e0df6e428af2f0b \
--hash=sha256:f02db159e0afa9cb350f15f4f7b86755eae95267b9012ee90bde329aa643f76c \
--hash=sha256:f32a703fe10cfc2d1020e296eeeeb650faa039678f6b79d9b820413a4c015ddc \
--hash=sha256:fed5e862d9b501c5673c163c8593ebdb2c5422386089c529dfac28d70cd55858 \
--hash=sha256:ff7fe042169dd6fce8213c173a4c337f2e807ed5178093143c778eb0484c12ec
simplejson==3.17.5 \
--hash=sha256:065230b9659ac38c8021fa512802562d122afb0cf8d4b89e257014dcddb5730a \
--hash=sha256:07707ba69324eaf58f0c6f59d289acc3e0ed9ec528dae5b0d4219c0d6da27dc5 \
--hash=sha256:10defa88dd10a0a4763f16c1b5504e96ae6dc68953cfe5fc572b4a8fcaf9409b \
--hash=sha256:140eb58809f24d843736edb8080b220417e22c82ac07a3dfa473f57e78216b5f \
--hash=sha256:188f2c78a8ac1eb7a70a4b2b7b9ad11f52181044957bf981fb3e399c719e30ee \
--hash=sha256:1c2688365743b0f190392e674af5e313ebe9d621813d15f9332e874b7c1f2d04 \
--hash=sha256:24e413bd845bd17d4d72063d64e053898543fb7abc81afeae13e5c43cef9c171 \
--hash=sha256:2b59acd09b02da97728d0bae8ff48876d7efcbbb08e569c55e2d0c2e018324f5 \
--hash=sha256:2df15814529a4625ea6f7b354a083609b3944c269b954ece0d0e7455872e1b2a \
--hash=sha256:352c11582aa1e49a2f0f7f7d8fd5ec5311da890d1354287e83c63ab6af857cf5 \
--hash=sha256:36b08b886027eac67e7a0e822e3a5bf419429efad7612e69501669d6252a21f2 \
--hash=sha256:376023f51edaf7290332dacfb055bc00ce864cb013c0338d0dea48731f37e42f \
--hash=sha256:3ba82f8b421886f4a2311c43fb98faaf36c581976192349fef2a89ed0fcdbdef \
--hash=sha256:3d72aa9e73134dacd049a2d6f9bd219f7be9c004d03d52395831611d66cedb71 \
--hash=sha256:40ece8fa730d1a947bff792bcc7824bd02d3ce6105432798e9a04a360c8c07b0 \
--hash=sha256:417b7e119d66085dc45bdd563dcb2c575ee10a3b1c492dd3502a029448d4be1c \
--hash=sha256:42b7c7264229860fe879be961877f7466d9f7173bd6427b3ba98144a031d49fb \
--hash=sha256:457d9cfe7ece1571770381edccdad7fc255b12cd7b5b813219441146d4f47595 \
--hash=sha256:4a6943816e10028eeed512ea03be52b54ea83108b408d1049b999f58a760089b \
--hash=sha256:5b94df70bd34a3b946c0eb272022fb0f8a9eb27cad76e7f313fedbee2ebe4317 \
--hash=sha256:5f5051a13e7d53430a990604b532c9124253c5f348857e2d5106d45fc8533860 \
--hash=sha256:5f7f53b1edd4b23fb112b89208377480c0bcee45d43a03ffacf30f3290e0ed85 \
--hash=sha256:5fe8c6dcb9e6f7066bdc07d3c410a2fca78c0d0b4e0e72510ffd20a60a20eb8e \
--hash=sha256:71a54815ec0212b0cba23adc1b2a731bdd2df7b9e4432718b2ed20e8aaf7f01a \
--hash=sha256:7332f7b06d42153255f7bfeb10266141c08d48cc1a022a35473c95238ff2aebc \
--hash=sha256:78c6f0ed72b440ebe1892d273c1e5f91e55e6861bea611d3b904e673152a7a4c \
--hash=sha256:7c9b30a2524ae6983b708f12741a31fbc2fb8d6fecd0b6c8584a62fd59f59e09 \
--hash=sha256:86fcffc06f1125cb443e2bed812805739d64ceb78597ac3c1b2d439471a09717 \
--hash=sha256:87572213965fd8a4fb7a97f837221e01d8fddcfb558363c671b8aa93477fb6a2 \
--hash=sha256:8e595de17178dd3bbeb2c5b8ea97536341c63b7278639cb8ee2681a84c0ef037 \
--hash=sha256:917f01db71d5e720b731effa3ff4a2c702a1b6dacad9bcdc580d86a018dfc3ca \
--hash=sha256:91cfb43fb91ff6d1e4258be04eee84b51a4ef40a28d899679b9ea2556322fb50 \
--hash=sha256:aa86cfdeb118795875855589934013e32895715ec2d9e8eb7a59be3e7e07a7e1 \
--hash=sha256:ade09aa3c284d11f39640aebdcbb748e1996f0c60504f8c4a0c5a9fec821e67a \
--hash=sha256:b2a5688606dffbe95e1347a05b77eb90489fe337edde888e23bbb7fd81b0d93b \
--hash=sha256:b92fbc2bc549c5045c8233d954f3260ccf99e0f3ec9edfd2372b74b350917752 \
--hash=sha256:c2d5334d935af711f6d6dfeec2d34e071cdf73ec0df8e8bd35ac435b26d8da97 \
--hash=sha256:cb0afc3bad49eb89a579103616574a54b523856d20fc539a4f7a513a0a8ba4b2 \
--hash=sha256:ce66f730031b9b3683b2fc6ad4160a18db86557c004c3d490a29bf8d450d7ab9 \
--hash=sha256:e29b9cea4216ec130df85d8c36efb9985fda1c9039e4706fb30e0fb6a67602ff \
--hash=sha256:e2cc4b68e59319e3de778325e34fbff487bfdb2225530e89995402989898d681 \
--hash=sha256:e90d2e219c3dce1500dda95f5b893c293c4d53c4e330c968afbd4e7a90ff4a5b \
--hash=sha256:f13c48cc4363829bdfecc0c181b6ddf28008931de54908a492dc8ccd0066cd60 \
--hash=sha256:f550730d18edec4ff9d4252784b62adfe885d4542946b6d5a54c8a6521b56afd \
--hash=sha256:fa843ee0d34c7193f5a816e79df8142faff851549cab31e84b526f04878ac778 \
--hash=sha256:fe1c33f78d2060719d52ea9459d97d7ae3a5b707ec02548575c4fbed1d1d345b
# via -r requirements/default.in
six==1.16.0 \
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
@ -770,7 +806,6 @@ six==1.16.0 \
# elasticsearch-dsl
# google-api-core
# google-api-python-client
# google-auth
# google-auth-httplib2
# html5lib
# oauth2client
@ -778,9 +813,10 @@ six==1.16.0 \
# python-dateutil
# python-memcached
# translate-toolkit
sqlparse==0.4.1 \
--hash=sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0 \
--hash=sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8
# zenpy
sqlparse==0.4.2 \
--hash=sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae \
--hash=sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d
# via django
text-unidecode==1.3 \
--hash=sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8 \
@ -796,17 +832,17 @@ twython==3.8.2 \
--hash=sha256:a469d673fdd20d1c346e9b9f784212db521aa611bbdfc4912229ab701b36002b \
--hash=sha256:c6ca64309260e0ab47267f76217c80812f591991437f376fc61498816384f9e7
# via -r requirements/default.in
tzlocal==2.1 \
--hash=sha256:643c97c5294aedc737780a49d9df30889321cbe1204eac2c2ec6134035a92e44 \
--hash=sha256:e2cb6c6b5b604af38597403e9852872d7f534962ae2954c7f35efcb1ccacf4a4
tzlocal==3.0 \
--hash=sha256:c736f2540713deb5938d789ca7c3fc25391e9a20803f05b60ec64987cf086559 \
--hash=sha256:f4e6e36db50499e0d92f79b67361041f048e2609d166e93456b50746dc4aef12
# via apscheduler
ua-parser==0.10.0 \
--hash=sha256:46ab2e383c01dbd2ab284991b87d624a26a08f72da4d7d413f5bfab8b9036f8a \
--hash=sha256:47b1782ed130d890018d983fac37c2a80799d9e0b9c532e734c67cf70f185033
# via user-agents
unidecode==1.2.0 \
--hash=sha256:12435ef2fc4cdfd9cf1035a1db7e98b6b047fe591892e81f34e94959591fad00 \
--hash=sha256:8d73a97d387a956922344f6b74243c2c6771594659778744b2dbdaad8f6b727d
unidecode==1.3.1 \
--hash=sha256:5f58926b9125b499f8ab6816828e737578fa3e31fa24d351a3ab7f4b7c064ab0 \
--hash=sha256:6efac090bf8f29970afc90caf4daae87b172709b786cb1b4da2d0c0624431ecc
# via py-wikimarkup
uritemplate==3.0.1 \
--hash=sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f \
@ -844,6 +880,9 @@ whitenoise==3.3.1 \
--hash=sha256:15f43b2e701821b95c9016cf469d29e2a546cb1c7dead584ba82c36f843995cf \
--hash=sha256:9d81515f2b5b27051910996e1e860b1332e354d9e7bcf30c98f21dcb6713e0dd
# via -r requirements/default.in
zenpy==2.0.24 \
--hash=sha256:4fb01f8e7f5a9bcf33a61546804953ec9dc07625c9801b30f13b5fad9370429a
# via -r requirements/default.in
# WARNING: The following packages were not pinned, but pip requires them to be
# pinned when the requirements file includes hashes. Consider using the --allow-unsafe flag.