diff --git a/social_network/README.md b/social_network/README.md index b00af60727745120c455dc9c870ae2b4eee146b8..0395cf63800fa10d9a03140641258bffb3711ecf 100644 --- a/social_network/README.md +++ b/social_network/README.md @@ -1,7 +1,424 @@ # The Social Network -The package "The Social Network" is a django base backend core element for any possible social network you can think of. +The package "The Social Network" is a django base backend core element for any possible social network you can think of. You can easily create clones for all popular social networks. -It contains the following models +## Installation +Minimum requierments are: -Django.Authentication.User +> [Python](https://www.python.org/downloads/) >= 3.9 +> [Django](https://pypi.org/project/Django/) >= 3.2.9 +> [Pillow](https://pypi.org/project/Pillow/) >= 8.4.0 +> [djangorestframework](https://pypi.org/project/djangorestframework/) >= 3.12.4 + +Intallation can be done by **pip** like + +> pip install the-social-network + +or download manuel on [Pypi](https://pypi.org/project/the-social-network/). + +## How to use + +If you not have already created a django python project, [create](https://docs.djangoproject.com/en/3.2/intro/tutorial01/) it at first in a new directory with the command + +> django-admin startproject mysite + +This will create a **mysite** directory in your current directory. + +Open the **mysite** directory and open the **settings.py**. +Add *'the_social_network'* to **INSTALLED_APPS** and save the file. + +Next open the urls.py and add the following lines to your **urlpatterns** + +> url(r'^authentication/', include('the_social_network.urls.authenticationUrls')), +> url(r'^accounts/', include('the_social_network.urls.accountUrls')), +> url(r'^search/', include('the_social_network.urls.searchUrls')), +> url(r'^contents/', include('the_social_network.urls.contentUrls')) + +Now everything is ready to run django with **the-social-network**. +Create the database with +> python manage.py migrate + +and start the server with +> python manage.py runserver + +the default django information page should showup if you open [http://127.0.0.1:8000/](http://127.0.0.1:8000/) in your browser. + +## Request API for the URLs + +In all requests (except for the request of *authentication/register/* or */authentication/login/*) you need to send the authentification token inside the header. +For authorization use the header name "Authorization" and the value "Token <token>" + +#### Authentication +##### POST url: ".../authentication/register/" +Register a user + +Requestbody: +```json +{ + "username": "username", + "password": "password", + "email": "email" +} +``` +Responsebody: +```json +{ + "token": "token" +} +``` + +##### POST url: ".../authentication/login/" +Login a user + +Requestbody: +```json +{ + "username": "username", + "password": "password" +} +``` +Responsebody: +```json +{ + "token": "token" +} +``` + +##### POST url: ".../authentication/logout/" +Logout a user + +Requestbody: None +Responsebody: None +Success: HTTP/200 + +##### GET url: ".../authentication/validate/" +Validate a token + +Requestbody: None +Responsebody: None +Success: HTTP/200 + +#### Account +##### GET url: ".../accounts/show/<user_id>/" +Show a public user + +Requestbody: None +Responsebody: +```json +[{ + "user": { + "id": ..., + "username": ..., + "email": ..., + "date_joined": "..." + }, + "image": "...", + "biography": "...", + "related_by": [], + "related_to": [], + "statements": [] +}] +``` + + +##### GET url: ".../accounts/show/own/" +Show the own user + +Requestbody: None +Responsebody: +```json +[{ + "user": { + "id": ..., + "username": ..., + "email": ..., + "date_joined": "..." + }, + "image": "...", + "biography": "...", + "related_by": [], + "related_to": [], + "statements": [] +}] +``` + +##### GET url: ".../accounts/show/all/" +Show all public users + +Requestbody: None +Responsebody: +```json +[{ + "user": { + "id": ..., + "username": ..., + "email": ..., + "date_joined": "..." + }, + "image": "...", + "biography": "...", + "related_by": [], + "related_to": [], + "statements": [] +}, +... +] +``` + +##### PUT url: ".../accounts/update/" +Updates the own account. Only "Biography" and "Image" are allowed to be updated. + +Requestbody: +```json +{ + "biography": "...", + "file": "..." +} +``` +Responsebody: None +Success: HTTP/200 + +##### PUT url: ".../accounts/follow/<user_id>/" +Follow a user + +Requestbody: None +Responsebody: None +Success: HTTP/200 + +##### PUT url: ".../accounts/unfollow/<user_id>/" +Unfollow a user + +Requestbody: None +Responsebody: None +Success: HTTP/200 + +##### PUT url: ".../accounts/operation/add/statement/" +Add a statement to the own account + +Requestbody: +```json +{ + "input": "<statement>" + "reactions": { "to": <reaction_to_a_statement_id>, "relation": <"attack" or "support">} <--- optional +} +``` +Responsebody: +```json +{ + "id": ..., + "author": { + "user": { + "id": ..., + "username": "..." + }, + "image": "..." + }, + "content": "...", + "tagged": [], + "mentioned": [], + "created": "...", + "relation_to_parent": ... +} +``` +Success: HTTP/200 + +#### Contents +##### GET url: ".../contents/statements/get/<statement_id>/" +Get a statement + +Requestbody: None +Responsebody: +```json +[ + { + "id": ..., + "author": { + "user": { + "id": ..., + "username": "..." + }, + "image": "..." + }, + "content": "...", + "tagged": [], + "mentioned": [], + "created": "...", + "relation_to_parent": ..., + "reactions": [] + } +] +``` + +##### GET url: ".../contents/statements/with/hashtag/" +Get all statements with a hashtag + +Requestbody: None +Queryparameters: "?q=<hashtag>" +Responsebody: +```json +[ + { + "id": ..., + "author": { + "user": { + "id": ..., + "username": "..." + }, + "image": "..." + }, + "content": "...", + "tagged": [], + "mentioned": [], + "created": "...", + "relation_to_parent": ..., + "reactions": [] + }, + ... +] +``` + +##### GET url: ".../contents/statements/feed/" +Get all statements of the accounts that are followed by the user + +Requestbody: None +Responsebody: +```json +[ + { + "id": ..., + "author": { + "user": { + "id": ..., + "username": "..." + }, + "image": "..." + }, + "content": "...", + "tagged": [], + "mentioned": [], + "created": "...", + "relation_to_parent": ..., + "reactions": [] + }, + ... +] +``` + +##### GET url: ".../contents/statements/feed/pagination" +Get statements of the accounts that are followed by the user + +Requestbody: None +Queryparameters: "?page=<page_number>&size=<number_of_statements_per_page>" +Responsebody: +```json +{ + "total": ..., + "data": [ + { + "id": ..., + "author": { + "user": { + "id": ..., + "username": "..." + }, + "image": "..." + }, + "content": "...", + "tagged": [], + "mentioned": [], + "created": "...", + "relation_to_parent": ..., + "reactions": [] + }, + ... + ] +} +``` + +##### GET url: ".../contents/trending/hashtag/" +Get all trending hashtags which are most used in statements + +Requestbody: None +Reponsebody: ++++++ TODO: Setting of a hashtag not clear ++++++ + + +#### Search +##### GET url: ".../search/" +Searchs for a user or hashtag + +Requestbody: None +Queryparameters: "?q=<search_query>&filter=<"user" or "hashtag">" +Responsebody: +```json +{ + "accounts": [ + { + "user": { + "id": ..., + "username": "...", + }, + "image": "..." + }, + ... + ], + "hashtags": [ + { + "id": ..., + "tag": "..." + }, + ... + ] +} +``` + +## Core Database structure + +The project requieres the base authentication database structure from django and extends it with the following tables: + +#### the_social_network_account + with + user_id: int as primary key and foreign key to django auth_user + image: varchar(100) + biography: varchar(1000) + +#### the_social_network_statement + with + id: int as primary key + author_id: int as foreign key to the_social_network_account + content: varchar(120) + created: datetime + +#### the_social_network_accounttagging + with + id: int as primary key + created: datetime + account_id: int as foreign key to the_social_network_account + statement_id: int as foreign key to the_social_network_statement + +#### the_social_network_hashtag + with + id: int as primary key + tag: varchar(30) + created: datetime + +#### the_social_network_hashtagtagging + with + id: int as primary key + created: datetime + hashtag_id: int as foreign key to the_social_network_hashtag + statement_id: int as foreign key to the_social_network_statement + +#### the_social_network_reaction + with + id: int as primary key + created: datetime + vote: small uint + child_id: int as foreign key to the_social_network_statement + parent_id: int as foreign key to the_social_network_statement + +#### the_social_network_relationship + with + id: int as primary key + created: datetime + from_account_id: int as foreign key to the_social_network_account + to_account_id: int as foreign key to the_social_network_account diff --git a/social_network/media/account/default/Argunaut.png b/social_network/media/account/default/Argunaut.png new file mode 100644 index 0000000000000000000000000000000000000000..3064d5e13e0f41d7a308b9b9db936d96d8e597d2 Binary files /dev/null and b/social_network/media/account/default/Argunaut.png differ diff --git a/social_network/media/account/default/Planet.png b/social_network/media/account/default/Planet.png new file mode 100644 index 0000000000000000000000000000000000000000..69a72b62095f20ee4f334414e05fe60f46909005 Binary files /dev/null and b/social_network/media/account/default/Planet.png differ diff --git a/social_network/pyproject.toml b/social_network/pyproject.toml index db99e32e50c998f7eb25d778f5cf9e2a21cfe749..8f0ce5527ddf49d91afda45524308c6f89f4eea1 100644 --- a/social_network/pyproject.toml +++ b/social_network/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "the-social-network" -version = "0.0.3" +version = "0.0.4" description = "Basic social network core." authors = ["Marc Feger <marc.feger@hhu.de>"] license = "BSD-4" diff --git a/social_network/the_social_network/tests.py b/social_network/the_social_network/tests.py index 5080a54c038cd1f75711dee15c95e59060093875..0bea20979ec7d52626158c86ae8be4ed8aa808c0 100644 --- a/social_network/the_social_network/tests.py +++ b/social_network/the_social_network/tests.py @@ -615,6 +615,45 @@ class TestGetStatementFeed(APITestCase): self.assertTrue(result[0].get("created") > result[1].get("created")) self.assertTrue(result[1].get("id") > result[2].get("id")) self.assertTrue(result[1].get("created") > result[2].get("created")) + + def test_feed_contains_correct_data_pagination(self): + self.client.credentials(HTTP_AUTHORIZATION='Token ' + str(self.token_beate)) + response: Response = self.client.get(path="/contents/statements/feed/pagination/?page=1&size=3") + result = response.data["data"] + result_total = response.data["total"] + self.assertTrue(len(result), 3) + self.assertTrue(result_total, 3) + self.assertEqual(result[0].get("id"), self.statement_3.id) + self.assertEqual(result[1].get("id"), self.statement_2.id) + self.assertEqual(result[2].get("id"), self.statement_1.id) + self.assertTrue(result[0].get("id") > result[1].get("id")) + self.assertTrue(result[0].get("created") > result[1].get("created")) + self.assertTrue(result[1].get("id") > result[2].get("id")) + self.assertTrue(result[1].get("created") > result[2].get("created")) + + def test_feed_load_over_size_pagination(self): + self.client.credentials(HTTP_AUTHORIZATION='Token ' + str(self.token_beate)) + response: Response = self.client.get(path="/contents/statements/feed/pagination/?page=1&size=4") + result = response.data["data"] + result_total = response.data["total"] + self.assertTrue(len(result), 3) + self.assertTrue(result_total, 3) + + def test_feed_load_under_size_pagination(self): + self.client.credentials(HTTP_AUTHORIZATION='Token ' + str(self.token_beate)) + response: Response = self.client.get(path="/contents/statements/feed/pagination/?page=1&size=2") + result = response.data["data"] + result_total = response.data["total"] + self.assertTrue(len(result), 2) + self.assertTrue(result_total, 3) + + def test_feed_contains_no_data_pagination(self): + self.client.credentials(HTTP_AUTHORIZATION='Token ' + str(self.token_beate)) + response: Response = self.client.get(path="/contents/statements/feed/pagination/?page=2&size=2") + result = response.data["data"] + result_total = response.data["total"] + self.assertTrue(len(result), 0) + self.assertTrue(result_total, 3) class TestTrendingHashtag(APITestCase): diff --git a/social_network/the_social_network/urls/contentUrls.py b/social_network/the_social_network/urls/contentUrls.py index 575325426a1d3e8cb10242c357789588c9e17233..66d74b543faaac0610ebe26c254980814ff40365 100644 --- a/social_network/the_social_network/urls/contentUrls.py +++ b/social_network/the_social_network/urls/contentUrls.py @@ -6,5 +6,6 @@ urlpatterns = [ path('statements/get/<int:id>/', ShowStatement.as_view(), name="show_statement"), path('statements/with/hashtag/', ShowStatementsWithHashtag.as_view(), name="show_statement_with_hashtag"), path('statements/feed/', ShowStatementFeed.as_view(), name="show_statement_feed"), + path('statements/feed/pagination/', ShowStatementFeedPagination.as_view(), name="show_statement_feed_pagination"), path('trending/hashtag/', ShowTrendingHashtag.as_view(), name="show_trending_hashtags"), ] \ No newline at end of file diff --git a/social_network/the_social_network/views/contentViews.py b/social_network/the_social_network/views/contentViews.py index dfc9760721e0cc22094c91e49d1ec118490912b2..9347182e288b0d0bedb7950417f5f05270d482df 100644 --- a/social_network/the_social_network/views/contentViews.py +++ b/social_network/the_social_network/views/contentViews.py @@ -82,7 +82,6 @@ class ShowStatementFeed(APIView): def get(request: Request): """ This is for getting the feed. - Todo: Add pagination for infinite scrolling. :param request: The request containing the token to identify the calling user. :return: Feed for the calling user based on the actions of those the calling account follows. """ @@ -91,6 +90,47 @@ class ShowStatementFeed(APIView): feed: QuerySet[Statement] = Statement.objects.filter(author__in=following) serializer: StatementSerializer = StatementSerializer(instance=feed, many=True) return Response(status=status.HTTP_200_OK, data=serializer.data) + + +class ShowStatementFeedPagination(APIView): + """ + This view is for querying the feed for an calling account. + The feed is generated by the accounts the calling account is following. + To get the feed the calling account must be authenticated. + """ + authentication_classes = [TokenAuthentication] + permission_classes = (IsAuthenticated,) + + @staticmethod + def get(request: Request): + """ + This is for getting the feed with pagination. + Todo: Add pagination for infinite scrolling. + :param request: The request containing the token to identify the calling user. The page number. The size number. + :return: Feed for the calling user based on the actions of those the calling account follows. + """ + page: int = int(request.query_params.get('page', None)) + size: int = int(request.query_params.get('size', None)) + if not page or not size or page <= 0 or size <= 0: + return Response(status=status.HTTP_400_BAD_REQUEST) + + account: Account = Account.objects.get(user=request.user) + following: List[Account] = account.get_related_to() + [account] + feed: QuerySet[Statement] = Statement.objects.filter(author__in=following) + + first = (page - 1) * size + last = first + size + total: int = feed.count() + + if first >= total: + feed = [] + else: + if last >= total: + last = None + feed = feed[first:last] + + serializer: StatementSerializer = StatementSerializer(instance=feed, many=True) + return Response(status=status.HTTP_200_OK, data={"data": serializer.data, "total": total}) class ShowTrendingHashtag(APIView): @@ -114,9 +154,7 @@ class ShowTrendingHashtag(APIView): :return: 200 OK with empty or not empty data section. The data section is empty if there are not hashtags. """ hashtags: QuerySet[Dict] = HashtagTagging.objects.values('hashtag') - hashtags_counted: QuerySet[Dict] = hashtags.annotate( - the_count=Count('hashtag') - ).order_by("-the_count") + hashtags_counted: QuerySet[Dict] = hashtags.annotate(the_count=Count('hashtag')).order_by("-the_count") counted: Dict = {item["hashtag"]: item["the_count"] for item in hashtags_counted} hashtags: QuerySet[Hashtag] = Hashtag.objects.filter(id__in=counted.keys()) if not hashtags_counted: