diff --git a/.pre-commit-config.yaml b/d.yaml similarity index 100% rename from .pre-commit-config.yaml rename to d.yaml diff --git a/poetry.lock b/poetry.lock index 996b520b..107cc807 100644 --- a/poetry.lock +++ b/poetry.lock @@ -42,23 +42,6 @@ six = "*" [package.extras] test = ["astroid (<=2.5.3)", "pytest"] -[[package]] -name = "attrs" -version = "22.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.5" -files = [ - {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, - {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, -] - -[package.extras] -dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] -docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] -tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] -tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] - [[package]] name = "backcall" version = "0.2.0" @@ -433,6 +416,20 @@ files = [ django = ">=3.0" pytz = "*" +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "executing" version = "1.1.1" @@ -652,6 +649,24 @@ files = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +[[package]] +name = "networkx" +version = "3.2.1" +description = "Python package for creating and manipulating graphs and networks" +optional = false +python-versions = ">=3.9" +files = [ + {file = "networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2"}, + {file = "networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6"}, +] + +[package.extras] +default = ["matplotlib (>=3.5)", "numpy (>=1.22)", "pandas (>=1.4)", "scipy (>=1.9,!=1.11.0,!=1.11.1)"] +developer = ["changelist (==0.4)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] +doc = ["nb2plots (>=0.7)", "nbconvert (<7.9)", "numpydoc (>=1.6)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.14)", "sphinx (>=7)", "sphinx-gallery (>=0.14)", "texext (>=0.6.7)"] +extra = ["lxml (>=4.6)", "pydot (>=1.4.2)", "pygraphviz (>=1.11)", "sympy (>=1.10)"] +test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] + [[package]] name = "nodeenv" version = "1.7.0" @@ -819,13 +834,13 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock [[package]] name = "pluggy" -version = "1.0.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -958,17 +973,6 @@ files = [ [package.extras] tests = ["pytest"] -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] - [[package]] name = "pycodestyle" version = "2.9.1" @@ -1021,26 +1025,25 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "7.1.3" +version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"}, - {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] -attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" -py = ">=1.8.2" -tomli = ">=1.0.0" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-django" @@ -1345,4 +1348,4 @@ brotli = ["Brotli"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "883c8eba88cc0e64dc8f2ef3c9de77f066af8c51111d128f27e7d7ec157acfb6" +content-hash = "20720d5535fbe734a6452451628c1754e2b75f67fdf9e65e838178d662161f01" diff --git a/project/core/management/commands/load_graph_dummy_data.py b/project/core/management/commands/load_graph_dummy_data.py new file mode 100644 index 00000000..a79e9ff8 --- /dev/null +++ b/project/core/management/commands/load_graph_dummy_data.py @@ -0,0 +1,62 @@ +# data_loader.py +from django.core.management.base import BaseCommand +from threads.models import Civi, CiviLink, Thread # Adjust import to your app's name + +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class Command(BaseCommand): + help = "Load dummy data for Civis and CiviLinks" + + def handle(self, *args, **kwargs): + # Create a thread + thread, _ = Thread.objects.get_or_create( + title="Net Neutrality", + ) + user = User.objects.first() + # Create dummy Civis + civi1 = Civi.objects.create( + title="Civi 1: Importance of Net Neutrality", + body="Net neutrality ensures that all users have equal access to information and services online.", + author=user, + votes_pos=10, + votes_neg=2, + thread=thread, + ) + + civi2 = Civi.objects.create( + title="Civi 2: Risks of Removing Net Neutrality", + body="Without net neutrality, ISPs could prioritize their own content or the content of those who pay for faster access.", + author=user, + votes_pos=15, + votes_neg=1, + thread=thread, + ) + + civi3 = Civi.objects.create( + title="Civi 3: Public Opinion on Net Neutrality", + body="A significant portion of the public supports net neutrality regulations to protect free internet access.", + author=user, + votes_pos=20, + votes_neg=3, + thread=thread, + ) + + # Create dummy CiviLinks + CiviLink.objects.create( + from_civi=civi1, to_civi=civi2, relation_type="response" + ) + + CiviLink.objects.create( + from_civi=civi2, to_civi=civi1, relation_type="rebuttal" + ) + + CiviLink.objects.create(from_civi=civi1, to_civi=civi3, relation_type="support") + + CiviLink.objects.create( + from_civi=civi3, to_civi=civi2, relation_type="challenge" + ) + + self.stdout.write(self.style.SUCCESS("Successfully loaded dummy data")) diff --git a/project/core/urls.py b/project/core/urls.py index b87c3a00..f7b01755 100644 --- a/project/core/urls.py +++ b/project/core/urls.py @@ -13,6 +13,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from core.router import CiviWikiRouter from django.conf import settings from django.conf.urls.static import static @@ -25,6 +26,7 @@ path("admin/", admin.site.urls), path("api/v1/", include(CiviWikiRouter.urls)), path("api/", include("threads.urls.api")), + path("api/", include("threads.urls.graph_api")), path("", include("accounts.urls")), path("", include("threads.urls.urls")), path( diff --git a/project/threads/graph_api.py b/project/threads/graph_api.py new file mode 100644 index 00000000..a483b6f6 --- /dev/null +++ b/project/threads/graph_api.py @@ -0,0 +1,87 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response +from .graphs import ( + load_graph_from_db, + most_caused_problems, + most_effective_solution, + shortest_path_problem_to_solution, +) + + +@api_view(["GET"]) +def get_most_caused_problem(request): + with_score = request.GET.get("with_score", False) + G = load_graph_from_db(with_score) + problem = most_caused_problems(G) + if problem: + if with_score: + return Response( + { + "problem": G.nodes[problem]["label"], + "score": G.nodes[problem]["score"], + } + ) + else: + return Response({"problem": G.nodes[problem]["label"]}) + return Response({"error": "No problem found"}, status=404) + + +@api_view(["GET"]) +def get_most_effective_solution(request): + with_score = request.GET.get("with_score", False) + G = load_graph_from_db(with_score) + solution = most_effective_solution(G) + if solution: + if with_score: + return Response( + { + "solution": G.nodes[solution]["label"], + "score": G.nodes[solution]["score"], + } + ) + else: + return Response({"solution": G.nodes[solution]["label"]}) + return Response({"error": "No solution found"}, status=404) + + +@api_view(["GET"]) +def get_shortest_path(request, problem_id, solution_id): + with_score = request.GET.get("with_score", False) + G = load_graph_from_db(with_score) + path = shortest_path_problem_to_solution(G, int(problem_id), int(solution_id)) + if path: + path_labels = [G.nodes[node]["label"] for node in path] + return Response({"path_labels": path_labels, "path":path}) + return Response({"error": "No path found"}, status=404) + + + + +@api_view(['GET']) +def get_graph_data(request): + G = load_graph_from_db() # Load the graph from your DB or other data source + nodes = [] + edges = [] + + # Format nodes and edges to match Cytoscape format + for node, attr in G.nodes(data=True): + nodes.append({ + 'data': { + 'id': node, + 'label': attr.get('label', node), + 'type': attr.get('type'), + 'score': attr.get('score', 0), + } + }) + + for source, target, attr in G.edges(data=True): + edges.append({ + 'data': { + 'id': f'{source}-{target}', + 'source': source, + 'target': target, + 'label': attr.get('label', 'related') + } + }) + + return Response({'nodes': nodes, 'edges': edges}) \ No newline at end of file diff --git a/project/threads/graphs.py b/project/threads/graphs.py new file mode 100644 index 00000000..0b318013 --- /dev/null +++ b/project/threads/graphs.py @@ -0,0 +1,42 @@ +import networkx as nx +from .models import Civi, CiviLink + + +def load_graph_from_db(with_score:bool=False): + # Create a directed graph + G = nx.DiGraph() + + # Add all Civis as nodes + for civi in Civi.objects.all(): + node_args, node_kwargs = civi.__node__(with_score=with_score) + G.add_node(*node_args, **node_kwargs) + + # Add CiviLinks as edges + for link in CiviLink.objects.all(): + edge_args, edge_kwargs = link.__edge__() + G.add_edge(*edge_args, **edge_kwargs) + + return G + + +def most_caused_problems(G, with_score:bool=False): + problem_nodes = [n for n, attr in G.nodes(data=True) if attr["type"] == "Problem"] + if with_score: + return max(problem_nodes, key=lambda n: (G.in_degree(n), G.nodes[n]['score']), default=None) + return max(problem_nodes, key=lambda n: G.in_degree(n), default=None) + + +def most_effective_solution(G, with_score:bool=False): + solution_nodes = [n for n, attr in G.nodes(data=True) if attr["type"] == "Solution"] + if with_score: + return max(solution_nodes, key=lambda n: (G.out_degree(n), G.nodes[n]['score']), default=None) + return max(solution_nodes, key=lambda n: G.out_degree(n), default=None) + + +def shortest_path_problem_to_solution(G, problem_id, solution_id, with_score:bool=False): + try: + if with_score: + return nx.shortest_path(G, source=problem_id, target=solution_id, weight='weight') + return nx.shortest_path(G, source=problem_id, target=solution_id) + except nx.NetworkXNoPath: + return None diff --git a/project/threads/migrations/0008_civilink.py b/project/threads/migrations/0008_civilink.py new file mode 100644 index 00000000..9033c64b --- /dev/null +++ b/project/threads/migrations/0008_civilink.py @@ -0,0 +1,56 @@ +# Generated by Django 4.1.2 on 2024-10-18 05:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("threads", "0007_alter_activity_civi_alter_activity_thread_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="CiviLink", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "relation_type", + models.CharField( + choices=[ + ("causes", "Causes"), + ("solves", "Solves"), + ("related", "Related"), + ], + max_length=50, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "from_civi", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="outgoing_links", + to="threads.civi", + ), + ), + ( + "to_civi", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="incoming_links", + to="threads.civi", + ), + ), + ], + ), + ] diff --git a/project/threads/migrations/max_migration.txt b/project/threads/migrations/max_migration.txt index 3b7302ff..0afe8599 100644 --- a/project/threads/migrations/max_migration.txt +++ b/project/threads/migrations/max_migration.txt @@ -1 +1 @@ -0007_alter_activity_civi_alter_activity_thread_and_more +0008_civilink diff --git a/project/threads/models.py b/project/threads/models.py index a4a39672..4cce0035 100644 --- a/project/threads/models.py +++ b/project/threads/models.py @@ -3,6 +3,7 @@ import math import os from calendar import month_name +from typing import Any, Dict, List, Tuple from categories.models import Category from common.utils import PathAndRename @@ -236,6 +237,7 @@ class Civi(models.Model): title = models.CharField(max_length=255, blank=False, null=False) body = models.CharField(max_length=1023, blank=False, null=False) + # todo rename to civi_type?? c_type = models.CharField(max_length=31, default="problem", choices=CIVI_TYPES) votes_vneg = models.IntegerField(default=0) @@ -250,6 +252,34 @@ def __str__(self): def __unicode__(self): return self.title + def __node__(self, with_score=False) -> Tuple[List[int], Dict[str, Any]]: + """ + Documentation: + Add to a graph like this: + ```python + import networkx as nx + G = nx.DiGraph() + a_civi = Civi.objects.first() + node_args, node_kwargs = a_civi.__node__() + G.add_node(*node_args, **node_kwargs) + ``` + Args: + with_score (bool, optional): Defaults to False. + + Returns: + Tuple[List[int], Dict[str, Any]]: The args and kwargs to be sent to the nx graph `add_node` function + """ + node_kwargs = { + 'label':self.title, + 'type':self.c_type + } + if with_score: + node_kwargs.update({"score":self.score(), "weight":self.__weight__()}) + return [self.id], node_kwargs + + def __weight__(self): + return 1 / (1 + self.score()) + def _get_votes(self): activity_votes = Activity.objects.filter(civi=self) @@ -395,6 +425,57 @@ def dict_with_score(self, requested_user_id=None): return data +class CiviLink(models.Model): + """Extend the existing model to support a graph structure + (i.e., Problem -> Causes -> Problem -> solved_by -> Solution), + + + Args: + models (_type_): _description_ + + Returns: + _type_: _description_ + """ + RELATION_TYPE_CHOICES = ( + ('causes', 'Causes'), + ('solves', 'Solves'), + ('related', 'Related'), # Optional, for general relationships + ) + + from_civi = models.ForeignKey(Civi, on_delete=models.CASCADE, related_name='outgoing_links') + to_civi = models.ForeignKey(Civi, on_delete=models.CASCADE, related_name='incoming_links') + relation_type = models.CharField(max_length=50, choices=RELATION_TYPE_CHOICES) + + created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.from_civi.title} {self.get_relation_type_display()} {self.to_civi.title}" + + def __edge__(self) -> Tuple[list, Dict[str, str]]: + """ + Documentation: + Add to a graph like this: + ```python + import networkx as nx + G = nx.DiGraph() + a_link = CiviLink.objects.first() + edge_args, edge_kwargs = a_link.__edge__() + G.add_edge(*edge_args, **edge_kwargs) + ``` + + Returns: + Tuple[list, Dict[str, str]]: the first is the args, the second the kwargs to the nx graph `add_edge` function + """ + # link.from_civi.id, link.to_civi.id, relation=link.relation_type + edge_kwargs = { + "relation":self.relation_type + } + edge_args = [self.from_civi.id, self.to_civi.id] + return edge_args, edge_kwargs + + + + class Response(models.Model): author = models.ForeignKey( get_user_model(), diff --git a/project/threads/templates/threads/graph.html b/project/threads/templates/threads/graph.html new file mode 100644 index 00000000..eea37c51 --- /dev/null +++ b/project/threads/templates/threads/graph.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} {% load static %} {% load i18n %} + + +{% block extra_css %} + +{% endblock %} {% block content %} +