diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..9e6dc3d --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +exclude = .git,__pycache__,venv,finalvenv, venv,migrations +max-line-length = 79 \ No newline at end of file diff --git a/finalvenv/bin/black b/finalvenv/bin/black new file mode 100755 index 0000000..8dce75b --- /dev/null +++ b/finalvenv/bin/black @@ -0,0 +1,10 @@ +#!/home/pasokon/Projects/backend-final-assignment-arvid-edvinsson/finalvenv/bin/python +# -*- coding: utf-8 -*- +import re +import sys + +from black import patched_main + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(patched_main()) diff --git a/finalvenv/bin/blackd b/finalvenv/bin/blackd new file mode 100755 index 0000000..3e478c3 --- /dev/null +++ b/finalvenv/bin/blackd @@ -0,0 +1,10 @@ +#!/home/pasokon/Projects/backend-final-assignment-arvid-edvinsson/finalvenv/bin/python +# -*- coding: utf-8 -*- +import re +import sys + +from blackd import patched_main + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(patched_main()) diff --git a/finalvenv/bin/django-admin b/finalvenv/bin/django-admin index fad4394..a1004aa 100755 --- a/finalvenv/bin/django-admin +++ b/finalvenv/bin/django-admin @@ -2,7 +2,9 @@ # -*- coding: utf-8 -*- import re import sys + from django.core.management import execute_from_command_line + if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) sys.exit(execute_from_command_line()) diff --git a/finalvenv/bin/flake8 b/finalvenv/bin/flake8 new file mode 100755 index 0000000..66d8556 --- /dev/null +++ b/finalvenv/bin/flake8 @@ -0,0 +1,10 @@ +#!/home/pasokon/Projects/backend-final-assignment-arvid-edvinsson/finalvenv/bin/python +# -*- coding: utf-8 -*- +import re +import sys + +from flake8.main.cli import main + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/finalvenv/bin/isort b/finalvenv/bin/isort new file mode 100755 index 0000000..80282b8 --- /dev/null +++ b/finalvenv/bin/isort @@ -0,0 +1,10 @@ +#!/home/pasokon/Projects/backend-final-assignment-arvid-edvinsson/finalvenv/bin/python +# -*- coding: utf-8 -*- +import re +import sys + +from isort.main import main + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/finalvenv/bin/isort-identify-imports b/finalvenv/bin/isort-identify-imports new file mode 100755 index 0000000..ed1df88 --- /dev/null +++ b/finalvenv/bin/isort-identify-imports @@ -0,0 +1,10 @@ +#!/home/pasokon/Projects/backend-final-assignment-arvid-edvinsson/finalvenv/bin/python +# -*- coding: utf-8 -*- +import re +import sys + +from isort.main import identify_imports_main + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(identify_imports_main()) diff --git a/finalvenv/bin/pip b/finalvenv/bin/pip index 6da36b5..d6e945d 100755 --- a/finalvenv/bin/pip +++ b/finalvenv/bin/pip @@ -2,7 +2,9 @@ # -*- coding: utf-8 -*- import re import sys + from pip._internal.cli.main import main + if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) sys.exit(main()) diff --git a/finalvenv/bin/pip3 b/finalvenv/bin/pip3 index 6da36b5..d6e945d 100755 --- a/finalvenv/bin/pip3 +++ b/finalvenv/bin/pip3 @@ -2,7 +2,9 @@ # -*- coding: utf-8 -*- import re import sys + from pip._internal.cli.main import main + if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) sys.exit(main()) diff --git a/finalvenv/bin/pip3.12 b/finalvenv/bin/pip3.12 index 6da36b5..d6e945d 100755 --- a/finalvenv/bin/pip3.12 +++ b/finalvenv/bin/pip3.12 @@ -2,7 +2,9 @@ # -*- coding: utf-8 -*- import re import sys + from pip._internal.cli.main import main + if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) sys.exit(main()) diff --git a/finalvenv/bin/pycodestyle b/finalvenv/bin/pycodestyle new file mode 100755 index 0000000..2384a0e --- /dev/null +++ b/finalvenv/bin/pycodestyle @@ -0,0 +1,10 @@ +#!/home/pasokon/Projects/backend-final-assignment-arvid-edvinsson/finalvenv/bin/python +# -*- coding: utf-8 -*- +import re +import sys + +from pycodestyle import _main + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(_main()) diff --git a/finalvenv/bin/pyflakes b/finalvenv/bin/pyflakes new file mode 100755 index 0000000..8dd292a --- /dev/null +++ b/finalvenv/bin/pyflakes @@ -0,0 +1,10 @@ +#!/home/pasokon/Projects/backend-final-assignment-arvid-edvinsson/finalvenv/bin/python +# -*- coding: utf-8 -*- +import re +import sys + +from pyflakes.api import main + +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/finalvenv/bin/sqlformat b/finalvenv/bin/sqlformat index 984456e..d1d8609 100755 --- a/finalvenv/bin/sqlformat +++ b/finalvenv/bin/sqlformat @@ -2,7 +2,9 @@ # -*- coding: utf-8 -*- import re import sys + from sqlparse.__main__ import main + if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) sys.exit(main()) diff --git a/followers/admin.py b/followers/admin.py index 8721cd6..1d7cf23 100644 --- a/followers/admin.py +++ b/followers/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from .models import Follow admin.site.register(Follow) diff --git a/followers/apps.py b/followers/apps.py index 1c742d7..de59152 100644 --- a/followers/apps.py +++ b/followers/apps.py @@ -2,5 +2,5 @@ class FollowersConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'followers' + default_auto_field = "django.db.models.BigAutoField" + name = "followers" diff --git a/followers/migrations/0001_initial.py b/followers/migrations/0001_initial.py index cd814a3..551c7c6 100644 --- a/followers/migrations/0001_initial.py +++ b/followers/migrations/0001_initial.py @@ -7,15 +7,22 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Follow', + name="Follow", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), ], ), ] diff --git a/followers/migrations/0002_initial.py b/followers/migrations/0002_initial.py index d793654..341ccab 100644 --- a/followers/migrations/0002_initial.py +++ b/followers/migrations/0002_initial.py @@ -10,23 +10,31 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('followers', '0001_initial'), + ("followers", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='follow', - name='followed', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='followers', to=settings.AUTH_USER_MODEL), + model_name="follow", + name="followed", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="followers", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='follow', - name='follower', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='following', to=settings.AUTH_USER_MODEL), + model_name="follow", + name="follower", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="following", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AlterUniqueTogether( - name='follow', - unique_together={('follower', 'followed')}, + name="follow", + unique_together={("follower", "followed")}, ), ] diff --git a/followers/models.py b/followers/models.py index a8455a2..b7c59c5 100644 --- a/followers/models.py +++ b/followers/models.py @@ -1,14 +1,23 @@ -from django.db import models from django.conf import settings +from django.db import models class Follow(models.Model): - follower = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='following') - followed = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='followers') + follower = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="following", + ) + + followed = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="followers", + ) created_at = models.DateTimeField(auto_now_add=True) class Meta: - unique_together = ('follower', 'followed') + unique_together = ("follower", "followed") def __str__(self): return f"{self.follower.username} follows {self.followed.username}" diff --git a/followers/tests.py b/followers/tests.py index 7ce503c..e69de29 100644 --- a/followers/tests.py +++ b/followers/tests.py @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/followers/urls.py b/followers/urls.py index b8c700a..4e70e89 100644 --- a/followers/urls.py +++ b/followers/urls.py @@ -1,7 +1,10 @@ from django.urls import path + from . import views urlpatterns = [ - path('follow//', views.follow_user, name='follow_user'), - path('unfollow//', views.unfollow_user, name='unfollow_user') + path("follow//", views.follow_user, name="follow_user"), + path( + "unfollow//", views.unfollow_user, name="unfollow_user" + ), ] diff --git a/followers/views.py b/followers/views.py index 1627c56..54397be 100644 --- a/followers/views.py +++ b/followers/views.py @@ -1,15 +1,12 @@ -from django.shortcuts import redirect, get_object_or_404 from django.contrib.auth.decorators import login_required -from django.contrib import messages -from users.models import CustomUser -from .models import Follow +from django.shortcuts import redirect @login_required def follow_user(request, username): - return redirect('home') + return redirect("home") @login_required def unfollow_user(request, username): - return redirect('home') \ No newline at end of file + return redirect("home") diff --git a/manage.py b/manage.py index f672733..ae32e4b 100755 --- a/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'twittercopy.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "twittercopy.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9216134 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.black] +line-length = 79 \ No newline at end of file diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..a364395 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,247 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', sans-serif; + background-color: #f0f2f5; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + color: #1a202c; + padding: 20px; +} + +/* Main Container for Login Form */ +.login-container { + background-color: #ffffff; + padding: 40px; + border-radius: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; + text-align: center; +} + +/* Heading */ +.login-container h2 { + font-size: 2.25rem; + color: #1da1f2; + margin-bottom: 30px; + font-weight: 700; +} + +/* Form Styling */ +.login-container form { + display: flex; + flex-direction: column; + gap: 15px; +} + +/* Input Fields */ +.login-container input[type="text"], +.login-container input[type="password"], +.login-container input[type="email"] { + width: 100%; + padding: 14px 16px; + border: 1px solid #ccd6dd; + border-radius: 8px; + font-size: 1rem; + color: #1a202c; + outline: none; + transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; +} + +.login-container input[type="text"]:focus, +.login-container input[type="password"]:focus, +.login-container input[type="email"]:focus { + border-color: #1da1f2; + box-shadow: 0 0 0 3px rgba(29, 161, 242, 0.2); +} + +/* Paragraphs for form fields */ +.login-container form p { + margin-bottom: 0; + text-align: left; +} + +.login-container form p label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #4a5568; + font-size: 0.9rem; +} + +/* Error messages for form fields */ +.login-container form ul.errorlist { + list-style: none; + padding: 0; + margin-top: 5px; + color: #e53e3e; + font-size: 0.875rem; + text-align: left; +} + +/* Submit Button */ +.login-container button[type="submit"] { + background-color: #1da1f2; + color: #ffffff; + padding: 14px 20px; + border: none; + border-radius: 9999px; + font-size: 1.1rem; + font-weight: 700; + cursor: pointer; + transition: background-color 0.2s ease-in-out, transform 0.1s ease-in-out; + margin-top: 15px; +} + +.login-container button[type="submit"]:hover { + background-color: #0c85d0; + transform: translateY(-1px); +} + +.login-container button[type="submit"]:active { + transform: translateY(0); +} + +.login-container p.link-text { + margin-top: 25px; + font-size: 0.95rem; + color: #4a5568; +} + +.login-container p.link-text a { + color: #1da1f2; + text-decoration: none; + font-weight: 600; + transition: text-decoration 0.2s ease-in-out; +} + +.login-container p.link-text a:hover { + text-decoration: underline; +} + +/* --- Home Screen Specific Styles --- */ + +/* Main Container for Home Screen Content */ +.home-container { + background-color: #ffffff; + padding: 40px; + border-radius: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 600px; + text-align: center; + margin: auto; +} + +/* Main Heading (Twitter clone) */ +.home-container h1 { + font-size: 2.5rem; + color: #1da1f2; + margin-bottom: 20px; + font-weight: 700; +} + +/* Paragraphs (e.g., "Hello, {{ user.username }}!", "What would you like to do today?") */ +.home-container p { + font-size: 1.1rem; + line-height: 1.6; + margin-bottom: 15px; + color: #4a5568; +} + +/* Span for username highlighting */ +.home-container p span { + color: #1da1f2; + font-weight: 700; +} + +/* Container for authenticated user links (View Tweets, Create Tweet, My Profile, Logout) */ +.home-container .app-links { + margin-top: 30px; + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; +} + +/* Container for unauthenticated user links (Log In, Sign Up) */ +.home-container .auth-links { + margin-top: 30px; + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; +} + +/* Styling for all links within app-links and auth-links */ +.home-container .app-links a, +.home-container .auth-links a { + display: inline-block; + background-color: #1da1f2; + color: #ffffff; + padding: 12px 25px; + border: none; + border-radius: 9999px; + text-decoration: none; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s ease-in-out, transform 0.1s ease-in-out; + min-width: 180px; + text-align: center; +} + +.home-container .app-links a:hover, +.home-container .auth-links a:hover { + background-color: #0c85d0; + transform: translateY(-1px); +} + +/* Styling for the "Or explore as a guest." text */ +.home-container p.small-link { + font-size: 0.9rem; + color: #4a5568; + margin-top: 20px; +} + +.home-container p.small-link a { + color: #1da1f2; + text-decoration: none; + font-weight: 600; +} + +.home-container p.small-link a:hover { + text-decoration: underline; +} + +/* Responsive Adjustments for Home Screen */ +@media (max-width: 600px) { + body { + padding: 15px; + } + .home-container { + padding: 30px 25px; + border-radius: 12px; + max-width: 100%; + } + .home-container h1 { + font-size: 2rem; + } + .home-container p { + font-size: 1rem; + } + .home-container .app-links a, + .home-container .auth-links a { + padding: 10px 20px; + font-size: 0.9rem; + min-width: unset; + width: 100%; + } +} diff --git a/templates/home.html b/templates/home.html index 07cdc3a..ad5fbfd 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,21 +1,38 @@ +{% load static %} + {# Link to your styles.css #} + {# Google Fonts link #} Twitter Clone Home -

Twitter clone

- {% if user.is_authenticated %} -

Hello, {{ user.username }}!

-

View Tweets

-

Create New Tweet

-

My Profile

-

Logout

- {% else %} -

Login

-

Admin

- {% endif %} +
+

Twitter clone

+ + {% if user.is_authenticated %} +

Hello, {{ user.username }}!

+

What would you like to do today?

+ + + {% else %} +

Join the conversation or log in to see what's happening.

{# Added for context #} + + + {# Removed Admin link from here as it's not typically a user-facing link on a home screen #} + + {% endif %} +
- \ No newline at end of file + diff --git a/templates/registration/login.html b/templates/registration/login.html index 9788802..1e35e0e 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -1,17 +1,22 @@ +{% load static %} - Login + Sign Up + + -

Login

-
- {% csrf_token %} - {{ form.as_p }} - -
-

Don't have an account? Register here (add registration URL later)

+ \ No newline at end of file diff --git a/templates/registration/signup.html b/templates/registration/signup.html new file mode 100644 index 0000000..c948bf6 --- /dev/null +++ b/templates/registration/signup.html @@ -0,0 +1,22 @@ +{% load static %} + + + + + + Sign Up + + + + + + + \ No newline at end of file diff --git a/templates/tweets/tweet-list.html b/templates/tweets/tweet-list.html new file mode 100644 index 0000000..9c79a98 --- /dev/null +++ b/templates/tweets/tweet-list.html @@ -0,0 +1,15 @@ +{% load static %} + + + + + + Sign Up + + + + +
+ + + \ No newline at end of file diff --git a/tweets/admin.py b/tweets/admin.py index 60abad1..62c1a9e 100644 --- a/tweets/admin.py +++ b/tweets/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from .models import Tweet admin.site.register(Tweet) diff --git a/tweets/apps.py b/tweets/apps.py index ec4ceff..852304f 100644 --- a/tweets/apps.py +++ b/tweets/apps.py @@ -2,5 +2,5 @@ class TweetsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'tweets' + default_auto_field = "django.db.models.BigAutoField" + name = "tweets" diff --git a/tweets/migrations/0001_initial.py b/tweets/migrations/0001_initial.py index ca0d64a..5cddea9 100644 --- a/tweets/migrations/0001_initial.py +++ b/tweets/migrations/0001_initial.py @@ -7,19 +7,26 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Tweet', + name="Tweet", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('content', models.CharField(max_length=280)), - ('created_at', models.DateTimeField(auto_now_add=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("content", models.CharField(max_length=280)), + ("created_at", models.DateTimeField(auto_now_add=True)), ], options={ - 'ordering': ['-created_at'], + "ordering": ["-created_at"], }, ), ] diff --git a/tweets/migrations/0002_initial.py b/tweets/migrations/0002_initial.py index ed88f0f..36f2081 100644 --- a/tweets/migrations/0002_initial.py +++ b/tweets/migrations/0002_initial.py @@ -10,19 +10,27 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('tweets', '0001_initial'), + ("tweets", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( - model_name='tweet', - name='likes', - field=models.ManyToManyField(blank=True, related_name='liked_tweets', to=settings.AUTH_USER_MODEL), + model_name="tweet", + name="likes", + field=models.ManyToManyField( + blank=True, + related_name="liked_tweets", + to=settings.AUTH_USER_MODEL, + ), ), migrations.AddField( - model_name='tweet', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tweets', to=settings.AUTH_USER_MODEL), + model_name="tweet", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tweets", + to=settings.AUTH_USER_MODEL, + ), ), ] diff --git a/tweets/models.py b/tweets/models.py index 781d3e4..264b2bd 100644 --- a/tweets/models.py +++ b/tweets/models.py @@ -1,18 +1,27 @@ -from django.db import models from django.conf import settings +from django.db import models class Tweet(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='tweets') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="tweets", + ) content = models.CharField(max_length=280) created_at = models.DateTimeField(auto_now_add=True) - likes = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='liked_tweets', blank=True) + likes = models.ManyToManyField( + settings.AUTH_USER_MODEL, related_name="liked_tweets", blank=True + ) class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] def __str__(self): - return f"Tweet by {self.user.username} at {self.created_at.strftime('%Y-%m-%d %H:%M')}" + return ( + f"Tweet by {self.user.username} at " + f"{self.created_at.strftime('%Y-%m-%d %H:%M')}" + ) @property def number_of_likes(self): diff --git a/tweets/tests.py b/tweets/tests.py index 7ce503c..e69de29 100644 --- a/tweets/tests.py +++ b/tweets/tests.py @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/tweets/urls.py b/tweets/urls.py index 1613a63..e4a015a 100644 --- a/tweets/urls.py +++ b/tweets/urls.py @@ -1,8 +1,11 @@ from django.urls import path + from . import views +app_name = "tweets" + urlpatterns = [ - path('', views.tweet_list_view, name='tweet_list'), - path('create/', views.tweet_create_view, name='tweet_create'), - path('/like/', views.tweet_like_view, name='tweet_like'), + path("", views.tweet_list_view, name="home"), + path("create/", views.tweet_create_view, name="create"), + path("/like/", views.tweet_like_view, name="tweet_like"), ] diff --git a/tweets/views.py b/tweets/views.py index 71a1d1d..39fd179 100644 --- a/tweets/views.py +++ b/tweets/views.py @@ -1,17 +1,20 @@ -from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect, render + from .models import Tweet def tweet_list_view(request): tweets = Tweet.objects.all() - context = {'tweets': tweets} - return render(request, 'tweets/tweet_list.html', context) + context = {"tweets": tweets} + return render(request, "tweets/tweet-list.html", context) + @login_required def tweet_create_view(request): - return render(request, 'tweets/tweet_create.html') + return render(request, "tweets/tweet-create.html") + @login_required def tweet_like_view(request, pk): - return redirect('tweet_list') + return redirect("tweet_list") diff --git a/twittercopy/asgi.py b/twittercopy/asgi.py index ed2f9e8..8ebef04 100644 --- a/twittercopy/asgi.py +++ b/twittercopy/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'twittercopy.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "twittercopy.settings") application = get_asgi_application() diff --git a/twittercopy/settings.py b/twittercopy/settings.py index 8a4b9b1..38f21d2 100644 --- a/twittercopy/settings.py +++ b/twittercopy/settings.py @@ -20,68 +20,70 @@ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-&i&e-j89jj^up*yi^b#9^g584cfn1n$xk$f2g-j+s)b7mh$@0k' +SECRET_KEY = ( + "django-insecure-&i&e-j89jj^up*yi^b#9^g584cfn1n$xk$f2g-j+s)b7mh$@0k" +) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['localhost', '127.0.0.1', '.pythonanywhere.com'] +ALLOWED_HOSTS = ["localhost", "127.0.0.1", ".pythonanywhere.com"] -LOGIN_REDIRECT_URL = 'home' -LOGOUT_REDIRECT_URL = 'login' +LOGIN_REDIRECT_URL = "home" +LOGOUT_REDIRECT_URL = "login" # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'users', - 'tweets', - 'followers' + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "users", + "tweets", + "followers", ] 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", ] -ROOT_URLCONF = 'twittercopy.urls' +ROOT_URLCONF = "twittercopy.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'twittercopy.wsgi.application' +WSGI_APPLICATION = "twittercopy.wsgi.application" # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -91,27 +93,32 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": ( + "django.contrib.auth.password_validation." + "UserAttributeSimilarityValidator" + ), }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "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." + "NumericPasswordValidator" + ), }, ] -AUTH_USER_MODEL = 'users.CustomUser' +AUTH_USER_MODEL = "users.CustomUser" # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'Asia/Tokyo' +TIME_ZONE = "Asia/Tokyo" USE_I18N = True @@ -121,12 +128,10 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ -STATIC_URL = 'static/' -STATICFILES_DIRS = [ - BASE_DIR / 'static' -] +STATIC_URL = "static/" +STATICFILES_DIRS = [BASE_DIR / "static"] # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/twittercopy/urls.py b/twittercopy/urls.py index 2761efd..cf032b9 100644 --- a/twittercopy/urls.py +++ b/twittercopy/urls.py @@ -1,12 +1,26 @@ from django.contrib import admin -from django.urls import path, include +from django.urls import include, path from django.views.generic.base import TemplateView +from users.views import SignUpView + urlpatterns = [ - path('admin/', admin.site.urls), - path('accounts/', include('django.contrib.auth.urls')), - path('users/', include('users.urls')), - path('tweets/', include('tweets.urls')), - path('followers/', include('followers.urls')), - path('', TemplateView.as_view(template_name='home.html'), name='home') + path("admin/", admin.site.urls), + path( + "account/", + include( + ( + [ + path("signup/", SignUpView.as_view(), name="signup"), + path("", include("django.contrib.auth.urls")), + ], + "account", + ), + namespace="account", + ), + ), + path("users/", include("users.urls")), + path("tweets/", include(("tweets.urls", "tweets"), namespace="tweets")), + path("followers/", include("followers.urls")), + path("", TemplateView.as_view(template_name="home.html"), name="home"), ] diff --git a/twittercopy/wsgi.py b/twittercopy/wsgi.py index 6e9831f..ca306e3 100644 --- a/twittercopy/wsgi.py +++ b/twittercopy/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'twittercopy.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "twittercopy.settings") application = get_wsgi_application() diff --git a/users/admin.py b/users/admin.py index 5ef0f20..134494e 100644 --- a/users/admin.py +++ b/users/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin + from .models import CustomUser admin.site.register(CustomUser, UserAdmin) diff --git a/users/apps.py b/users/apps.py index 72b1401..88f7b17 100644 --- a/users/apps.py +++ b/users/apps.py @@ -2,5 +2,5 @@ class UsersConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'users' + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/users/forms.py b/users/forms.py new file mode 100644 index 0000000..91d300c --- /dev/null +++ b/users/forms.py @@ -0,0 +1,12 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm + +from .models import CustomUser + + +class CustomUserCreationForm(UserCreationForm): + email = forms.EmailField(required=True) + + class Meta(UserCreationForm.Meta): + model = CustomUser + fields = UserCreationForm.Meta.fields + ("email",) diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index ac12b09..136f2a8 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -11,35 +11,132 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ("auth", "0012_alter_user_first_name_max_length"), ] operations = [ migrations.CreateModel( - name='CustomUser', + name="CustomUser", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('bio', models.TextField(blank=True, max_length=500, null=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "password", + models.CharField(max_length=128, verbose_name="password"), + ), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, + max_length=254, + verbose_name="email address", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="date joined", + ), + ), + ( + "bio", + models.TextField(blank=True, max_length=500, null=True), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), ], options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, }, managers=[ - ('objects', django.contrib.auth.models.UserManager()), + ("objects", django.contrib.auth.models.UserManager()), ], ), ] diff --git a/users/tests.py b/users/tests.py index 7ce503c..9def9a3 100644 --- a/users/tests.py +++ b/users/tests.py @@ -1,3 +1,252 @@ +from django.contrib.auth import SESSION_KEY, get_user_model from django.test import TestCase +from django.urls import reverse -# Create your tests here. +User = get_user_model() + + +class TestSignupView(TestCase): + def setUp(self): + self.url = reverse("account:signup") + self.initial_user_count = User.objects.count() + + def test_success_get(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "registration/signup.html") + + def test_success_post(self): + valid_data = { + "username": "testuser", + "email": "test@test.com", + "password1": "testpassword", + "password2": "testpassword", + } + response = self.client.post(self.url, valid_data) + + # 1の確認 = tweets/homeにリダイレクトすること + self.assertRedirects( + response, + reverse("tweets:home"), + status_code=302, + target_status_code=200, + ) + # 2の確認 = ユーザーが作成されること + self.assertTrue( + User.objects.filter(username=valid_data["username"]).exists() + ) + # 3の確認 = ログイン状態になること + self.assertIn(SESSION_KEY, self.client.session) + + def test_failure_post_with_empty_username(self): + invalid_data = { + "username": "", + "email": "test@test.com", + "password1": "testpassword", + "password2": "testpassword", + } + response = self.client.post(self.url, invalid_data) + form = response.context["form"] + + self.assertEqual(response.status_code, 200) + self.assertFalse( + User.objects.filter(username=invalid_data["username"]).exists() + ) + self.assertFalse(form.is_valid()) + self.assertIn("This field is required.", form.errors["username"]) + + def test_failure_post_with_empty_email(self): + invalid_data = { + "username": "tester", + "email": "", + "password1": "testpassword", + "password2": "testpassword", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + + form = response.context["form"] + + self.assertFalse(form.is_valid()) + self.assertEqual(User.objects.count(), self.initial_user_count) + self.assertIn("This field is required.", form.errors["email"]) + + def test_failure_post_with_empty_password(self): + invalid_data = { + "username": "tester", + "email": "tester@tester.com", + "password1": "", + "password2": "", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + + form = response.context["form"] + + self.assertFalse(form.is_valid()) + self.assertEqual(User.objects.count(), self.initial_user_count) + self.assertIn("This field is required.", form.errors["password1"]) + self.assertIn("This field is required.", form.errors["password2"]) + + def test_failure_post_with_empty_form(self): + invalid_data = { + "username": "", + "email": "", + "password1": "", + "password2": "", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + + form = response.context["form"] + + self.assertFalse(form.is_valid()) + self.assertEqual(User.objects.count(), self.initial_user_count) + + self.assertIn("This field is required.", form.errors["username"]) + self.assertIn("This field is required.", form.errors["email"]) + self.assertIn("This field is required.", form.errors["password1"]) + self.assertIn("This field is required.", form.errors["password2"]) + + def test_failure_post_with_duplicated_user(self): + + User.objects.create_user( + username="tester", email="tester@test.com", password="testpassword" + ) + + invalid_data = { + "username": "tester", + "email": "tester@test.com", + "password1": "testpassword", + "password2": "testpassword", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + + form = response.context["form"] + + self.assertEqual(User.objects.count(), 1) + self.assertFalse(form.is_valid()) + self.assertIn( + "A user with that username already exists.", + form.errors["username"], + ) + + def test_failure_post_with_invalid_email(self): + invalid_data = { + "username": "tester", + "email": "invalidemail", + "password1": "testpassword", + "password2": "testpassword", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(User.objects.count(), self.initial_user_count) + + form = response.context["form"] + + self.assertFalse(form.is_valid()) + self.assertIn("Enter a valid email address.", form.errors["email"]) + + def test_failure_post_with_too_short_password(self): + invalid_data = { + "username": "tester", + "email": "test@test.com", + "password1": "t", + "password2": "t", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(User.objects.count(), self.initial_user_count) + + form = response.context["form"] + + self.assertFalse(form.is_valid()) + self.assertIn( + "This password is too short. " + "It must contain at least 8 characters.", + form.errors["password2"], + ) + + def test_failure_post_with_password_similar_to_username(self): + invalid_data = { + "username": "testingname", + "email": "test@test.com", + "password1": "testingname", + "password2": "testingname", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(User.objects.count(), self.initial_user_count) + + form = response.context["form"] + + self.assertFalse(form.is_valid()) + self.assertIn( + "The password is too similar to the username.", + form.errors["password2"], + ) + + def test_failure_post_with_only_numbers_password(self): + invalid_data = { + "username": "testingname", + "email": "test@test.com", + "password1": "123123123", + "password2": "123123123", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(User.objects.count(), self.initial_user_count) + + form = response.context["form"] + + self.assertFalse(form.is_valid()) + self.assertIn( + "This password is entirely numeric.", form.errors["password2"] + ) + + def test_failure_post_with_mismatch_password(self): + invalid_data = { + "username": "testingname", + "email": "test@test.com", + "password1": "testpassword1", + "password2": "testpassword2", + } + + response = self.client.post(self.url, invalid_data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(User.objects.count(), self.initial_user_count) + + form = response.context["form"] + + self.assertFalse(form.is_valid()) + self.assertIn( + "The two password fields didn’t match.", form.errors["password2"] + ) + + +class TestHomePageView(TestCase): + def setUp(self): + self.url = reverse("home") + + def test_success_get_home_page(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "home.html") diff --git a/users/urls.py b/users/urls.py index 2a9865b..c6bf83d 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,7 +1,9 @@ - from django.urls import path + from . import views +app_name = "users" + urlpatterns = [ - path('profile//', views.profile_view, name='profile'), + path("profile//", views.profile_view, name="profile"), ] diff --git a/users/views.py b/users/views.py index d7c7fde..b39a919 100644 --- a/users/views.py +++ b/users/views.py @@ -1,8 +1,25 @@ -from django.shortcuts import render, get_object_or_404 +from django.contrib.auth import login +from django.shortcuts import get_object_or_404, render +from django.urls import reverse_lazy +from django.views import generic + +from .forms import CustomUserCreationForm from .models import CustomUser def profile_view(request, username): user = get_object_or_404(CustomUser, username=username) - context = {'user': user} - return render(request, 'users/profile.html', context) + context = {"user": user} + return render(request, "users/profile.html", context) + + +class SignUpView(generic.CreateView): + form_class = CustomUserCreationForm + success_url = reverse_lazy("tweets:home") + template_name = "registration/signup.html" + + def form_valid(self, form): + response = super().form_valid(form) + user = form.save() + login(self.request, user) + return response