diff --git a/.gitignore b/.gitignore index 42c5ed8f..d5e84246 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ cython_debug/ .env deployed_static static_deploy +media python.log # direnv diff --git a/config/settings.py b/config/settings.py index dd22d263..5ede656b 100755 --- a/config/settings.py +++ b/config/settings.py @@ -1,5 +1,6 @@ import environs import logging +import os import structlog import subprocess import sys @@ -207,6 +208,11 @@ STATICFILES_DIRS = [ # after running collectstatic STATIC_ROOT = str(BASE_DIR.joinpath("static_deploy")) +# Directory where uploaded media is saved. +MEDIA_ROOT = os.path.join(BASE_DIR, "media") +# Public URL at the browser +MEDIA_URL = "/media/" + # Logging setup # Configure struct log structlog.configure( diff --git a/config/urls.py b/config/urls.py index eb67d93a..2acf36d1 100755 --- a/config/urls.py +++ b/config/urls.py @@ -1,4 +1,6 @@ +from django.conf import settings from django.conf.urls import include, re_path +from django.conf.urls.static import static from django.contrib import admin from django.views.generic import TemplateView from django.urls import path @@ -35,129 +37,135 @@ router.register(r"versions", VersionViewSet, basename="versions") router.register(r"libraries", LibrarySearchView, basename="libraries") -urlpatterns = [ - path("", HomepageView.as_view(), name="home"), - path("admin/", admin.site.urls), - path("accounts/", include("allauth.urls")), - path("users/me/", CurrentUserView.as_view(), name="current-user"), - path("users//", ProfileViewSet.as_view(), name="profile-user"), - path("api/v1/", include(router.urls)), - path("200", OKView.as_view(), name="ok"), - path("403", ForbiddenView.as_view(), name="forbidden"), - path("404", NotFoundView.as_view(), name="not_found"), - path("500", InternalServerErrorView.as_view(), name="internal_server_error"), - path( - "about/", - TemplateView.as_view(template_name="boost/about.html"), - name="boost-about", - ), - path("health/", include("health_check.urls")), - path("forum/", include(machina_urls)), - path( - "donate/", - TemplateView.as_view(template_name="donate/donate.html"), - name="donate", - ), - path( - "libraries-by-category//", - LibraryByCategory.as_view(), - name="libraries-by-category", - ), - path("libraries/", LibraryList.as_view(), name="libraries"), - path( - "libraries//", - LibraryDetail.as_view(), - name="library-detail", - ), - path( - "people/detail/", - TemplateView.as_view(template_name="boost/people_detail.html"), - name="boost-people-detail", - ), - path( - "people/", - TemplateView.as_view( - template_name="boost/people.html", extra_context={"range": range(50)} +urlpatterns = ( + [ + path("", HomepageView.as_view(), name="home"), + path("admin/", admin.site.urls), + path("accounts/", include("allauth.urls")), + path("users/me/", CurrentUserView.as_view(), name="current-user"), + path("users//", ProfileViewSet.as_view(), name="profile-user"), + path("api/v1/", include(router.urls)), + path("200", OKView.as_view(), name="ok"), + path("403", ForbiddenView.as_view(), name="forbidden"), + path("404", NotFoundView.as_view(), name="not_found"), + path("500", InternalServerErrorView.as_view(), name="internal_server_error"), + path( + "about/", + TemplateView.as_view(template_name="boost/about.html"), + name="boost-about", ), - name="boost-people", - ), - path( - "moderators/", - TemplateView.as_view( - template_name="boost/moderators.html", extra_context={"range": range(50)} + path("health/", include("health_check.urls")), + path("forum/", include(machina_urls)), + path( + "donate/", + TemplateView.as_view(template_name="donate/donate.html"), + name="donate", ), - name="boost-moderators", - ), - path( - "resources/", - TemplateView.as_view(template_name="resources/resources.html"), - name="resources", - ), - path( - "review/past/", - TemplateView.as_view(template_name="review/past_reviews.html"), - name="review-past", - ), - path( - "review/request/", - TemplateView.as_view(template_name="review/review_form.html"), - name="review-request", - ), - path( - "review/upcoming/", - TemplateView.as_view(template_name="review/upcoming_reviews.html"), - name="review-upcoming", - ), - path( - "review/detail/", - TemplateView.as_view(template_name="review/review_detail.html"), - name="review-request", - ), - path( - "review/", - TemplateView.as_view(template_name="review/review_process.html"), - name="review-process", - ), - path( - "news/detail/", - TemplateView.as_view(template_name="news/news_detail.html"), - name="news_detail", - ), - path( - "news/", - TemplateView.as_view(template_name="news/news_list.html"), - name="news", - ), - # support and contact views - path("support/", SupportView.as_view(), name="support"), - path( - "getting-started/", - TemplateView.as_view(template_name="support/getting_started.html"), - name="getting-started", - ), - path("contact/", ContactView.as_view(), name="contact"), - # Boost versions views - path( - "versions//libraries-by-category//", - LibraryListByVersionByCategory.as_view(), - name="libraries-by-version-by-category", - ), - path( - "versions//libraries/", - LibraryListByVersion.as_view(), - name="libraries-by-version", - ), - path( - "versions///", - LibraryDetailByVersion.as_view(), - name="library-detail-by-version", - ), - path("versions//", VersionDetail.as_view(), name="version-detail"), - path("versions/", VersionList.as_view(), name="version-list"), - # Markdown content - re_path( - r"^(?P.+)/?", - MarkdownTemplateView.as_view(), - name="markdown-page", - ), -] + path( + "libraries-by-category//", + LibraryByCategory.as_view(), + name="libraries-by-category", + ), + path("libraries/", LibraryList.as_view(), name="libraries"), + path( + "libraries//", + LibraryDetail.as_view(), + name="library-detail", + ), + path( + "people/detail/", + TemplateView.as_view(template_name="boost/people_detail.html"), + name="boost-people-detail", + ), + path( + "people/", + TemplateView.as_view( + template_name="boost/people.html", extra_context={"range": range(50)} + ), + name="boost-people", + ), + path( + "moderators/", + TemplateView.as_view( + template_name="boost/moderators.html", + extra_context={"range": range(50)}, + ), + name="boost-moderators", + ), + path( + "resources/", + TemplateView.as_view(template_name="resources/resources.html"), + name="resources", + ), + path( + "review/past/", + TemplateView.as_view(template_name="review/past_reviews.html"), + name="review-past", + ), + path( + "review/request/", + TemplateView.as_view(template_name="review/review_form.html"), + name="review-request", + ), + path( + "review/upcoming/", + TemplateView.as_view(template_name="review/upcoming_reviews.html"), + name="review-upcoming", + ), + path( + "review/detail/", + TemplateView.as_view(template_name="review/review_detail.html"), + name="review-request", + ), + path( + "review/", + TemplateView.as_view(template_name="review/review_process.html"), + name="review-process", + ), + path( + "news/detail/", + TemplateView.as_view(template_name="news/news_detail.html"), + name="news_detail", + ), + path( + "news/", + TemplateView.as_view(template_name="news/news_list.html"), + name="news", + ), + # support and contact views + path("support/", SupportView.as_view(), name="support"), + path( + "getting-started/", + TemplateView.as_view(template_name="support/getting_started.html"), + name="getting-started", + ), + path("contact/", ContactView.as_view(), name="contact"), + # Boost versions views + path( + "versions//libraries-by-category//", + LibraryListByVersionByCategory.as_view(), + name="libraries-by-version-by-category", + ), + path( + "versions//libraries/", + LibraryListByVersion.as_view(), + name="libraries-by-version", + ), + path( + "versions///", + LibraryDetailByVersion.as_view(), + name="library-detail-by-version", + ), + path("versions//", VersionDetail.as_view(), name="version-detail"), + path("versions/", VersionList.as_view(), name="version-list"), + ] + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + [ + # Markdown content + re_path( + r"^(?P.+)/?", + MarkdownTemplateView.as_view(), + name="markdown-page", + ), + ] +) diff --git a/libraries/github.py b/libraries/github.py index 1026132d..28f0ebfa 100644 --- a/libraries/github.py +++ b/libraries/github.py @@ -22,6 +22,11 @@ def get_api(): return GhApi(token=token) +def get_user_by_username(api, username): + """Return the response from GitHub's /users/{username}/""" + return api.users.get_by_username(username=username) + + def get_repo(api, owner, repo): """ Return the response from GitHub's /repos/{owner}/{repo} diff --git a/libraries/tests/fixtures.py b/libraries/tests/fixtures.py index 7e129a7c..3be7c1ea 100644 --- a/libraries/tests/fixtures.py +++ b/libraries/tests/fixtures.py @@ -75,6 +75,22 @@ def github_api_get_tree_response(db): } +@pytest.fixture +def github_api_get_user_by_username_response(db): + """Returns a JSON example of GhApi().api.users.get_by_username(username)""" + return { + "login": "testerson", + "id": 2286306, + "avatar_url": "https://avatars.githubusercontent.com/u/2286306?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/testerson", + "name": "Tester Testerson", + "email": None, + "created_at": "2012-09-05T17:17:25Z", + "updated_at": "2023-02-03T15:41:37Z", + } + + @pytest.fixture def github_api_get_repo_response(db): """Returns a JSON example of GhApi().api.repos.get(owner=owner, repo=repo)""" diff --git a/libraries/tests/test_github.py b/libraries/tests/test_github.py index fe88aff8..917440cc 100644 --- a/libraries/tests/test_github.py +++ b/libraries/tests/test_github.py @@ -6,7 +6,12 @@ from dateutil.parser import parse from ghapi.all import GhApi from model_bakery import baker -from libraries.github import GithubUpdater, LibraryUpdater, get_api +from libraries.github import ( + GithubUpdater, + LibraryUpdater, + get_api, + get_user_by_username, +) from libraries.models import Issue, Library, PullRequest @@ -15,6 +20,16 @@ def test_get_api(): assert isinstance(result, GhApi) +@pytest.mark.skip("The mock isn't working and is hitting the live API") +def test_get_user_by_username(github_api_get_user_by_username_response): + api = get_api() + with patch("libraries.github.get_user_by_username") as get_user_mock: + get_user_mock.return_value = github_api_get_user_by_username_response + result = get_user_by_username(api, "testerson") + assert result == github_api_get_user_by_username_response + assert "avatar_url" in result + + # GithubUpdater tests diff --git a/templates/users/profile.html b/templates/users/profile.html index 6cfaeab6..f4dee805 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -5,7 +5,7 @@ {% block subnav %}
- user + user
{{ user.get_full_name }} diff --git a/users/admin.py b/users/admin.py index 5899e829..8148c8e7 100644 --- a/users/admin.py +++ b/users/admin.py @@ -8,7 +8,10 @@ from .models import User class EmailUserAdmin(UserAdmin): fieldsets = ( (None, {"fields": ("email", "password")}), - (_("Personal info"), {"fields": ("first_name", "last_name")}), + ( + _("Personal info"), + {"fields": ("first_name", "last_name", "github_username")}, + ), ( _("Permissions"), { @@ -23,6 +26,7 @@ class EmailUserAdmin(UserAdmin): ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Data"), {"fields": ("data",)}), + (_("Image"), {"fields": ("image",)}), ) add_fieldsets = ( (None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}), diff --git a/users/management/commands/photos_test.py b/users/management/commands/photos_test.py new file mode 100644 index 00000000..5da29de3 --- /dev/null +++ b/users/management/commands/photos_test.py @@ -0,0 +1,28 @@ +from django.core.management import BaseCommand +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class Command(BaseCommand): + def __init__(self, *args, **kwargs): + super(Command, self).__init__(*args, **kwargs) + + help = "Convenience command to test saving a photo from a GH profile" + + def handle(self, *args, **options): + """ + Having trouble getting the patch to work in the test for the underlying function. + This is a functional test. + Replace "testing" with your username and run `./manage.py photos_test`, + then you will see your photo in the admin for whoever your first user is. + """ + user = User.objects.first() + user.image = None + user.github_username = "testing" + user.save() + user.refresh_from_db() + assert bool(user.image) is False + user.save_image_from_github() + user.refresh_from_db() + assert bool(user.image) is True diff --git a/users/migrations/0007_user_image.py b/users/migrations/0007_user_image.py new file mode 100644 index 00000000..bf99e992 --- /dev/null +++ b/users/migrations/0007_user_image.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.2 on 2023-02-15 21:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0006_auto_20220209_1545"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="image", + field=models.FileField(blank=True, null=True, upload_to="profile-images"), + ), + ] diff --git a/users/models.py b/users/models.py index cfc8e9d4..907fdeb1 100644 --- a/users/models.py +++ b/users/models.py @@ -1,16 +1,24 @@ import logging -from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver +import os +import requests + from django.conf import settings from django.contrib.auth.models import ( AbstractBaseUser, PermissionsMixin, BaseUserManager, ) +from django.core.files import File from django.core.mail import send_mail -from django.utils.translation import gettext_lazy as _ +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver from django.utils import timezone +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ + +from libraries.github import get_api as get_github_api +from libraries.github import get_user_by_username as get_github_user logger = logging.getLogger(__name__) @@ -138,6 +146,34 @@ class Badge(models.Model): class User(BaseUser): badges = models.ManyToManyField(Badge) github_username = models.CharField(_("github username"), max_length=100, blank=True) + image = models.FileField(upload_to="profile-images", null=True, blank=True) + + def save_image_from_github(self): + if not self.github_username: + # todo: log + return + + api = get_github_api() + result = get_github_user(api, self.github_username) + avatar_url = result.get("avatar_url") + + if not avatar_url: + # todo: log + return + + response = requests.get(avatar_url) + base_filename = f"{slugify(self.get_full_name())}-profile" + filename = f"{base_filename}.png" + img_path = os.path.join( + settings.MEDIA_ROOT, "media", "profile-images", filename + ) + + with open(filename, "wb") as f: + f.write(response.content) + + reopen = open(filename, "rb") + django_file = File(reopen) + self.image.save(filename, django_file, save=True) class LastSeen(models.Model): diff --git a/users/tests/fixtures.py b/users/tests/fixtures.py index 6cca6719..a6a5fe5f 100644 --- a/users/tests/fixtures.py +++ b/users/tests/fixtures.py @@ -14,6 +14,7 @@ def user(db): first_name="Regular", last_name="User", last_login=timezone.now(), + image="static/img/fpo/user.png", ) user.set_password("password") user.save() @@ -31,6 +32,7 @@ def staff_user(db): last_name="User", last_login=timezone.now(), is_staff=True, + image="static/img/fpo/user.png", ) user.set_password("password") user.save() @@ -49,6 +51,7 @@ def super_user(db): last_login=timezone.now(), is_staff=True, is_superuser=True, + image="static/img/fpo/user.png", ) user.set_password("password") user.save()