diff --git a/.dockerignore b/.dockerignore index e63c0c1844fda5ac3ba4a5c4d34d222908d62d33..02c7fe526bff11ba557c86fe9a5d48a668bf11d3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,4 @@ !.coveragerc !.env !.pylintrc +*.pyc diff --git a/config/settings/base.py b/config/settings/base.py index b1b03d97ca0b96827d2a5417864e389c584ae1ef..5e76b09fb5f8d3d4cce83ff53cc9d98ce950f324 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -4,32 +4,36 @@ Base settings to build other settings files upon. import environ -ROOT_DIR = environ.Path(__file__) - 3 # (contributions/config/settings/base.py - 3 = contributions/) -APPS_DIR = ROOT_DIR.path('contributions') +ROOT_DIR = ( + environ.Path(__file__) - 3 +) # (contributions/config/settings/base.py - 3 = contributions/) +APPS_DIR = ROOT_DIR.path("contributions") env = environ.Env() -READ_DOT_ENV_FILE = env.bool('DJANGO_READ_DOT_ENV_FILE', default=False) +READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) if READ_DOT_ENV_FILE: # OS environment variables take precedence over variables from .env - env.read_env(str(ROOT_DIR.path('.env'))) + env.read_env(str(ROOT_DIR.path(".env"))) -SECRET_KEY = env('DJANGO_SECRET_KEY') -ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=['contributions.funkwhale.audio']) +SECRET_KEY = env("DJANGO_SECRET_KEY") +ALLOWED_HOSTS = env.list( + "DJANGO_ALLOWED_HOSTS", default=["contributions.funkwhale.audio"] +) # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = env.bool('DJANGO_DEBUG', False) +DEBUG = env.bool("DJANGO_DEBUG", False) # Local time zone. Choices are # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # though not all of them may be available with every OS. # In Windows, this must be set to your system time zone. -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" # https://docs.djangoproject.com/en/dev/ref/settings/#language-code -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" # https://docs.djangoproject.com/en/dev/ref/settings/#site-id SITE_ID = 1 # https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n @@ -42,17 +46,15 @@ USE_TZ = True # DATABASES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#databases -DATABASES = { - 'default': env.db('DATABASE_URL'), -} -DATABASES['default']['ATOMIC_REQUESTS'] = True -DATABASES['default']['CONN_MAX_AGE'] = env.int('CONN_MAX_AGE', default=60) # noqa F405 +DATABASES = {"default": env.db("DATABASE_URL")} +DATABASES["default"]["ATOMIC_REQUESTS"] = True +DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # noqa F405 CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': '' + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "", } } @@ -60,113 +62,100 @@ CACHES = { # URLS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf -ROOT_URLCONF = 'config.urls' +ROOT_URLCONF = "config.urls" # https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application -WSGI_APPLICATION = 'config.wsgi.application' +WSGI_APPLICATION = "config.wsgi.application" # APPS # ------------------------------------------------------------------------------ DJANGO_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", # 'django.contrib.humanize', # Handy template tags - 'django.contrib.admin', + "django.contrib.admin", ] THIRD_PARTY_APPS = [ - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'rest_framework', + "allauth", + "allauth.account", + "allauth.socialaccount", + "rest_framework", ] LOCAL_APPS = [ - 'contributions.users.apps.UsersAppConfig', + "contributions.users.apps.UsersAppConfig", # Your stuff: custom apps go here + "contributions.core", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS -# MIGRATIONS -# ------------------------------------------------------------------------------ -# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules -MIGRATION_MODULES = { - 'sites': 'contributions.contrib.sites.migrations' -} # AUTHENTICATION # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", ] # https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model -AUTH_USER_MODEL = 'users.User' +AUTH_USER_MODEL = "users.User" # https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url -LOGIN_REDIRECT_URL = 'users:redirect' +LOGIN_REDIRECT_URL = "users:redirect" # https://docs.djangoproject.com/en/dev/ref/settings/#login-url -LOGIN_URL = 'account_login' +LOGIN_URL = "account_login" # PASSWORDS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.PBKDF2PasswordHasher', - 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', - 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', - 'django.contrib.auth.hashers.BCryptPasswordHasher', + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", + "django.contrib.auth.hashers.BCryptPasswordHasher", ] # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] # MIDDLEWARE # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#middleware MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] # STATIC # ------------------------------------------------------------------------------ -STATIC_URL = env('STATIC_URL', default='/static/') -STATIC_ROOT = env('STATIC_ROOT', default=str(ROOT_DIR('staticfiles'))) +STATIC_URL = env("STATIC_URL", default="/static/") +STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR("staticfiles"))) # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS -STATICFILES_DIRS = [ - str(APPS_DIR.path('static')), -] +STATICFILES_DIRS = [] # https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders STATICFILES_FINDERS = [ - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] # MEDIA # ------------------------------------------------------------------------------ -MEDIA_URL = env('MEDIA_URL', default='/media/') -MEDIA_ROOT = env('MEDIA_ROOT', default=str(APPS_DIR('media'))) +MEDIA_URL = env("MEDIA_URL", default="/media/") +MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media"))) # TEMPLATES # ------------------------------------------------------------------------------ @@ -174,71 +163,69 @@ MEDIA_ROOT = env('MEDIA_ROOT', default=str(APPS_DIR('media'))) TEMPLATES = [ { # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND - 'BACKEND': 'django.template.backends.django.DjangoTemplates', + "BACKEND": "django.template.backends.django.DjangoTemplates", # https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs - 'DIRS': [ - str(APPS_DIR.path('templates')), - ], - 'OPTIONS': { + "DIRS": [str(APPS_DIR.path("templates"))], + "OPTIONS": { # https://docs.djangoproject.com/en/dev/ref/settings/#template-debug - 'debug': DEBUG, + "debug": DEBUG, # https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types - 'loaders': [ - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", ], # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - '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', + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "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", ], }, - }, + } ] -TEMPLATES[0]['OPTIONS']['debug'] = DEBUG # noqa F405 +TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG # noqa F405 # FIXTURES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs -FIXTURE_DIRS = ( - str(APPS_DIR.path('fixtures')), -) +FIXTURE_DIRS = (str(APPS_DIR.path("fixtures")),) # EMAIL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') +EMAIL_BACKEND = env( + "DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend" +) DEFAULT_FROM_EMAIL = env( - 'DJANGO_DEFAULT_FROM_EMAIL', - default='Contributions <noreply@contributions.funkwhale.audio>' + "DJANGO_DEFAULT_FROM_EMAIL", + default="Contributions <noreply@contributions.funkwhale.audio>", ) -SERVER_EMAIL = env('DJANGO_SERVER_EMAIL', default=DEFAULT_FROM_EMAIL) +SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) # https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix -EMAIL_SUBJECT_PREFIX = env('DJANGO_EMAIL_SUBJECT_PREFIX', default='[Contributions]') +EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX", default="[Contributions]") # https://docs.djangoproject.com # ADMIN # ------------------------------------------------------------------------------ # Django Admin URL. -ADMIN_URL = env('DJANGO_ADMIN_URL', default='admin/') +ADMIN_URL = env("DJANGO_ADMIN_URL", default="admin/") # django-allauth # ------------------------------------------------------------------------------ -ACCOUNT_ALLOW_REGISTRATION = env.bool('DJANGO_ACCOUNT_ALLOW_REGISTRATION', False) +ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", False) # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_AUTHENTICATION_METHOD = 'username' +ACCOUNT_AUTHENTICATION_METHOD = "username" # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_EMAIL_REQUIRED = True # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_EMAIL_VERIFICATION = 'mandatory' +ACCOUNT_EMAIL_VERIFICATION = "mandatory" # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_ADAPTER = 'contributions.users.adapters.AccountAdapter' +ACCOUNT_ADAPTER = "contributions.users.adapters.AccountAdapter" # https://django-allauth.readthedocs.io/en/latest/configuration.html -SOCIALACCOUNT_ADAPTER = 'contributions.users.adapters.SocialAccountAdapter' +SOCIALACCOUNT_ADAPTER = "contributions.users.adapters.SocialAccountAdapter" diff --git a/config/urls.py b/config/urls.py index c140490b381470373f4a324292e2ae6aecfc11a8..322ca7b959292931a44764c036b8e66a151c1f6e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -5,23 +5,7 @@ from django.contrib import admin from django.views.generic import TemplateView from django.views import defaults as default_views -urlpatterns = [ - path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), - path( - "about/", - TemplateView.as_view(template_name="pages/about.html"), - name="about", - ), - # Django Admin, use {% url 'admin:index' %} - path(settings.ADMIN_URL, admin.site.urls), - # User management - path( - "users/", - include("contributions.users.urls", namespace="users"), - ), - path("accounts/", include("allauth.urls")), - # Your stuff: custom urls includes go here -] + static( +urlpatterns = [path(settings.ADMIN_URL, admin.site.urls)] + static( settings.MEDIA_URL, document_root=settings.MEDIA_ROOT ) diff --git a/contributions/core/__init__.py b/contributions/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/contributions/core/admin.py b/contributions/core/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..224ca306530f7399329bd6f186f7fd84abc9d23b --- /dev/null +++ b/contributions/core/admin.py @@ -0,0 +1,31 @@ +from django.contrib import admin + +from . import models + + +@admin.register(models.Contributor) +class ContributorAdmin(admin.ModelAdmin): + list_display = ["username", "name", "creation_date"] + search_fields = ["username", "name"] + + +@admin.register(models.Contribution) +class ContributionAdmin(admin.ModelAdmin): + list_display = [ + "contributor", + "summary", + "creation_date", + "import_date", + "type", + "external_id", + ] + search_fields = [ + "contributor__username", + "contributor__name", + "summary", + "external_id", + ] + + list_filter = ["contributor", "type"] + + list_select_related = ["contributor"] diff --git a/contributions/core/migrations/0001_initial.py b/contributions/core/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..25745296aa2852b9368a01502cbaaa1a9f69899a --- /dev/null +++ b/contributions/core/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 2.1.2 on 2018-10-06 15:48 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Contribution', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('summary', models.CharField(max_length=500)), + ('type', models.CharField(choices=[('dev', 'Development'), ('i18n', 'Translations'), ('network', 'Network'), ('donation', 'Donations'), ('communication', 'Communication'), ('other', 'Other')], max_length=50)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('import_date', models.DateTimeField(default=django.utils.timezone.now)), + ('is_visible', models.BooleanField(default=True)), + ('external_id', models.CharField(max_length=250, unique=True)), + ('url', models.URLField()), + ('metadata', django.contrib.postgres.fields.jsonb.JSONField()), + ], + ), + migrations.CreateModel( + name='Contributor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=200, unique=True)), + ('name', models.CharField(max_length=200)), + ('creation_date', models.DateTimeField(default=django.utils.timezone.now)), + ('is_visible', models.BooleanField(default=True)), + ('metadata', django.contrib.postgres.fields.jsonb.JSONField()), + ], + ), + migrations.AddField( + model_name='contribution', + name='contributor', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contributions', to='core.Contributor'), + ), + ] diff --git a/contributions/core/migrations/__init__.py b/contributions/core/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/contributions/core/models.py b/contributions/core/models.py new file mode 100644 index 0000000000000000000000000000000000000000..a84207895880a31ba4b1afee58a119a3f4a5134c --- /dev/null +++ b/contributions/core/models.py @@ -0,0 +1,44 @@ +from django.contrib.postgres.fields import JSONField +from django.db import models +from django.utils import timezone + + +class Contributor(models.Model): + username = models.CharField(max_length=200, unique=True) + name = models.CharField(max_length=200) + creation_date = models.DateTimeField(default=timezone.now) + is_visible = models.BooleanField(default=True) + metadata = JSONField() + + class Meta: + ordering = ("-creation_date",) + + def __str__(self): + return self.username + + +CONTRIBUTION_TYPES = [ + ("dev", "Development"), + ("i18n", "Translations"), + ("network", "Network"), + ("donation", "Donations"), + ("communication", "Communication"), + ("other", "Other"), +] + + +class Contribution(models.Model): + contributor = models.ForeignKey( + Contributor, related_name="contributions", on_delete=models.CASCADE + ) + summary = models.CharField(max_length=500) + type = models.CharField(max_length=50, choices=CONTRIBUTION_TYPES) + creation_date = models.DateTimeField(default=timezone.now) + import_date = models.DateTimeField(default=timezone.now) + is_visible = models.BooleanField(default=True) + external_id = models.CharField(max_length=250, unique=True) + url = models.URLField() + metadata = JSONField() + + class Meta: + ordering = ("-creation_date",) diff --git a/contributions/sources/gitlab.py b/contributions/sources/gitlab.py new file mode 100644 index 0000000000000000000000000000000000000000..e92ad82405c8ac1928830a8cbf1738ab5663741e --- /dev/null +++ b/contributions/sources/gitlab.py @@ -0,0 +1,59 @@ +import requests +import pendulum + +from django.utils import timezone + +from contributions.core import models as core_models + + +def retrieve_issue(gitlab_url, project_id, issue_id): + url = f"{gitlab_url}/api/v4/projects/{project_id}/issues/{issue_id}" + response = requests.get(url) + response.raise_for_status() + return response.json() + + +def retrieve_issue_page(url): + response = requests.get(url) + response.raise_for_status() + + links = response.links + next_page = None + if links and links.get("next"): + next_page = links["next"]["url"] + return response.json(), next_page + + +def import_contributor_from_author(payload): + contributor = None + try: + contributor = core_models.Contributor.objects.get(username=payload["username"]) + except core_models.Contributor.DoesNotExist: + return core_models.Contributor.objects.create( + username=payload["username"], + name=payload["name"], + metadata={"gitlab": payload}, + ) + + contributor.metadata["gitlab"] = payload + contributor.save(update_fields=["metadata"]) + return contributor + + +def import_issue_as_contribution(payload): + contributor = import_contributor_from_author(payload["author"]) + external_id = payload["_links"]["self"] + defaults = { + "summary": payload["title"], + "contributor": contributor, + "type": "dev", + "creation_date": pendulum.parse(payload["created_at"]), + "import_date": timezone.now(), + "is_visible": True, + "url": payload["web_url"], + "metadata": {"labels": payload["labels"]}, + } + + return core_models.Contribution.objects.update_or_create( + external_id=external_id, defaults=defaults + )[0] diff --git a/local.yml b/local.yml index b2bb4721e97f1ac201fa2241d12195854aa8f75b..48733ccb67d927f4784b1b0d773fb0d8c2bcf6b1 100644 --- a/local.yml +++ b/local.yml @@ -1,9 +1,5 @@ version: '3' -volumes: - local_postgres_data: {} - local_postgres_data_backups: {} - services: django: build: @@ -27,7 +23,6 @@ services: dockerfile: ./compose/production/postgres/Dockerfile image: contributions_production_postgres volumes: - - local_postgres_data:/var/lib/postgresql/data - - local_postgres_data_backups:/backups + - ./data/postgres:/var/lib/postgresql/data env_file: - ./.envs/.local/.postgres diff --git a/pytest.ini b/pytest.ini index 5b4369b89d53164b249c798da8cfc26c466c9e29..36617795ad5f63dd4e96db67193b678d1d36e1ec 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] DJANGO_SETTINGS_MODULE=config.settings.test +testpaths = tests diff --git a/requirements/base.txt b/requirements/base.txt index 4d4b5e2a9904aba232c9997d0054fdc23f8086fe..43c17318ab2ad62e8fcad121e1a6013be7ce42de 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -12,3 +12,5 @@ django-redis==4.9.0 # https://github.com/niwinz/django-redis # Django REST Framework djangorestframework>=3.8,<3.9 # https://github.com/encode/django-rest-framework psycopg2==2.7.4 --no-binary psycopg2 # https://github.com/psycopg/psycopg2 +requests +pendulum diff --git a/requirements/local.txt b/requirements/local.txt index 25ea1a028095432ba11d481efbb529f5ffb49959..0af83ecb1c01071c3d4642a4f4b125dd6fff9356 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -23,3 +23,5 @@ django-debug-toolbar==1.10.1 # https://github.com/jazzband/django-debug-toolbar django-extensions==2.1.3 # https://github.com/django-extensions/django-extensions django-coverage-plugin==1.6.0 # https://github.com/nedbat/django_coverage_plugin pytest-django==3.4.3 # https://github.com/pytest-dev/pytest-django +requests-mock==1.5.2 +pytest-mock==1.10.0 diff --git a/tests/conftest.py b/tests/conftest.py index f73ac88b4357881e746be856182e65325881d0fa..e8feb802baf99a3a04d995f6c2e33c99b0e78b49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,28 @@ import pytest from django.conf import settings from django.test import RequestFactory +from django.utils import timezone - -@pytest.fixture(autouse=True) -def media_storage(settings, tmpdir): - settings.MEDIA_ROOT = tmpdir.strpath +from . import factories as test_factories @pytest.fixture def request_factory() -> RequestFactory: return RequestFactory() + + +@pytest.fixture +def factories(db) -> test_factories: + return test_factories + + +@pytest.fixture +def nodb_factories() -> test_factories: + return test_factories + + +@pytest.fixture +def now(mocker): + n = timezone.now() + mocker.patch.object(timezone, 'now', return_value=n) + return n diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..d88e384c3732235dbdecbc3274396e6c4a34ba04 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,23 @@ +import factory + +from contributions.core import models as core_models + + +class Contributor(factory.DjangoModelFactory): + name = factory.Faker('name') + metadata = factory.LazyAttribute(lambda o: {}) + + class Meta: + model = core_models.Contributor + + +class Contribution(factory.DjangoModelFactory): + contributor = factory.SubFactory(Contributor) + summary = factory.Faker('paragraph') + type = factory.Iterator([t[0] for t in core_models.CONTRIBUTION_TYPES]) + external_id = factory.Faker('uuid4') + url = factory.Faker('url') + metadata = factory.LazyAttribute(lambda o: {}) + + class Meta: + model = core_models.Contribution diff --git a/tests/test_gitlab.py b/tests/test_gitlab.py new file mode 100644 index 0000000000000000000000000000000000000000..f05ca253a5b75733dea22f9d919b0841c902f413 --- /dev/null +++ b/tests/test_gitlab.py @@ -0,0 +1,94 @@ +from contributions.sources import gitlab +import pendulum + + +def test_retrieve_issue(requests_mock): + gitlab_url = "https://gitlab.test" + project_id = 1 + issue_id = 42 + + requests_mock.get( + f"https://gitlab.test/api/v4/projects/{project_id}/issues/{issue_id}", + json={"hello": "world"}, + ) + result = gitlab.retrieve_issue(gitlab_url, project_id, issue_id) + + assert result == {"hello": "world"} + + +def test_import_issue_as_contribution(now, db): + issue_payload = { + "id": 703, + "iid": 559, + "project_id": 17, + "title": "My awesome issue", + "created_at": "2018-10-05T15:23:17.257Z", + "labels": ["tag1", "tag2", "tag3"], + "author": { + "id": 130, + "name": "Alice", + "username": "Alice42", + "avatar_url": "https://secure.gravatar.com/avatar/1ed9cb8b4bdad28157104cd2f9468b3f?s=80\u0026d=identicon", + "web_url": "https://gitlab.test/Alice42", + }, + "web_url": "https://gitlab.test/awesome/project/issues/559", + "_links": {"self": "https://gitlab.test/api/v4/projects/17/issues/559"}, + } + + contribution = gitlab.import_issue_as_contribution(issue_payload) + + contributor = contribution.contributor + + assert contributor.name == issue_payload["author"]["name"] + assert contributor.username == issue_payload["author"]["username"] + assert contributor.metadata == {"gitlab": issue_payload["author"]} + + assert contribution.summary == issue_payload["title"] + assert contribution.type == "dev" + assert contribution.creation_date == pendulum.parse(issue_payload["created_at"]) + assert contribution.import_date == now + assert contribution.is_visible is True + assert contribution.external_id == issue_payload["_links"]["self"] + assert contribution.url == issue_payload["web_url"] + assert contribution.metadata == {"labels": issue_payload["labels"]} + + +def test_import_contributor_create(db): + payload = {"id": 130, "name": "Alice", "username": "Alice42"} + + contributor = gitlab.import_contributor_from_author(payload) + + assert contributor.name == payload["name"] + assert contributor.username == payload["username"] + assert contributor.metadata == {"gitlab": payload} + + +def test_import_contributor_update_metadata(factories): + existing = factories.Contributor(username="Alice42", metadata={"hello": "world"}) + payload = {"id": 130, "name": "Alice", "username": "Alice42"} + + contributor = gitlab.import_contributor_from_author(payload) + contributor == existing + + assert contributor.name == existing.name + assert contributor.username == existing.username + assert contributor.metadata == {"gitlab": payload, "hello": "world"} + + +def test_retrieve_issue_page(requests_mock): + gitlab_url = "https://gitlab.test" + project_id = 1 + url = f"{gitlab_url}/api/v4/projects/{project_id}/issues" + + expected_next_page = ( + f"https://gitlab.test/api/v4/projects/{project_id}/issues?page=2" + ) + requests_mock.get( + f"https://gitlab.test/api/v4/projects/{project_id}/issues", + json=["hello", "world"], + headers={"Link": f'<{expected_next_page}>; rel="next"'}, + ) + result, next_page = gitlab.retrieve_issue_page(url) + + assert result == ["hello", "world"] + assert expected_next_page == next_page diff --git a/tests/test_models.py b/tests/test_models.py index 6ca5ac1b59eac984285cf31edbf1748d9c35aecc..2c64ebb22b51d4f6ed552ff4c9d9bb01486bd1cf 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,20 +1,35 @@ from django.utils import timezone -from . import factories +from contributions.core import models as core_models -def test_contributor_factory(): - c = factories.Contributor( - name='alice', +def test_contributor_factory(factories): + contributor = factories.Contributor( + name="alice", is_visible=True, metadata={"hello": "world"} + ) + + assert contributor.name == "alice" + assert contributor.creation_date < timezone.now() + assert contributor.is_visible is True + assert contributor.metadata == {"hello": "world"} + + +def test_contribution_factory(factories): + contribution = factories.Contribution( + external_id="weblate:front", + metadata={"locale": "en"}, is_visible=True, - links={ - 'hello': 'world' - } + url="https://weblate.test", + summary="Hello", + type="i18n:translation", ) - assert c.name == 'alice' - assert c.creation_date < timezone.now() - assert c.is_visible is True - assert c.links == { - 'hello': 'world' - } + assert isinstance(contribution.contributor, core_models.Contributor) + assert contribution.creation_date < timezone.now() + assert contribution.import_date < timezone.now() + assert contribution.is_visible is True + assert contribution.metadata == {"locale": "en"} + assert contribution.summary == "Hello" + assert contribution.type == "i18n:translation" + assert contribution.external_id == "weblate:front" + assert contribution.url == "https://weblate.test"