diff --git a/CHANGES b/CHANGES index 53fd1bad0..df03e5143 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,11 @@ +1.5 +--- +* add .xlsx format support +* add ability to invalide all cache at startup (set `INVALIDATE_CACHE` env variable) +* add ability to create default users at startup (set `AUTOCREATE_USERS` env variable) +* enable Azure login without email +* add partner.name to Intervention endpoint + 1.4.1 ----- * fixes dependencies diff --git a/MANIFEST.in b/MANIFEST.in index af113d81e..268d68760 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -22,6 +22,7 @@ exclude Makefile exclude manage.py exclude manage.py exclude tests +exclude ROADMAP prune .circleci prune db diff --git a/Pipfile b/Pipfile index 5a3a7f32b..3c429e8ed 100644 --- a/Pipfile +++ b/Pipfile @@ -34,7 +34,6 @@ pyparsing = "*" raven = "*" sqlparse = "*" whitenoise = "*" -django-monthfield = "*" django-model-utils = "*" python-social-auth = "*" social-auth-app-django = "*" @@ -44,6 +43,7 @@ cryptography = "*" "django-rest-framework-social-oauth2" = "*" django-countries = "*" django-filter = "*" +drf-renderer-xlsx = "*" [dev-packages] "flake8" = ">=3.6.0" @@ -62,7 +62,6 @@ pytest-coverage = "*" pytest-django = "*" pytest-echo = "*" pytest-pythonpath = "*" -#tox-pipenv = "==1.6.0" yapf = "*" vcrpy = "*" diff --git a/Pipfile.lock b/Pipfile.lock index b670cd10e..674d7233f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "77175119807c7c5bbd7ff9d8b0e59eea0e19233b2e5d85d125232fa342d2f428" + "sha256": "4ebe763c2d5e81b5c2291549170c2ff2329dac4cafccf66b9b12e18589c19e12" }, "pipfile-spec": 6, "requires": { @@ -264,13 +264,6 @@ "index": "pypi", "version": "==3.1.2" }, - "django-monthfield": { - "hashes": [ - "sha256:981031ade748fa9a6fc5b1ded60b4a95c02af7a50ddbb69b734a564deb14683b" - ], - "index": "pypi", - "version": "==0.1.1" - }, "django-oauth-toolkit": { "hashes": [ "sha256:ad1b76275950ebbff708222cec57bbdb879f89bac7df6b9dee0f4b9db485c264" @@ -366,6 +359,14 @@ "index": "pypi", "version": "==0.4.0" }, + "drf-renderer-xlsx": { + "hashes": [ + "sha256:1fad2c299f444a68b2ff963a28df11697427afc582485bab26e8efacf1596bfb", + "sha256:b08c55b4a0c75578457fbfcaf75fce081cce0f46c84ddce0d86abf01dbce8c27" + ], + "index": "pypi", + "version": "==0.3.0" + }, "drf-yasg": { "extras": [ "validation" @@ -377,6 +378,13 @@ "index": "pypi", "version": "==1.11.0" }, + "et-xmlfile": { + "hashes": [ + "sha256:614d9722d572f6246302c4491846d2c393c199cfa4edc9af593437691683335b", + "sha256:a6de963569df3b3bf5a3427e2d40495e6ce81006dacb2b2b79670a0f42a8b689" + ], + "version": "==1.0.1" + }, "flex": { "hashes": [ "sha256:17a58b3c0ca6524dbc79e1b266dcb8fc39d18060fbe8072ae297f14249ee0909", @@ -426,6 +434,13 @@ ], "version": "==1.1.0" }, + "jdcal": { + "hashes": [ + "sha256:948fb8d079e63b4be7a69dd5f0cd618a0a57e80753de8248fd786a8a20658a07", + "sha256:ea0a5067c5f0f50ad4c7bdc80abad3d976604f6fb026b0b3a17a9d84bb9046c9" + ], + "version": "==1.4" + }, "jinja2": { "hashes": [ "sha256:74c935a1b8bb9a3947c50a54766a969d4846290e1e788ea44c1392163723c3bd", @@ -469,6 +484,12 @@ ], "version": "==2.1.0" }, + "openpyxl": { + "hashes": [ + "sha256:022c0f3fa1e873cc0ba20651c54dd5e6276fc4ff150b4060723add4fc448645e" + ], + "version": "==2.5.9" + }, "psutil": { "hashes": [ "sha256:1c19957883e0b93d081d41687089ad630e370e26dc49fd9df6951d6c891c4736", diff --git a/ROADMAP b/ROADMAP new file mode 100644 index 000000000..3a6e0fe01 --- /dev/null +++ b/ROADMAP @@ -0,0 +1 @@ +Features diff --git a/src/etools_datamart/__init__.py b/src/etools_datamart/__init__.py index 58a977a8c..8ff6ef56b 100644 --- a/src/etools_datamart/__init__.py +++ b/src/etools_datamart/__init__.py @@ -1,3 +1,3 @@ NAME = 'etools-datamart' -VERSION = __version__ = '1.4.1' +VERSION = __version__ = '1.5' __author__ = '' diff --git a/src/etools_datamart/api/endpoints/common.py b/src/etools_datamart/api/endpoints/common.py index 8f09637cc..0f5fce262 100644 --- a/src/etools_datamart/api/endpoints/common.py +++ b/src/etools_datamart/api/endpoints/common.py @@ -7,6 +7,7 @@ from django.db import connections from django.http import Http404 from drf_querystringfilter.exceptions import QueryFilterException +from drf_renderer_xlsx.renderers import XLSXRenderer from dynamic_serializer.core import DynamicSerializerMixin from rest_framework.exceptions import NotAuthenticated, PermissionDenied from rest_framework.filters import OrderingFilter @@ -59,6 +60,7 @@ class APIReadOnlyModelViewSet(ReadOnlyModelViewSet): renderer_classes = [JSONRenderer, APIBrowsableAPIRenderer, CSVRenderer, + XLSXRenderer, ] filter_backends = [SystemFilterBackend, DatamartQueryStringFilterBackend, diff --git a/src/etools_datamart/api/filtering.py b/src/etools_datamart/api/filtering.py index b315d92b6..4ade0d55e 100644 --- a/src/etools_datamart/api/filtering.py +++ b/src/etools_datamart/api/filtering.py @@ -15,63 +15,6 @@ 'oct', 'nov', 'dec'] -# class SchemaFilterBackend(BaseFilterBackend): -# @lru_cache(100) -# def get_schema_fields(self, view): -# return [coreapi.Field( -# name='country_name', -# required=False, -# location='query', -# schema=coreschema.String( -# title='country_name', -# description="""case insensitive, comma separated list of country names
-# {c} -# """.format(c=", ".join([c.name for c in connections['etools'].get_tenants()])) -# ) -# )] -# -# def filter_queryset(self, request, queryset, view): -# value = request.GET.get('country_name', None) -# assert queryset.model._meta.app_label == 'etools' -# conn = connections['etools'] -# if not value: -# if request.user.is_superuser: -# conn.set_all_schemas() -# else: -# allowed = get_etools_allowed_schemas(request.user) -# if not allowed: -# raise PermissionDenied("You don't have enabled schemas") -# conn.set_schemas(get_etools_allowed_schemas(request.user)) -# else: -# value = set(value.split(",")) -# validate_schemas(*value) -# if not request.user.is_superuser: -# user_schemas = get_etools_allowed_schemas(request.user) -# if not user_schemas.issuperset(value): -# raise NotAuthorizedSchema(",".join(sorted(value - user_schemas))) -# conn.set_schemas(value) -# return queryset -# class CountryFilterBackend(SchemaFilterBackend): -# def filter_queryset(self, request, queryset, view): -# value = request.GET.get('country_name', None) -# assert queryset.model._meta.app_label != 'etools' -# if not value: -# if not request.user.is_superuser: -# allowed = get_etools_allowed_schemas(request.user) -# if not allowed: -# raise PermissionDenied("You don't have enabled schemas") -# queryset.filter(country_name__in=allowed) -# else: -# value = set(value.split(",")) -# validate_schemas(*value) -# if not request.user.is_superuser: -# user_schemas = get_etools_allowed_schemas(request.user) -# if not user_schemas.issuperset(value): -# raise NotAuthorizedSchema(",".join(sorted(value - user_schemas))) -# queryset.filter(country_name__in=value) -# return queryset - - class CountryNameProcessor: def process_country_name(self, efilters, eexclude, field, value, request, op, param, negate, **payload): @@ -99,16 +42,6 @@ def process_country_name(self, efilters, eexclude, field, value, request, class CountryNameProcessorTenantModel(CountryNameProcessor): pass - # def process_country_name(self, filters, exclude, field, value, request, **payload): - # _f, _e = super().process_country_name({}, {}, field, value, request, **payload) - # # assert queryset.model._meta.app_label == 'etools' - # conn = connections['etools'] - # if 'country_name__iregex' in _f: - # conn.set_schemas(_f['country_name__iregex']) - # else: - # conn.set_all_schemas() - # - # return filters, exclude class MonthProcessor: diff --git a/src/etools_datamart/apps/data/admin.py b/src/etools_datamart/apps/data/admin.py index 92b717e29..129a0e643 100644 --- a/src/etools_datamart/apps/data/admin.py +++ b/src/etools_datamart/apps/data/admin.py @@ -123,6 +123,6 @@ class FAMIndicatorAdmin(DataModelAdmin): @register(models.UserStats) class UserStatsAdmin(DataModelAdmin): list_display = ('country_name', 'schema_name', 'month', 'total', 'unicef', 'logins', 'unicef_logins') - list_filter = (SchemaFilter, 'month',) + list_filter = (SchemaFilter, 'month') load_handler = load_user_report date_hierarchy = 'month' diff --git a/src/etools_datamart/apps/data/migrations/0002_intervention_partner_name.py b/src/etools_datamart/apps/data/migrations/0002_intervention_partner_name.py new file mode 100644 index 000000000..05810c918 --- /dev/null +++ b/src/etools_datamart/apps/data/migrations/0002_intervention_partner_name.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-06 13:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='intervention', + name='partner_name', + field=models.CharField(max_length=200, null=True), + ), + ] diff --git a/src/etools_datamart/apps/data/models/intervention.py b/src/etools_datamart/apps/data/models/intervention.py index 3e4fa9207..ddda23684 100644 --- a/src/etools_datamart/apps/data/models/intervention.py +++ b/src/etools_datamart/apps/data/models/intervention.py @@ -34,6 +34,7 @@ class Intervention(DataMartModel): unicef_signatory_last_name = models.CharField(max_length=30, null=True) unicef_signatory_email = models.CharField(max_length=254, null=True) + partner_name = models.CharField(max_length=200, null=True) partner_signatory_title = models.CharField(max_length=64, null=True) partner_signatory_first_name = models.CharField(max_length=64, null=True) partner_signatory_last_name = models.CharField(max_length=64, null=True) diff --git a/src/etools_datamart/apps/etl/tasks/etl.py b/src/etools_datamart/apps/etl/tasks/etl.py index 7a6a42f1a..73b9adfef 100644 --- a/src/etools_datamart/apps/etl/tasks/etl.py +++ b/src/etools_datamart/apps/etl/tasks/etl.py @@ -104,7 +104,7 @@ def load_intervention(): end_date=record.end, review_date_prc=record.review_date_prc, prc_review_document=record.prc_review_document, - + partner_name=record.agreement.partner.name, agreement_id=record.agreement.pk, partner_authorized_officer_signatory_id=get_attr(record, 'partner_authorized_officer_signatory.pk'), diff --git a/src/etools_datamart/apps/init/management/commands/init-setup.py b/src/etools_datamart/apps/init/management/commands/init-setup.py index 2ee6f08fa..d483ad8bb 100644 --- a/src/etools_datamart/apps/init/management/commands/init-setup.py +++ b/src/etools_datamart/apps/init/management/commands/init-setup.py @@ -1,5 +1,6 @@ import os import sys +import warnings from django.conf import settings from django.contrib.auth import get_user_model @@ -11,6 +12,7 @@ from django_celery_beat.models import CrontabSchedule, PeriodicTask from humanize import naturaldelta from strategy_field.utils import fqn +from unicef_rest_framework.models.acl import GroupAccessControl from etools_datamart.apps.etl.models import TaskLog from etools_datamart.celery import app @@ -83,6 +85,7 @@ def handle(self, *args, **options): "is_staff": True, "password": make_password(pwd)}) Group.objects.get_or_create(name='Guests') + all_access, __ = Group.objects.get_or_create(name='Endpoints all access') if created: # pragma: no cover self.stdout.write(f"Created superuser `{admin}` with password `{pwd}`") @@ -92,6 +95,35 @@ def handle(self, *args, **options): from unicef_rest_framework.models import Service created, deleted, total = Service.objects.load_services() self.stdout.write(f"{total} services found. {created} new. {deleted} deleted") + if os.environ.get('INVALIDATE_CACHE'): + Service.objects.invalidate_cache() + + for service in Service.objects.all(): + GroupAccessControl.objects.get_or_create( + group=all_access, + service=service, + serializers=['*'], + policy=GroupAccessControl.POLICY_ALLOW + ) + + if os.environ.get('AUTOCREATE_USERS'): + self.stdout.write("Found 'AUTOCREATE_USERS' environment variable") + self.stdout.write("Going to create new users") + try: + for entry in os.environ.get('AUTOCREATE_USERS').split(','): + user, pwd = entry.split('|') + User = get_user_model() + u, created = User.objects.get_or_create(username=user) + if created: + self.stdout.write(f"Created user {u}") + u.set_password(pwd) + u.save() + u.groups.add(all_access) + else: + self.stdout.write(f"User {u} already exists.") + + except Exception as e: + warnings.warn(f"Unable to create default users. {e}") if options['tasks'] or _all or options['refresh']: midnight, __ = CrontabSchedule.objects.get_or_create(minute=0, hour=0) diff --git a/src/etools_datamart/apps/web/static/favicon.ico b/src/etools_datamart/apps/web/static/favicon.ico new file mode 100644 index 000000000..1f67ff427 Binary files /dev/null and b/src/etools_datamart/apps/web/static/favicon.ico differ diff --git a/src/etools_datamart/apps/web/templates/base.html b/src/etools_datamart/apps/web/templates/base.html index 30d812757..c4e351edc 100644 --- a/src/etools_datamart/apps/web/templates/base.html +++ b/src/etools_datamart/apps/web/templates/base.html @@ -4,6 +4,7 @@ Title {% block head %} + {% endblock head %} diff --git a/src/etools_datamart/config/settings.py b/src/etools_datamart/config/settings.py index 46c1ced80..859e19123 100644 --- a/src/etools_datamart/config/settings.py +++ b/src/etools_datamart/config/settings.py @@ -362,7 +362,7 @@ AZURE_URL_EXPIRATION_SECS = 10800 AZURE_ACCESS_POLICY_EXPIRY = 10800 # length of time before signature expires in seconds AZURE_ACCESS_POLICY_PERMISSION = 'r' -AZURE_TOKEN_URL = 'https://login.microsoftonline.com/unicef.org/oauth2/token' +AZURE_TOKEN_URL = 'https://login.microsoftonline.com/saxix.onmicrosoft.com/oauth2/token' AZURE_GRAPH_API_BASE_URL = 'https://graph.microsoft.com' AZURE_GRAPH_API_VERSION = 'v1.0' AZURE_GRAPH_API_PAGE_SIZE = 300 @@ -389,22 +389,29 @@ } # social auth +# WARNINGS: UNICEF pipeline does not work if other provider +# are added to UNICEF AD. Dio not change below settings +# SOCIAL_AUTH_POSTGRES_JSONFIELD = True -SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL = True -SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email'] +SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL = False +SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['username'] SOCIAL_AUTH_SANITIZE_REDIRECTS = False SOCIAL_AUTH_URL_NAMESPACE = 'social' -SOCIAL_AUTH_WHITELISTED_DOMAINS = ['unicef.org', 'google.com'] +SOCIAL_AUTH_WHITELISTED_DOMAINS = ['unicef.org', ] SOCIAL_AUTH_PIPELINE = ( 'social_core.pipeline.social_auth.social_details', - 'social_core.pipeline.social_auth.social_uid', - 'social_core.pipeline.social_auth.social_user', - 'social_core.pipeline.user.get_username', - 'social_core.pipeline.user.create_user', - 'social_core.pipeline.social_auth.associate_user', - 'social_core.pipeline.social_auth.load_extra_data', - 'social_core.pipeline.user.user_details', - 'social_core.pipeline.social_auth.associate_by_email', + 'unicef_security.azure.get_unicef_user', + # 'unicef_security.azure.social_uid', + # 'social_core.pipeline.social_auth.social_uid', + # 'social_core.pipeline.social_auth.social_user', + # 'social_core.pipeline.user.get_username', + # 'social_core.pipeline.user.create_user', + # 'unicef_security.azure.get_username', + # 'unicef_security.azure.create_user', + # 'social_core.pipeline.social_auth.associate_user', + # 'social_core.pipeline.social_auth.load_extra_data', + # 'social_core.pipeline.user.user_details', + # 'social_core.pipeline.social_auth.associate_by_email', 'unicef_security.azure.default_group', ) diff --git a/src/month_field/admin.py b/src/month_field/admin.py index 7aeba526a..306259065 100644 --- a/src/month_field/admin.py +++ b/src/month_field/admin.py @@ -1,5 +1,56 @@ -from django.contrib.admin import ListFilter +from datetime import datetime +from django.contrib.admin import FieldListFilter +from django.utils.dates import MONTHS +from month_field.models import MonthField -class MonthAdminFilter(ListFilter): +today = datetime.today() + + +class MonthAdminFilter(FieldListFilter): template = 'month_field/admin/filter.html' + + def __init__(self, field, request, params, model, model_admin, field_path): + self.param_year_name = field_path + '_year' + self.param_month_name = field_path + '_month' + super().__init__(field, request, params, model, model_admin, field_path) + + def expected_parameters(self): + return [self.param_year_name, self.param_month_name] + + def month(self): + return int(self.used_parameters.get(self.expected_parameters()[1]) or 0) + + def year(self): + return int(self.used_parameters.get(self.expected_parameters()[0]) or today.year) + + def choices(self, changelist): + yield { + 'selected': self.month() == 0, + 'this_year': today.year, + 'year': self.year(), + 'value': 0, + 'query_string': changelist.get_query_string({}, ['month', 'year']), + 'label': 'Not set', + 'param_month_name': self.param_month_name, + 'param_year_name': self.param_year_name, + + } + for num, name in MONTHS.items(): + yield { + 'selected': num == self.month(), + 'this_year': today.year, # this is tricky... + 'year': self.year(), # this is tricky... + 'value': num, + 'query_string': changelist.get_query_string({}, ['month', 'year']), + 'label': name, + } + + def queryset(self, request, queryset): + if self.month(): + date = datetime(self.year(), self.month(), 1) + return queryset.filter(month=date) + return queryset + + +FieldListFilter.register(lambda f: isinstance(f, MonthField), MonthAdminFilter, True) diff --git a/src/month_field/templates/month_field/admin/filter.html b/src/month_field/templates/month_field/admin/filter.html index e149a3943..036ac1a36 100644 --- a/src/month_field/templates/month_field/admin/filter.html +++ b/src/month_field/templates/month_field/admin/filter.html @@ -1,10 +1,44 @@ - - - - - Title - - - - - +{% load i18n %} +

{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}

+ diff --git a/src/unicef_rest_framework/admin/acl.py b/src/unicef_rest_framework/admin/acl.py index 60db01277..c60548bc9 100644 --- a/src/unicef_rest_framework/admin/acl.py +++ b/src/unicef_rest_framework/admin/acl.py @@ -23,8 +23,8 @@ class Meta: class UserAccessControlAdmin(admin.ModelAdmin): - list_display = ('user', 'service', 'rate', 'serializers') - list_filter = ('user', 'service',) + list_display = ('user', 'service', 'rate', 'serializers', 'policy') + list_filter = ('user', 'policy', 'service') search_fields = ('user', 'service',) form = UserACLAdminForm @@ -33,8 +33,8 @@ def get_queryset(self, request): class GroupAccessControlAdmin(admin.ModelAdmin): - list_display = ('group', 'service', 'rate', 'serializers') - list_filter = ('group', 'service',) + list_display = ('group', 'service', 'rate', 'serializers', 'policy') + list_filter = ('group', 'policy', 'service') search_fields = ('group', 'service',) form = GroupACLAdminForm diff --git a/src/unicef_rest_framework/admin/service.py b/src/unicef_rest_framework/admin/service.py index 87ab4f19d..7d7027990 100644 --- a/src/unicef_rest_framework/admin/service.py +++ b/src/unicef_rest_framework/admin/service.py @@ -79,7 +79,7 @@ def security(self, object): def json(self, obj): if obj.endpoint: - return mark_safe("call".format(obj.endpoint)) + return mark_safe("api".format(obj.endpoint)) else: return '' @@ -115,6 +115,10 @@ def refresh(self, request): info = self.model._meta.app_label, self.model._meta.model_name return HttpResponseRedirect(reverse('admin:%s_%s_changelist' % info)) + @link(label='Invalidate Cache') + def invalidate_all_cache(self, request): + Service.objects.invalidate_cache() + @action() def invalidate_cache(self, request, pk): service = Service.objects.get(pk=pk) diff --git a/src/unicef_rest_framework/models/service.py b/src/unicef_rest_framework/models/service.py index 4900df2ff..f7a6dfb95 100644 --- a/src/unicef_rest_framework/models/service.py +++ b/src/unicef_rest_framework/models/service.py @@ -21,6 +21,11 @@ class ServiceManager(models.Manager): + def invalidate_cache(self, **kwargs): + Service.objects.filter(**kwargs).update(cache_version=F("cache_version") + 1) + for service in Service.objects.filter(**kwargs): + service.viewset.get_service.cache_clear() + cluster_cache.set('{}{}'.format(service.pk, service.name), True) def get_for_viewset(self, viewset): name = getattr(viewset, 'label', viewset.__name__) @@ -111,10 +116,8 @@ class Meta: objects = ServiceManager() def invalidate_cache(self): - Service.objects.filter(id=self.pk).update(cache_version=F("cache_version") + 1) + Service.objects.invalidate_cache(id=self.pk) self.refresh_from_db() - self.viewset.get_service.cache_clear() - cluster_cache.set('{}{}'.format(self.pk, self.name), True) def reset_cache(self, value=0): Service.objects.filter(id=self.pk).update(cache_version=value) diff --git a/src/unicef_rest_framework/templates/rest_framework/base.html b/src/unicef_rest_framework/templates/rest_framework/base.html index abdeadcd4..368265510 100644 --- a/src/unicef_rest_framework/templates/rest_framework/base.html +++ b/src/unicef_rest_framework/templates/rest_framework/base.html @@ -6,7 +6,7 @@ {% block head %} - + {% block meta %} diff --git a/src/unicef_security/azure.py b/src/unicef_security/azure.py index e92fa3d89..7b8134dd1 100644 --- a/src/unicef_security/azure.py +++ b/src/unicef_security/azure.py @@ -1,11 +1,13 @@ import logging import requests +from constance import config as constance from crashlog.middleware import process_exception from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.cache import cache +from social_django.models import UserSocialAuth from . import config @@ -35,9 +37,31 @@ def default_group(**kwargs): user.is_superuser = True user.save() else: - g = Group.objects.filter(name=config.DEFAULT_GROUP).first() + g = Group.objects.filter(name=constance.DEFAULT_GROUP).first() if g: - g.add(user) + user.groups.add(g) + + +def get_unicef_user(backend, details, response, *args, **kwargs): + from .models import User + user, created = User.objects.get_or_create( + username=details['username'], + defaults={'first_name': details['first_name'], + 'last_name': details['last_name'], + } + ) + # FIXME: use MSGRAPH to get user email + # if created: + # sync = Synchronizer() + # data = sync.get_user(user.username) + + social, __ = UserSocialAuth.objects.get_or_create(user=user, + provider=backend.name, + uid=user.username + ) + return {'user': user, + 'social': social, + 'is_new': created} class SyncResult: