11 | {% blocktrans trimmed %}
12 | To create a team, enter a team name, and optionally provide a description and URL.
13 | You will be able to invite team members once you've created the team.
14 | {% endblocktrans %}
15 |
11 | {% blocktrans trimmed %}
12 | To create a board, enter a title, a description, and two initial competing hypotheses.
13 | You will be able to add additional hypotheses once you've created the board.
14 | {% endblocktrans %}
15 |
16 |
17 |
26 | {% endblock %}
27 |
--------------------------------------------------------------------------------
/openach/migrations/0042_auto_20180113_0323.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.11.6 on 2018-01-13 03:23
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("openach", "0041_auto_20180113_0301"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterModelOptions(
14 | name="projectnews",
15 | options={"verbose_name_plural": "project news"},
16 | ),
17 | migrations.AddField(
18 | model_name="team",
19 | name="invitation_required",
20 | field=models.BooleanField(default=True),
21 | ),
22 | migrations.AlterField(
23 | model_name="team",
24 | name="public",
25 | field=models.BooleanField(
26 | default=True, help_text="Whether or not the team is publicly visible"
27 | ),
28 | ),
29 | ]
30 |
--------------------------------------------------------------------------------
/openach/admin.py:
--------------------------------------------------------------------------------
1 | """openach Admin Dashboard Configuration.
2 |
3 | For more information, please see:
4 | https://docs.djangoproject.com/en/1.10/ref/contrib/admin/
5 | """
6 |
7 | from django.contrib import admin
8 |
9 | from .models import Board, Evidence, EvidenceSourceTag, Hypothesis, ProjectNews, Team
10 |
11 |
12 | class HypothesisInline(admin.StackedInline):
13 | """Inline editor for an ACH board's hypotheses."""
14 |
15 | model = Hypothesis
16 | extra = 2
17 |
18 |
19 | class EvidenceInline(admin.StackedInline):
20 | """Inline editor for an ACH board's evidence."""
21 |
22 | model = Evidence
23 | extra = 2
24 |
25 |
26 | @admin.register(Board)
27 | class BoardAdmin(admin.ModelAdmin):
28 | """Admin interface for editing ACH boards."""
29 |
30 | inlines = [HypothesisInline, EvidenceInline]
31 |
32 |
33 | admin.site.register(EvidenceSourceTag)
34 | admin.site.register(ProjectNews)
35 | admin.site.register(Team)
36 |
--------------------------------------------------------------------------------
/openach/util.py:
--------------------------------------------------------------------------------
1 | """Utility functions for working with iterators and collections."""
2 |
3 | import itertools
4 |
5 |
6 | def partition(pred, iterable):
7 | """Use a predicate to partition entries into false entries and true entries."""
8 | # https://stackoverflow.com/questions/8793772/how-to-split-a-sequence-according-to-a-predicate
9 | # NOTE: this might iterate over the collection twice
10 | # NOTE: need to use filter(s) here because we're lazily dealing with iterators
11 | it1, it2 = itertools.tee(iterable)
12 | return (
13 | itertools.filterfalse(pred, it1),
14 | filter(pred, it2),
15 | ) # pylint: disable=bad-builtin
16 |
17 |
18 | def first_occurrences(iterable):
19 | """Return list where subsequent occurrences of repeat elements have been removed."""
20 | added = set()
21 | result = []
22 | for elt in iterable:
23 | if elt not in added:
24 | result.append(elt)
25 | added.add(elt)
26 | return result
27 |
--------------------------------------------------------------------------------
/openach/tests/test_util.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from openach import tasks
4 | from openach.util import first_occurrences
5 |
6 |
7 | class UtilMethodTests(TestCase):
8 | def test_first_occurrences_empty(self):
9 | """Test that first_instances() returns an empty list when an empty list is provided."""
10 | self.assertEqual(first_occurrences([]), [])
11 |
12 | def test_first_occurrences(self):
13 | """Test that first_instances() only preserves the first occurrence in the list."""
14 | self.assertEqual(first_occurrences(["a", "a"]), ["a"])
15 | self.assertEqual(first_occurrences(["a", "b", "a"]), ["a", "b"])
16 |
17 |
18 | class CeleryTestCase(TestCase):
19 | def test_celery_example(self):
20 | """Test that the ``example_task`` task runs with no errors, and returns the correct result."""
21 | result = tasks.example_task.delay(8, 8)
22 |
23 | self.assertEqual(result.get(), 16)
24 | self.assertTrue(result.successful())
25 |
--------------------------------------------------------------------------------
/.idea/copyright/GPLv3.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
24 |
25 | {% endblock %}
26 |
--------------------------------------------------------------------------------
/openach/migrations/0047_alter_board_board_title.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.6 on 2021-08-21 14:55
2 |
3 | import django.core.validators
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ("openach", "0046_auto_20180908_0056"),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterField(
15 | model_name="board",
16 | name="board_title",
17 | field=models.CharField(
18 | db_index=True,
19 | help_text="The board title. Typically phrased as a question asking about what happened in the past, what is happening currently, or what will happen in the future",
20 | max_length=200,
21 | validators=[
22 | django.core.validators.RegexValidator(
23 | "^((?![!@#^*~`|<>{}+=\\[\\]]).)*$",
24 | "No special characters allowed.",
25 | )
26 | ],
27 | ),
28 | ),
29 | ]
30 |
--------------------------------------------------------------------------------
/openach/templates/boards/edit_board.html:
--------------------------------------------------------------------------------
1 | {% extends 'boards/base.html' %}
2 | {% load bootstrap %}
3 | {% load i18n %}
4 | {% load board_extras %}
5 |
6 | {% block title %}{% trans "Edit Board" %} | {{ site.name }}{% endblock %}
7 |
8 | {% block content %}
9 |
{% trans "Edit Board" %}
10 |
11 |
{% trans "Update the title and/or description for the board." %}
12 |
13 |
26 | {% endblock %}
27 |
--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
1 | # Open Synthesis, an open platform for intelligence analysis
2 | # Copyright (C) 2016-2020 Open Synthesis Contributors. See CONTRIBUTING.md
3 | # file at the top-level directory of this distribution.
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see .
17 |
18 |
19 | def pytest_configure(config):
20 | import sys
21 |
22 | sys._called_from_test = True
23 |
24 |
25 | def pytest_unconfigure(config):
26 | import sys
27 |
28 | del sys._called_from_test
29 |
--------------------------------------------------------------------------------
/openach/auth.py:
--------------------------------------------------------------------------------
1 | """Analysis of Competing Hypotheses Authorization Functions."""
2 |
3 | from django.core.exceptions import PermissionDenied
4 |
5 |
6 | def has_edit_authorization(request, board, has_creator=None):
7 | """Return True if the user does not have edit rights for the resource.
8 |
9 | :param request: a Django request object
10 | :param board: the Board context
11 | :param has_creator: a model that has a creator member, or None
12 | """
13 | permissions = board.permissions.for_user(request.user)
14 | return "edit_elements" in permissions or (
15 | has_creator and request.user.id == has_creator.creator_id
16 | )
17 |
18 |
19 | def check_edit_authorization(request, board, has_creator=None):
20 | """Raise a PermissionDenied exception if the user does not have edit rights for the resource.
21 |
22 | :param request: a Django request object
23 | :param board: the Board context
24 | :param has_creator: a model that has a creator member, or None
25 | """
26 | if not has_edit_authorization(request, board, has_creator=has_creator):
27 | raise PermissionDenied()
28 |
--------------------------------------------------------------------------------
/openach/templates/boards/_detail_icons.html:
--------------------------------------------------------------------------------
1 | {% load board_extras %}
2 | {% load i18n %}
3 | {% load static %}
4 |
5 | {# The levels here need to match the levels in board_extras. #}
6 | {% if detail_disagreement >= 2.0 %}
7 |
8 |
10 |
11 | {% elif detail_disagreement >= 1.5 %}
12 |
13 |
15 |
16 | {% elif detail_disagreement >= 0.5 %}
17 |
18 |
20 |
21 | {% endif %}
22 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks.
3 |
4 | For more information, please see:
5 | https://docs.djangoproject.com/en/1.10/ref/django-admin/
6 | """
7 | import os
8 | import sys
9 |
10 | if __name__ == "__main__":
11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openintel.settings")
12 | try:
13 | from django.core.management import execute_from_command_line
14 | except ImportError: # pragma: no cover
15 | # The above import may fail for some other reason. Ensure that the
16 | # issue is really that Django is missing to avoid masking other
17 | # exceptions on Python 2.
18 | try:
19 | # NOTE: the django import is used by 'execute_from_command_line' below
20 | import django # pylint: disable=unused-import
21 | except ImportError:
22 | raise ImportError(
23 | "Couldn't import Django. Are you sure it's installed and "
24 | "available on your PYTHONPATH environment variable? Did you "
25 | "forget to activate a virtual environment?"
26 | )
27 | raise
28 | execute_from_command_line(sys.argv)
29 |
--------------------------------------------------------------------------------
/openach/migrations/0032_auto_20161010_1940.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.10.2 on 2016-10-10 19:40
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("openach", "0031_auto_20161004_1651"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="board",
15 | name="board_desc",
16 | field=models.CharField(
17 | db_index=True,
18 | help_text="A description providing context around the topic. Helps to clarify which hypotheses and evidence are relevant",
19 | max_length=255,
20 | verbose_name="board description",
21 | ),
22 | ),
23 | migrations.AlterField(
24 | model_name="board",
25 | name="board_title",
26 | field=models.CharField(
27 | db_index=True,
28 | help_text="The board title. Typically phrased as a question asking about what happened in the past, what is happening currently, or what will happen in the future",
29 | max_length=200,
30 | ),
31 | ),
32 | ]
33 |
--------------------------------------------------------------------------------
/openach/templates/boards/user_boards.html:
--------------------------------------------------------------------------------
1 | {% extends 'boards/base.html' %}
2 | {% load board_extras %}
3 | {% load i18n %}
4 | {% load static %}
5 |
6 | {% block title %}
7 | {% blocktrans trimmed with username=user.username %}
8 | {{ username }}'s Boards
9 | {% endblocktrans %}
10 | | {{ site.name }}
11 | {% endblock %}
12 |
13 | {% block opengraph %}
14 |
15 |
16 | {% endblock %}
17 |
18 | {% block content %}
19 |
20 | {% if user == request.user %}
21 | {% blocktrans %}
22 | Boards you have {{ verb }}
23 | {% endblocktrans %}
24 | {% else %}
25 | {% blocktrans trimmed with username=user.username %}
26 | Boards {{ username }} has {{ verb }}
27 | {% endblocktrans %}
28 | {% endif %}
29 |
30 | {% trans "View Account" %}
31 |
32 |
33 | {% include 'boards/_board_table.html' %}
34 | {% endblock %}
35 |
--------------------------------------------------------------------------------
/openach/migrations/0028_initusersettings.py:
--------------------------------------------------------------------------------
1 | """Migration to initialize default UserSettings for each user."""
2 |
3 | from django.db import migrations
4 |
5 |
6 | def forwards_func(apps, schema_editor):
7 | """Create default UserSettings for each user, if they don't already have settings."""
8 | User = apps.get_model("auth", "User")
9 | UserSettings = apps.get_model("openach", "UserSettings")
10 | db_alias = schema_editor.connection.alias
11 |
12 | # doesn't matter that this is inefficient because we don't have many users yet
13 | for user in User.objects.using(db_alias).all():
14 | UserSettings.objects.update_or_create(user=user)
15 |
16 |
17 | def reverse_func(apps, schema_editor):
18 | """Remove all UserSettings."""
19 | UserSettings = apps.get_model("openach", "UserSettings")
20 | db_alias = schema_editor.connection.alias
21 | UserSettings.objects.using(db_alias).all().delete()
22 |
23 |
24 | class Migration(migrations.Migration):
25 |
26 | dependencies = [
27 | ("openach", "0027_auto_20160924_2043"),
28 | ("auth", "0008_alter_user_username_max_length"),
29 | ]
30 |
31 | operations = [
32 | migrations.RunPython(forwards_func, reverse_func),
33 | ]
34 |
--------------------------------------------------------------------------------
/openach/templates/boards/edit_permissions.html:
--------------------------------------------------------------------------------
1 | {% extends 'boards/base.html' %}
2 | {% load i18n %}
3 | {% load board_extras %}
4 | {% load bootstrap %}
5 |
6 | {% block title %}{% trans "Modify Permissions" %} | {{ site.name }}{% endblock %}
7 |
8 | {% block content %}
9 |
16 | {% trans "Warning:" %}
17 | {% blocktrans trimmed %}
18 | Read permissions include access to both new information and historic information.
19 | {% endblocktrans %}
20 |
{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}
10 | {% blocktrans with team_name=team.name %}Team Members for {{ team_name }}{% endblocktrans %}
11 |
12 |
13 | {% for member in members %}
14 |
15 | {% if is_owner and member.id != team.owner.id %}
16 | {# FIXME: should ideally pass a "next" parameter to server can redirect back to same page #}
17 |
22 | {% else %}
23 | {{ member.username }}
24 | {% endif %}
25 |
22 |
23 | {% for target, for_target in notifications.items %}
24 |
25 | {% blocktrans trimmed with site.domain as site_domain and target|board_url as custom_url and target.board_title as board_title %}
26 | Board {{ board_title }}
27 | {% endblocktrans %}
28 |
29 |
30 | {% for notification in for_target %}
31 |
{% include 'boards/email/_notification.html' %}
32 | {% endfor %}
33 |
34 | {% endfor %}
35 | {% endif %}
36 |
37 |
38 | {% blocktrans trimmed with site_domain=site.domain site_name=site.name %}
39 | You are receiving this e-mail because you're subscribed to receive {{ digest_frequency }} updates
40 | from {{ site_name }}. To modify your email settings, visit
41 | your account profile.
42 | {% endblocktrans %}
43 |
44 |
--------------------------------------------------------------------------------
/openach/migrations/0036_defaultpermissions.py:
--------------------------------------------------------------------------------
1 | """Initialize default public board permissions."""
2 |
3 | from django.conf import settings
4 | from django.db import migrations
5 |
6 | from openach.models import AuthLevels
7 |
8 |
9 | def forwards_func(apps, schema_editor):
10 | """Create default permissions for each board, if they don't already have permissions."""
11 | BoardPermissions = apps.get_model("openach", "BoardPermissions")
12 | Board = apps.get_model("openach", "Board")
13 | db_alias = schema_editor.connection.alias
14 |
15 | default_read = (
16 | AuthLevels.registered.key
17 | if getattr(settings, "ACCOUNT_REQUIRED", True)
18 | else AuthLevels.anyone.key
19 | )
20 |
21 | for board in Board.objects.using(db_alias).all():
22 | if not BoardPermissions.objects.filter(board=board).exists():
23 | BoardPermissions.objects.create(
24 | board=board,
25 | read_board=default_read,
26 | read_comments=default_read,
27 | add_comments=AuthLevels.collaborators.key,
28 | add_elements=AuthLevels.collaborators.key,
29 | edit_elements=AuthLevels.collaborators.key,
30 | )
31 |
32 |
33 | def reverse_func(apps, schema_editor):
34 | """Do nothing."""
35 | # we could remove board permissions that currently have default values, but there's no compelling reason to do this
36 | # at this point.
37 | pass
38 |
39 |
40 | class Migration(migrations.Migration):
41 |
42 | dependencies = [
43 | ("openach", "0035_boardpermissions"),
44 | ]
45 |
46 | operations = [
47 | migrations.RunPython(forwards_func, reverse_func),
48 | ]
49 |
--------------------------------------------------------------------------------
/openach/context_processors.py:
--------------------------------------------------------------------------------
1 | """Django template context processors."""
2 |
3 | from django.conf import settings
4 | from django.contrib.sites.shortcuts import get_current_site
5 | from django.utils.translation import gettext_lazy as _
6 |
7 |
8 | def site(request):
9 | """Return a template context with the current site as 'site'."""
10 | # NOTE: get_current_site caches the result from the db
11 | # See: https://docs.djangoproject.com/en/1.10/ref/contrib/sites/#caching-the-current-site-object
12 | return {"site": get_current_site(request)}
13 |
14 |
15 | def meta(request):
16 | """Return a template context with site's social account information."""
17 | site_name = get_current_site(request)
18 | return {
19 | "twitter_account": getattr(settings, "TWITTER_ACCOUNT", None),
20 | "facebook_account": getattr(settings, "FACEBOOK_ACCOUNT", None),
21 | "default_description": _(
22 | "{name} is an open platform for CIA-style intelligence analysis"
23 | ).format(
24 | name=site_name
25 | ), # nopep8
26 | "default_keywords": [
27 | _("Analysis of Competing Hypotheses"),
28 | _("ACH"),
29 | _("intelligence analysis"),
30 | _("current events"),
31 | ],
32 | }
33 |
34 |
35 | def invite(dummy_request):
36 | """Return a template context with the site's invitation configuration."""
37 | return {
38 | "invite_request_url": getattr(settings, "INVITE_REQUEST_URL", None),
39 | }
40 |
41 |
42 | def banner(dummy_request):
43 | """Return a template context with a the site's banner configuration."""
44 | return {
45 | "banner": getattr(settings, "BANNER_MESSAGE", None),
46 | }
47 |
--------------------------------------------------------------------------------
/openach/migrations/0004_auto_20160826_1651.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.10 on 2016-08-26 16:51
2 |
3 | import django.db.models.deletion
4 | from django.conf import settings
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ("openach", "0003_auto_20160825_1622"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="EvidenceSource",
18 | fields=[
19 | (
20 | "id",
21 | models.AutoField(
22 | auto_created=True,
23 | primary_key=True,
24 | serialize=False,
25 | verbose_name="ID",
26 | ),
27 | ),
28 | ("source_url", models.URLField()),
29 | ("submit_date", models.DateTimeField(verbose_name="date added")),
30 | ],
31 | ),
32 | migrations.RemoveField(
33 | model_name="evidence",
34 | name="evidence_url",
35 | ),
36 | migrations.AddField(
37 | model_name="evidencesource",
38 | name="evidence",
39 | field=models.ForeignKey(
40 | on_delete=django.db.models.deletion.CASCADE, to="openach.Evidence"
41 | ),
42 | ),
43 | migrations.AddField(
44 | model_name="evidencesource",
45 | name="uploader",
46 | field=models.ForeignKey(
47 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
48 | ),
49 | ),
50 | ]
51 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "open-synthesis",
3 | "version": "0.0.3",
4 | "description": "Open platform for CIA-style intelligence analysis",
5 | "private": true,
6 | "main": "index.js",
7 | "scripts": {
8 | "build": "npx webpack --config webpack.config.prod.js",
9 | "watch": "NODE_ENV='development' npx webpack --config webpack.config.dev.js --watch",
10 | "postinstall": "npm run build",
11 | "test": "echo \"Error: no test specified\" && exit 1"
12 | },
13 | "repository": {
14 | "type": "git",
15 | "url": "git+https://github.com/twschiller/open-synthesis.git"
16 | },
17 | "keywords": [
18 | "intelligence analysis",
19 | "ACH",
20 | "analysis of competing hypotheses"
21 | ],
22 | "author": "Todd Schiller",
23 | "license": "AGPL-3.0",
24 | "bugs": {
25 | "url": "https://github.com/twschiller/open-synthesis/issues"
26 | },
27 | "homepage": "https://github.com/twschiller/open-synthesis#readme",
28 | "dependencies": {},
29 | "devDependencies": {
30 | "@babel/core": "^7.28.5",
31 | "@babel/preset-env": "^7.28.5",
32 | "babel-loader": "^10.0.0",
33 | "bootstrap": "^3.4.1",
34 | "bootstrap-datepicker": "^1.10.1",
35 | "css-loader": "^7.1.2",
36 | "eslint": "^9.39.1",
37 | "file-loader": "^6.2.0",
38 | "jquery": "^3.7.1",
39 | "mini-css-extract-plugin": "^2.9.4",
40 | "css-minimizer-webpack-plugin": "^7.0.4",
41 | "@selectize/selectize": "^0.15.2",
42 | "style-loader": "^4.0.0",
43 | "webpack": "^5.103.0",
44 | "webpack-bundle-tracker": "^1.8.1",
45 | "webpack-cli": "^6.0.1",
46 | "webpack-merge": "^6.0.1"
47 | },
48 | "engine-strict": true,
49 | "engines": {
50 | "node": ">=18.13 <19",
51 | "npm": "8.x"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/openach/templates/boards/common/_pagenav.html:
--------------------------------------------------------------------------------
1 | {% load board_extras %}
2 | {% load i18n %}
3 |
4 |
41 |
--------------------------------------------------------------------------------
/openach-frontend/src/js/board_search.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Open Synthesis, an open platform for intelligence analysis
3 | * Copyright (C) 2016 Open Synthesis Contributors. See CONTRIBUTING.md
4 | * file at the top-level directory of this distribution.
5 | *
6 | * This program is free software: you can redistribute it and/or modify
7 | * it under the terms of the GNU General Public License as published by
8 | * the Free Software Foundation, either version 3 of the License, or
9 | * (at your option) any later version.
10 | *
11 | * This program is distributed in the hope that it will be useful,
12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | * GNU General Public License for more details.
15 | *
16 | * You should have received a copy of the GNU General Public License
17 | * along with this program. If not, see .
18 | */
19 |
20 | import "@selectize/selectize";
21 |
22 | $(document).ready(function () {
23 | $("#board-search").selectize({
24 | valueField: "url",
25 | labelField: "board_title",
26 | searchField: ["board_title", "board_desc"],
27 | maxItems: 1,
28 | create: false,
29 | render: {
30 | option: function (item) {
31 | return "
12 |
13 | {% get_providers as socialaccount_providers %}
14 |
15 | {% if socialaccount_providers %}
16 |
{% blocktrans with site.name as site_name %}Please sign in with one
17 | of your existing third party accounts. Or, sign up
18 | for a {{ site_name }} account and sign in below:{% endblocktrans %}
19 |
20 |
21 |
22 |
23 | {% include "socialaccount/snippets/provider_list.html" with process="login" %}
24 |
41 | {% include 'teams/_member_panel.html' %}
42 |
43 |
44 | {% endblock %}
45 |
--------------------------------------------------------------------------------
/openintel/celery.py:
--------------------------------------------------------------------------------
1 | """Celery configuration.
2 |
3 | For more information, please see:
4 | - http://docs.celeryproject.org/en/latest/getting-started/first-steps-with-celery.html#first-steps-with-celery
5 | - http://docs.celeryproject.org/en/latest/django/first-steps-with-django.html
6 | - https://devcenter.heroku.com/articles/celery-heroku
7 | """
8 |
9 | import logging
10 | import os
11 |
12 | from celery import Celery
13 |
14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openintel.settings")
15 |
16 | from django.conf import settings # noqa
17 |
18 | logger = logging.getLogger(__name__) # pylint: disable=invalid-name
19 | app = Celery("openintel") # pylint: disable=invalid-name
20 |
21 | app.conf.update(
22 | CELERY_TASK_SERIALIZER="json",
23 | CELERY_ACCEPT_CONTENT=["json"], # Ignore other content
24 | CELERY_RESULT_SERIALIZER="json",
25 | # synchronously execute tasks, so you don't need to run a celery server
26 | # http://docs.celeryproject.org/en/latest/configuration.html#celery-always-eager
27 | CELERY_ALWAYS_EAGER=getattr(settings, "CELERY_ALWAYS_EAGER", False),
28 | )
29 |
30 | # Using a string here means the worker will not have to
31 | # pickle the object when using Windows.
32 | app.config_from_object("django.conf:settings")
33 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
34 |
35 | _BROKER_URL = getattr(settings, "CELERY_BROKER_URL", None)
36 | _BACKEND_URL = getattr(settings, "CELERY_RESULT_BACKEND", _BROKER_URL)
37 |
38 |
39 | if app.conf.CELERY_ALWAYS_EAGER:
40 | logger.warning("Running Celery tasks eagerly/synchronously; may impact performance")
41 | if _BROKER_URL or _BACKEND_URL:
42 | logger.warning("Ignoring Celery broker and result backend settings")
43 | elif _BROKER_URL and _BACKEND_URL:
44 | logger.info("Celery Broker: %s", _BROKER_URL)
45 | logger.info("Celery Result Backend: %s", _BACKEND_URL)
46 | app.conf.update(BROKER_URL=_BROKER_URL, CELERY_RESULT_BACKEND=_BACKEND_URL)
47 | else:
48 | logger.warning(
49 | "No broker url/backend supplied for Celery; enable CELERY_ALWAYS_EAGER to run without a server"
50 | )
51 |
--------------------------------------------------------------------------------
/openach/migrations/0015_auto_20160915_1402.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.10.1 on 2016-09-15 14:02
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("openach", "0014_auto_20160908_1915"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterModelOptions(
14 | name="evidence",
15 | options={"verbose_name_plural": "evidence"},
16 | ),
17 | migrations.AlterModelOptions(
18 | name="hypothesis",
19 | options={"verbose_name_plural": "hypotheses"},
20 | ),
21 | migrations.AddField(
22 | model_name="board",
23 | name="removed",
24 | field=models.BooleanField(default=False),
25 | preserve_default=False,
26 | ),
27 | migrations.AddField(
28 | model_name="evidence",
29 | name="removed",
30 | field=models.BooleanField(default=False),
31 | preserve_default=False,
32 | ),
33 | migrations.AddField(
34 | model_name="hypothesis",
35 | name="removed",
36 | field=models.BooleanField(default=False),
37 | preserve_default=False,
38 | ),
39 | migrations.AlterField(
40 | model_name="board",
41 | name="board_desc",
42 | field=models.CharField(max_length=255, verbose_name="board description"),
43 | ),
44 | migrations.AlterField(
45 | model_name="evidence",
46 | name="event_date",
47 | field=models.DateField(null=True, verbose_name="evidence event date"),
48 | ),
49 | migrations.AlterField(
50 | model_name="evidence",
51 | name="evidence_desc",
52 | field=models.CharField(max_length=200, verbose_name="evidence description"),
53 | ),
54 | migrations.AlterField(
55 | model_name="hypothesis",
56 | name="hypothesis_text",
57 | field=models.CharField(max_length=200, verbose_name="hypothesis"),
58 | ),
59 | ]
60 |
--------------------------------------------------------------------------------
/openach/tests/test_sitemap.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.urls import reverse
3 |
4 | from openach.models import Evidence, Hypothesis
5 | from openach.sitemap import BoardSitemap
6 |
7 | from .common import PrimaryUserTestCase, create_board, remove
8 |
9 |
10 | class SitemapTests(PrimaryUserTestCase):
11 | def setUp(self):
12 | super().setUp()
13 | self.board = create_board("Test Board", days=-5)
14 | self.evidence = Evidence.objects.create(
15 | board=self.board,
16 | creator=self.user,
17 | evidence_desc="Evidence #1",
18 | event_date=None,
19 | )
20 | self.hypotheses = [
21 | Hypothesis.objects.create(
22 | board=self.board,
23 | hypothesis_text="Hypothesis #1",
24 | creator=self.user,
25 | )
26 | ]
27 |
28 | def test_can_get_items(self):
29 | """Test that we can get all the boards."""
30 | sitemap = BoardSitemap()
31 | self.assertEqual(len(sitemap.items()), 1, "Sitemap included removed board")
32 |
33 | def test_cannot_get_removed_items(self):
34 | """Test that the sitemap doesn't include removed boards."""
35 | remove(self.board)
36 | sitemap = BoardSitemap()
37 | self.assertEqual(len(sitemap.items()), 0)
38 |
39 | def test_can_get_last_update(self):
40 | """Test that sitemap uses the latest change."""
41 | latest = Hypothesis.objects.create(
42 | board=self.board,
43 | hypothesis_text="Hypothesis #2",
44 | creator=self.user,
45 | )
46 | sitemap = BoardSitemap()
47 | board = sitemap.items()[0]
48 | self.assertEqual(sitemap.lastmod(board), latest.submit_date)
49 |
50 |
51 | class RobotsViewTests(TestCase):
52 | def test_can_render_robots_page(self):
53 | """Test that the robots.txt view returns a robots.txt that includes a sitemap."""
54 | response = self.client.get(reverse("robots"))
55 | self.assertTemplateUsed(response, "robots.txt")
56 | self.assertContains(response, "sitemap.xml", status_code=200)
57 | self.assertEqual(response["Content-Type"], "text/plain")
58 |
--------------------------------------------------------------------------------
/.github/workflows/integration.yaml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on: [push]
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 |
9 | env:
10 | CI: true
11 | DEBUG: true
12 | SITE_NAME: Open Synthesis (CI)
13 | SITE_DOMAIN: localhost
14 | DJANGO_SECRET_KEY: DONOTUSEINPRODUCTION
15 | ROLLBAR_ENABLED: false
16 | APP_LOG_LEVEL: DEBUG
17 | ALLOWED_HOSTS: 127.0.0.1
18 | ENVIRONMENT: development
19 | SECURE_SSL_REDIRECT: false
20 | SESSION_COOKIE_SECURE: false
21 | CSRF_COOKIE_SECURE: false
22 | CSRF_COOKIE_HTTPONLY: true
23 | ENABLE_CACHE: false
24 | ACCOUNT_REQUIRED: false
25 | ADMIN_EMAIL_ADDRESS: "admin@localhost"
26 | CELERY_ALWAYS_EAGER: true
27 |
28 | steps:
29 | - uses: actions/checkout@v6
30 | # https://docs.astral.sh/uv/guides/integration/github/#installation
31 | - name: Install uv
32 | uses: astral-sh/setup-uv@v7
33 | with:
34 | enable-cache: true
35 | cache-dependency-glob: "uv.lock"
36 | - uses: actions/setup-node@v6
37 | with:
38 | cache: npm
39 | - name: "Set up Python"
40 | uses: actions/setup-python@v6
41 | with:
42 | python-version-file: ".python-version"
43 | - name: Install development headers
44 | run: |
45 | sudo apt-get install libmemcached-dev
46 | - name: Install the project
47 | run: uv sync --all-extras --dev --group=coverage
48 | - name: Build Front-end
49 | # `npm ci` also runs `npm run build` because it's in the `post-install` action in package.json
50 | run: |
51 | npm ci
52 | uv run manage.py collectstatic --noinput
53 | - name: Django system checks
54 | run: |
55 | uv run manage.py check
56 | uv run manage.py makemigrations --check --dry-run
57 | - name: Run Tests
58 | run: uv run pytest --cov='.'
59 | - name: Upload Coverage
60 | # https://coveralls-python.readthedocs.io/en/latest/usage/configuration.html#github-actions-support
61 | run: |
62 | uv run coveralls --service=github
63 | env:
64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65 |
--------------------------------------------------------------------------------
/openach/views/notifications.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.contrib import messages
4 | from django.contrib.auth.decorators import login_required
5 | from django.http import HttpResponseRedirect
6 | from django.shortcuts import render
7 | from django.utils.translation import gettext as _
8 | from django.views.decorators.http import require_http_methods, require_safe
9 | from notifications.signals import notify
10 |
11 | from .util import make_paginator
12 |
13 | logger = logging.getLogger(__name__) # pylint: disable=invalid-name
14 |
15 |
16 | @require_safe
17 | @login_required
18 | def notifications(request):
19 | """Return a paginated list of notifications for the user."""
20 | notification_list = request.user.notifications.unread()
21 | context = {
22 | "notifications": make_paginator(request, notification_list),
23 | }
24 | return render(request, "boards/notifications/notifications.html", context)
25 |
26 |
27 | @require_http_methods(["HEAD", "GET", "POST"])
28 | @login_required
29 | def clear_notifications(request):
30 | """Handle POST request to clear notifications and redirect user to their profile."""
31 | if request.method == "POST":
32 | if "clear" in request.POST:
33 | request.user.notifications.mark_all_as_read()
34 | messages.success(request, _("Cleared all notifications."))
35 | return HttpResponseRedirect("/accounts/profile")
36 |
37 |
38 | def notify_followers(board, actor, verb, action_object):
39 | """Notify board followers of that have read permissions for the board."""
40 | for follow in board.followers.all().select_related("user"):
41 | if follow.user != actor and board.can_read(follow.user):
42 | notify.send(
43 | actor,
44 | recipient=follow.user,
45 | actor=actor,
46 | verb=verb,
47 | action_object=action_object,
48 | target=board,
49 | )
50 |
51 |
52 | def notify_add(board, actor, action_object):
53 | """Notify board followers of an addition."""
54 | notify_followers(board, actor, "added", action_object)
55 |
56 |
57 | def notify_edit(board, actor, action_object):
58 | """Notify board followers of an edit."""
59 | notify_followers(board, actor, "edited", action_object)
60 |
--------------------------------------------------------------------------------
/openach/migrations/0037_auto_20180107_2344.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.11.6 on 2018-01-07 23:44
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ("openach", "0036_defaultpermissions"),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterModelOptions(
15 | name="board",
16 | options={"ordering": ("-pub_date",)},
17 | ),
18 | migrations.AddField(
19 | model_name="boardpermissions",
20 | name="edit_board",
21 | field=models.PositiveSmallIntegerField(
22 | choices=[
23 | (0, "Only Me"),
24 | (1, "Collaborators"),
25 | (2, "Registered Users"),
26 | (3, "Public"),
27 | ],
28 | default=0,
29 | help_text="Who can edit the board title, description, and permissions?",
30 | ),
31 | ),
32 | migrations.AlterField(
33 | model_name="boardpermissions",
34 | name="add_comments",
35 | field=models.PositiveSmallIntegerField(
36 | choices=[
37 | (0, "Only Me"),
38 | (1, "Collaborators"),
39 | (2, "Registered Users"),
40 | (3, "Public"),
41 | ],
42 | default=1,
43 | help_text="Who can comment on the board?",
44 | ),
45 | ),
46 | migrations.AlterField(
47 | model_name="boardpermissions",
48 | name="collaborators",
49 | field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
50 | ),
51 | migrations.AlterField(
52 | model_name="boardpermissions",
53 | name="edit_elements",
54 | field=models.PositiveSmallIntegerField(
55 | choices=[
56 | (0, "Only Me"),
57 | (1, "Collaborators"),
58 | (2, "Registered Users"),
59 | (3, "Public"),
60 | ],
61 | default=1,
62 | help_text="Who can edit hypotheses, evidence, and sources?",
63 | ),
64 | ),
65 | ]
66 |
--------------------------------------------------------------------------------
/openach/static/boards/images/planet-earth.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
54 |
--------------------------------------------------------------------------------
/openach/templates/boards/email/_notification.html:
--------------------------------------------------------------------------------
1 | {% load board_extras %}
2 | {% load i18n %}
3 |
4 | {% if notification.verb == 'added' %}
5 | {% if notification.action_object|get_class == 'Hypothesis' %}
6 | {% blocktrans trimmed with site_domain=site.domain actor_id=notification.actor.id actor_username=notification.actor.username action_name=notification.action_object %}
7 | {{ actor_username }} added hypothesis '{{ action_name }}'
8 | {% endblocktrans %}
9 | {% elif notification.action_object|get_class == 'Evidence' %}
10 | {% url 'openach:evidence_detail' notification.action_object.id as evidence_detail_url %}
11 | {% blocktrans trimmed with site_domain=site.domain actor_id=notification.actor.id actor_username=notification.actor.username action_name=notification.action_object %}
12 | {{ actor_username }}
13 | added evidence
14 |
15 | '{{ action_name }}'
16 |
17 | {% endblocktrans %}
18 | {% else %}
19 | {{ notification }}
20 | {% endif %}
21 | {% elif notification.verb == 'edited' %}
22 | {% if notification.action_object|get_class == 'Hypothesis' %}
23 | {% blocktrans trimmed with site_domain=site.domain actor_id=notification.actor.id actor_username=notification.actor.username action_name=notification.action_object %}
24 | {{ actor_username }} edited hypothesis '{{ action_name }}'
25 | {% endblocktrans %}
26 | {% elif notification.action_object|get_class == 'Evidence' %}
27 | {% url 'openach:evidence_detail' notification.action_object.id as evidence_detail_url %}
28 | {% blocktrans trimmed with site_domain=site.domain actor_id=notification.actor.id actor_username=notification.actor.username action_name=notification.action_object %}
29 | {{ actor_username }}
30 | edited evidence
31 |
32 | '{{ action_name }}'
33 |
34 | {% endblocktrans %}
35 | {% else %}
36 | {{ notification }}
37 | {% endif %}
38 | {% else %}
39 | {{ notification }}
40 | {% endif %}
41 |
--------------------------------------------------------------------------------
/openach/tests/factories.py:
--------------------------------------------------------------------------------
1 | # Open Synthesis, an open platform for intelligence analysis
2 | # Copyright (C) 2016-2020 Open Synthesis Contributors. See CONTRIBUTING.md
3 | # file at the top-level directory of this distribution.
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 | #
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 | #
15 | # You should have received a copy of the GNU General Public License
16 | # along with this program. If not, see .
17 | import factory
18 | from django.contrib.auth import get_user_model
19 | from django.utils import timezone
20 | from factory.django import DjangoModelFactory
21 |
22 | from openach import models
23 |
24 | User = get_user_model()
25 |
26 |
27 | class BoardFactory(DjangoModelFactory):
28 | class Meta:
29 | model = models.Board
30 |
31 | board_title = factory.Sequence(lambda x: f"Board Title {x}")
32 |
33 | pub_date = factory.LazyFunction(timezone.now)
34 |
35 | board_desc = "Description"
36 |
37 | @factory.post_generation
38 | def teams(obj, create, extracted, **kwargs):
39 | if not create:
40 | return
41 | if extracted:
42 | obj.permissions.teams.set(extracted, clear=True)
43 |
44 | @factory.post_generation
45 | def permissions(obj, create, extracted, **kwargs):
46 | if not create:
47 | return
48 | if extracted:
49 | obj.permissions.update_all(extracted)
50 |
51 |
52 | class TeamFactory(DjangoModelFactory):
53 | class Meta:
54 | model = models.Team
55 |
56 | name = factory.Sequence(lambda x: f"Team {x}")
57 |
58 | @factory.post_generation
59 | def members(obj, create, extracted, **kwargs):
60 | if not create:
61 | return
62 | if extracted:
63 | obj.members.set([obj.owner, *extracted], clear=True)
64 |
65 |
66 | class UserFactory(DjangoModelFactory):
67 | class Meta:
68 | model = models.User
69 |
70 | username = factory.Sequence(lambda x: f"username{x}")
71 |
--------------------------------------------------------------------------------
/openach/tests/common.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.contrib.auth import get_user_model
4 | from django.test import Client, TestCase
5 | from django.utils import timezone
6 | from field_history.tracker import FieldHistoryTracker
7 |
8 | from openach.models import Board, BoardFollower
9 |
10 | PASSWORD = "commonpassword"
11 | USERNAME_PRIMARY = "john"
12 | USERNAME_OTHER = "paul"
13 |
14 | HTTP_OK = 200
15 | HTTP_FORBIDDEN = 403
16 | HTTP_REDIRECT = 302
17 |
18 | User = get_user_model()
19 |
20 |
21 | def create_board(board_title, days=0, public=True):
22 | """Create a board with the given title and publishing date offset.
23 |
24 | :param board_title: the board title
25 | :param days: negative for boards published in the past, positive for boards that have yet to be published
26 | :param public: true if all permissions should be set to public (see make_public)
27 | """
28 | time = timezone.now() + datetime.timedelta(days=days)
29 | board = Board.objects.create(board_title=board_title, pub_date=time)
30 | if public:
31 | board.permissions.make_public()
32 | return board
33 |
34 |
35 | def remove(model):
36 | """Mark model as removed."""
37 | model.removed = True
38 | model.save()
39 |
40 |
41 | def add_follower(board):
42 | """Create a user and have the user follow the given board."""
43 | follower = User.objects.create_user("bob", "bob@thebeatles.com", "bobpassword")
44 | BoardFollower.objects.create(
45 | user=follower,
46 | board=board,
47 | )
48 | return follower
49 |
50 |
51 | class PrimaryUserTestCase(TestCase):
52 | def assertStatus(self, response, expected_status):
53 | self.assertEqual(response.status_code, expected_status)
54 |
55 | def setUp(self):
56 | self.client = Client()
57 | self.user = User.objects.create_user(
58 | USERNAME_PRIMARY, f"{USERNAME_PRIMARY}@thebeatles.com", PASSWORD
59 | )
60 | self.other = User.objects.create_user(
61 | USERNAME_OTHER, f"{USERNAME_OTHER}@thebeatles.com", PASSWORD
62 | )
63 |
64 | def tearDown(self) -> None:
65 | FieldHistoryTracker.thread.request = None
66 |
67 | def login(self):
68 | self.client.login(username=USERNAME_PRIMARY, password=PASSWORD)
69 |
70 | def login_other(self):
71 | self.client.login(username=USERNAME_OTHER, password=PASSWORD)
72 |
73 | def logout(self):
74 | self.client.logout()
75 |
--------------------------------------------------------------------------------
/openach/migrations/0007_auto_20160826_1841.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.10 on 2016-08-26 18:41
2 |
3 | import django.db.models.deletion
4 | from django.conf import settings
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ("openach", "0006_evidence_submit_date"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="AnalystSourceTag",
18 | fields=[
19 | (
20 | "id",
21 | models.AutoField(
22 | auto_created=True,
23 | primary_key=True,
24 | serialize=False,
25 | verbose_name="ID",
26 | ),
27 | ),
28 | ("tag_date", models.DateTimeField(verbose_name="date tagged")),
29 | (
30 | "source",
31 | models.ForeignKey(
32 | on_delete=django.db.models.deletion.CASCADE,
33 | to="openach.EvidenceSource",
34 | ),
35 | ),
36 | ],
37 | ),
38 | migrations.CreateModel(
39 | name="EvidenceSourceTag",
40 | fields=[
41 | (
42 | "id",
43 | models.AutoField(
44 | auto_created=True,
45 | primary_key=True,
46 | serialize=False,
47 | verbose_name="ID",
48 | ),
49 | ),
50 | ("tag_name", models.CharField(max_length=64)),
51 | ("tag_desc", models.CharField(max_length=200)),
52 | ],
53 | ),
54 | migrations.AddField(
55 | model_name="analystsourcetag",
56 | name="tag",
57 | field=models.ForeignKey(
58 | on_delete=django.db.models.deletion.CASCADE,
59 | to="openach.EvidenceSourceTag",
60 | ),
61 | ),
62 | migrations.AddField(
63 | model_name="analystsourcetag",
64 | name="tagger",
65 | field=models.ForeignKey(
66 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
67 | ),
68 | ),
69 | ]
70 |
--------------------------------------------------------------------------------
/openach/migrations/0039_auto_20180111_0248.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.11.6 on 2018-01-11 02:48
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("openach", "0038_auto_20180108_0022"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="boardpermissions",
15 | name="add_comments",
16 | field=models.PositiveSmallIntegerField(
17 | choices=[(0, "Only Me"), (1, "Collaborators"), (2, "Registered Users")],
18 | default=1,
19 | help_text="Who can comment on the board?",
20 | ),
21 | ),
22 | migrations.AlterField(
23 | model_name="boardpermissions",
24 | name="add_elements",
25 | field=models.PositiveSmallIntegerField(
26 | choices=[(0, "Only Me"), (1, "Collaborators"), (2, "Registered Users")],
27 | default=1,
28 | help_text="Who can add hypotheses, evidence, and sources?",
29 | ),
30 | ),
31 | migrations.AlterField(
32 | model_name="boardpermissions",
33 | name="edit_board",
34 | field=models.PositiveSmallIntegerField(
35 | choices=[(0, "Only Me"), (1, "Collaborators"), (2, "Registered Users")],
36 | default=0,
37 | help_text="Who can edit the board title, description, and permissions?",
38 | ),
39 | ),
40 | migrations.AlterField(
41 | model_name="boardpermissions",
42 | name="edit_elements",
43 | field=models.PositiveSmallIntegerField(
44 | choices=[(0, "Only Me"), (1, "Collaborators"), (2, "Registered Users")],
45 | default=1,
46 | help_text="Who can edit hypotheses, evidence, and sources?",
47 | ),
48 | ),
49 | migrations.AlterField(
50 | model_name="boardpermissions",
51 | name="read_board",
52 | field=models.PositiveSmallIntegerField(
53 | choices=[
54 | (0, "Only Me"),
55 | (1, "Collaborators"),
56 | (2, "Registered Users"),
57 | (3, "Public"),
58 | ],
59 | default=3,
60 | help_text="Who can view the board?",
61 | ),
62 | ),
63 | ]
64 |
--------------------------------------------------------------------------------
/openach/decorators.py:
--------------------------------------------------------------------------------
1 | """Analysis of Competing Hypotheses View Decorators for managing caching and authorization."""
2 |
3 | from functools import wraps
4 |
5 | from django.conf import settings
6 | from django.contrib import messages
7 | from django.contrib.auth.decorators import REDIRECT_FIELD_NAME, user_passes_test
8 | from django.views.decorators.cache import cache_page
9 |
10 |
11 | def account_required(
12 | func=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None
13 | ):
14 | """Require that the (1) the user is logged in, or (2) that an account is not required to view the page.
15 |
16 | If the user fails the test, redirect the user to the log-in page. See also
17 | django.contrib.auth.decorators.login_required
18 | """
19 | req = getattr(settings, "ACCOUNT_REQUIRED", False)
20 | actual_decorator = user_passes_test(
21 | lambda u: not req or u.is_authenticated,
22 | login_url=login_url,
23 | redirect_field_name=redirect_field_name,
24 | )
25 | if func:
26 | return actual_decorator(func)
27 | return actual_decorator
28 |
29 |
30 | def cache_on_auth(timeout):
31 | """Cache the response based on whether or not the user is authenticated.
32 |
33 | Do NOT use on views that include user-specific information, e.g., CSRF tokens.
34 | """
35 |
36 | # https://stackoverflow.com/questions/11661503/django-caching-for-authenticated-users-only
37 | def _decorator(view_func):
38 | @wraps(view_func)
39 | def _wrapped_view(request, *args, **kwargs):
40 | key_prefix = "_auth_%s_" % request.user.is_authenticated
41 | return cache_page(timeout, key_prefix=key_prefix)(view_func)(
42 | request, *args, **kwargs
43 | )
44 |
45 | return _wrapped_view
46 |
47 | return _decorator
48 |
49 |
50 | def cache_if_anon(timeout):
51 | """Cache the view if the user is not authenticated and there are no messages to display."""
52 |
53 | # https://stackoverflow.com/questions/11661503/django-caching-for-authenticated-users-only
54 | def _decorator(view_func):
55 | @wraps(view_func)
56 | def _wrapped_view(request, *args, **kwargs):
57 | if request.user.is_authenticated or messages.get_messages(request):
58 | return view_func(request, *args, **kwargs)
59 | else:
60 | return cache_page(timeout)(view_func)(request, *args, **kwargs)
61 |
62 | return _wrapped_view
63 |
64 | return _decorator
65 |
--------------------------------------------------------------------------------
/openach/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 1.10 on 2016-08-24 18:12
2 |
3 | import django.db.models.deletion
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | initial = True
10 |
11 | dependencies = []
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="Board",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | auto_created=True,
21 | primary_key=True,
22 | serialize=False,
23 | verbose_name="ID",
24 | ),
25 | ),
26 | ("board_title", models.CharField(max_length=200)),
27 | ("board_desc", models.CharField(max_length=200)),
28 | ("pub_date", models.DateTimeField(verbose_name="date published")),
29 | ],
30 | ),
31 | migrations.CreateModel(
32 | name="Evidence",
33 | fields=[
34 | (
35 | "id",
36 | models.AutoField(
37 | auto_created=True,
38 | primary_key=True,
39 | serialize=False,
40 | verbose_name="ID",
41 | ),
42 | ),
43 | ("evidence_url", models.URLField()),
44 | (
45 | "board",
46 | models.ForeignKey(
47 | on_delete=django.db.models.deletion.CASCADE, to="openach.Board"
48 | ),
49 | ),
50 | ],
51 | ),
52 | migrations.CreateModel(
53 | name="Hypothesis",
54 | fields=[
55 | (
56 | "id",
57 | models.AutoField(
58 | auto_created=True,
59 | primary_key=True,
60 | serialize=False,
61 | verbose_name="ID",
62 | ),
63 | ),
64 | ("hypothesis_text", models.CharField(max_length=200)),
65 | (
66 | "board",
67 | models.ForeignKey(
68 | on_delete=django.db.models.deletion.CASCADE, to="openach.Board"
69 | ),
70 | ),
71 | ],
72 | ),
73 | ]
74 |
--------------------------------------------------------------------------------
/openach/management/commands/senddigest.py:
--------------------------------------------------------------------------------
1 | """Django admin command to send email digests."""
2 |
3 | from django.conf import settings
4 | from django.core.management.base import BaseCommand, CommandError
5 | from django.utils import timezone
6 |
7 | from openach.digest import send_digest_emails
8 | from openach.models import DigestFrequency
9 |
10 |
11 | class Command(BaseCommand):
12 | """Django admin command to send out digest emails."""
13 |
14 | help = "Send digest emails to users subscribed with the given frequency."
15 |
16 | def add_arguments(self, parser):
17 | """Add 'frequency' and 'force' command arguments."""
18 | parser.add_argument("frequency", choices=["daily", "weekly"])
19 | parser.add_argument(
20 | "--force",
21 | nargs="?",
22 | dest="force",
23 | const=True,
24 | default=False,
25 | help="Force the weekly digest to be sent regardless of day (default: False)",
26 | )
27 |
28 | def report(self, cnt):
29 | """Report number of emails sent to STDOUT."""
30 | msg = "Sent %s digest emails (%s skipped, %s failed)" % cnt
31 | self.stdout.write(self.style.SUCCESS(msg)) # pylint: disable=no-member
32 |
33 | def handle(self, *args, **options):
34 | """Handle the command invocation."""
35 | if options["frequency"] == "daily":
36 | self.report(send_digest_emails(DigestFrequency.daily))
37 | elif options["frequency"] == "weekly":
38 | digest_day = getattr(settings, "DIGEST_WEEKLY_DAY")
39 | current_day = timezone.now().weekday()
40 | if current_day == digest_day or options["force"]:
41 | if current_day != digest_day and options["force"]:
42 | msg = (
43 | "Forcing weekly digest to be sent (scheduled=%s, current=%s)"
44 | % (digest_day, current_day)
45 | )
46 | self.stdout.write(
47 | self.style.WARNING(msg)
48 | ) # pylint: disable=no-member
49 | self.report(send_digest_emails(DigestFrequency.weekly))
50 | else:
51 | msg = "Skipping weekly digest until day {} (current={})".format(
52 | digest_day,
53 | current_day,
54 | )
55 | self.stdout.write(self.style.WARNING(msg)) # pylint: disable=no-member
56 | else:
57 | raise CommandError('Expected frequency "daily" or "weekly"')
58 |
--------------------------------------------------------------------------------
/openach/tasks.py:
--------------------------------------------------------------------------------
1 | """Celery tasks.
2 |
3 | For more information, please see:
4 | - http://docs.celeryproject.org/en/latest/django/first-steps-with-django.html
5 |
6 | """
7 |
8 | import logging
9 | import re
10 |
11 | import requests
12 | from bs4 import BeautifulSoup
13 | from celery import shared_task
14 | from requests.adapters import HTTPAdapter
15 | from urllib3.util.retry import Retry
16 |
17 | from .models import EvidenceSource
18 |
19 | logger = logging.getLogger(__name__)
20 |
21 | SOURCE_METADATA_RETRY = Retry(
22 | total=5, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504]
23 | )
24 |
25 |
26 | @shared_task
27 | def example_task(x, y): # pragma: no cover
28 | """Add two numbers together.
29 |
30 | An example for reference.
31 | """
32 | return x + y
33 |
34 |
35 | def parse_metadata(html):
36 | """Return document metadata from Open Graph, meta, and title tags.
37 |
38 | If title Open Graph property is not set, uses the document title tag. If the description Open Graph property is
39 | not set, uses the description meta tag.
40 |
41 | For more information, please see:
42 | - http://ogp.me/
43 | """
44 | # adapted from https://github.com/erikriver/opengraph
45 | metadata = {}
46 | doc = BeautifulSoup(html, "html.parser")
47 | tags = doc.html.head.find_all(property=re.compile(r"^og"))
48 | for tag in tags:
49 | if tag.has_attr("content"):
50 | metadata[tag["property"][len("og:") :]] = tag["content"].strip()
51 |
52 | if "title" not in metadata and doc.title:
53 | metadata["title"] = doc.title.text.strip()
54 |
55 | if "description" not in metadata:
56 | description_tags = doc.html.head.find_all("meta", attrs={"name": "description"})
57 | # there should be at most one description tag per document
58 | if len(description_tags) > 0:
59 | metadata["description"] = description_tags[0].get("content", "").strip()
60 |
61 | return metadata
62 |
63 |
64 | @shared_task
65 | def fetch_source_metadata(source_id):
66 | """Fetch title and description metadata for the given source."""
67 | source = EvidenceSource.objects.get(id=source_id)
68 | session = requests.session()
69 | session.mount("http://", HTTPAdapter(max_retries=SOURCE_METADATA_RETRY))
70 | session.mount("https://", HTTPAdapter(max_retries=SOURCE_METADATA_RETRY))
71 | html = session.get(source.source_url)
72 | metadata = parse_metadata(html.text)
73 | source.source_title = metadata.get("title", "")
74 | source.source_description = metadata.get("description", "")
75 | source.save()
76 |
--------------------------------------------------------------------------------
/openach/templates/boards/notifications/_boards.html:
--------------------------------------------------------------------------------
1 | {% load board_extras %}
2 | {% load i18n %}
3 |
4 | {% if notification.verb == 'added' %}
5 | {% if notification.action_object|get_class == 'Hypothesis' %}
6 | {% blocktrans trimmed with notification.actor.id as actor_id and notification.actor.username as actor_username and notification.action_object as action_name and notification.target|board_url as custom_url and notification.target as target and notification.timesince as timesince %}
7 | {{ actor_username }}
8 | added hypothesis '{{ action_name }}' to board {{ target }} {{ timesince }} ago.
9 | {% endblocktrans %}
10 | {% elif notification.action_object|get_class == 'Evidence' %}
11 | {% blocktrans trimmed with notification.actor.id as actor_id and notification.actor.username as actor_username and notification.action_object as action_name and notification.target|board_url as custom_url and notification.target as target and notification.timesince as timesince %}
12 | {{ actor_username }}
13 | added evidence '{{ action_name }}' to board {{ target }} {{ timesince }} ago.
14 | {% endblocktrans %}
15 | {% else %}
16 | {{ notification }}
17 | {% endif %}
18 | {% elif notification.verb == 'edited' %}
19 | {% if notification.action_object|get_class == 'Hypothesis' %}
20 | {% blocktrans trimmed with notification.actor.id as actor_id and notification.actor.username as actor_username and notification.action_object as action_name and notification.target|board_url as custom_url and notification.target as target and notification.timesince as timesince %}
21 | {{ actor_username }}
22 | edited hypothesis '{{ action_name }}' on board {{ target }} {{ timesince }} ago.
23 | {% endblocktrans %}
24 | {% elif notification.action_object|get_class == 'Evidence' %}
25 | {% blocktrans trimmed with notification.actor.id as actor_id and notification.actor.username as actor_username and notification.action_object as action_name and notification.target|board_url as custom_url and notification.target as target and notification.timesince as timesince %}
26 | {{ actor_username }}
27 | edited evidence '{{ action_name }}' on board {{ target }} {{ timesince }} ago.
28 | {% endblocktrans %}
29 | {% else %}
30 | {{ notification }}
31 | {% endif %}
32 | {% else %}
33 | {{ notification }}
34 | {% endif %}
35 |
--------------------------------------------------------------------------------
/openach/templates/boards/evaluate.html:
--------------------------------------------------------------------------------
1 | {% extends 'boards/base.html' %}
2 | {% load board_extras %}
3 | {% load i18n %}
4 |
5 | {% block title %}{% trans "Evaluate Evidence" %} | {{ site.name }}{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
{% trans "Evaluate Evidence" %}
10 |
11 |
12 | {% trans "Instructions:" %}
13 | {% url 'openach:evidence_detail' evidence.id as evidence_url %}
14 | {% blocktrans trimmed %}
15 | Evaluate the consistency of each hypothesis with respect to the following evidence. We've hidden your previous
16 | evaluations and randomized the order of the hypotheses in order to prevent bias. If the evidence does not
17 | apply to a hypothesis, select N/A.
18 | {% endblocktrans %}
19 |
20 |
21 | {% blocktrans trimmed %}
22 | Assume that the evidence is valid. You can evaluate the validity of the evidence (and its sources) on
23 | the evidence detail page.
24 | {% endblocktrans %}
25 |
26 |
27 |
{{ evidence.evidence_desc }}
28 |
29 |
56 |
57 | {% endblock %}
58 |
--------------------------------------------------------------------------------
/webpack.config.base.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Open Synthesis, an open platform for intelligence analysis
3 | * Copyright (C) 2016-2020 Open Synthesis Contributors. See CONTRIBUTING.md
4 | * file at the top-level directory of this distribution.
5 | *
6 | * This program is free software: you can redistribute it and/or modify
7 | * it under the terms of the GNU General Public License as published by
8 | * the Free Software Foundation, either version 3 of the License, or
9 | * (at your option) any later version.
10 | *
11 | * This program is distributed in the hope that it will be useful,
12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | * GNU General Public License for more details.
15 | *
16 | * You should have received a copy of the GNU General Public License
17 | * along with this program. If not, see .
18 | */
19 | const path = require("path");
20 | const webpack = require("webpack");
21 | const BundleTracker = require("webpack-bundle-tracker");
22 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
23 |
24 | module.exports = {
25 | context: __dirname,
26 | entry: path.resolve(__dirname, "openach-frontend/src/js/index.js"),
27 | output: {
28 | publicPath: "/static/",
29 | path: path.resolve("./openach-frontend/bundles/"),
30 | filename: "[name]-[hash].js",
31 | },
32 | resolve: {
33 | alias: {
34 | css: path.resolve(__dirname, "openach-frontend/src/css/"),
35 | src: path.resolve(__dirname, "openach-frontend/src/js/"),
36 | },
37 | },
38 | plugins: [
39 | new BundleTracker({ filename: "./webpack-stats.json" }),
40 | // https://webpack.github.io/docs/list-of-plugins.html#provideplugin
41 | new webpack.ProvidePlugin({
42 | $: "jquery",
43 | jQuery: "jquery",
44 | }),
45 | ],
46 | module: {
47 | rules: [
48 | {
49 | test: /\.css$/,
50 | use: [
51 | {
52 | loader: MiniCssExtractPlugin.loader,
53 | },
54 | "css-loader",
55 | ],
56 | },
57 | {
58 | test: /\.m?js$/,
59 | exclude: /(node_modules|bower_components)/,
60 | use: {
61 | loader: "babel-loader",
62 | options: {
63 | presets: ["@babel/preset-env"],
64 | },
65 | },
66 | },
67 | {
68 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
69 | use: [
70 | {
71 | loader: "file-loader",
72 | options: {
73 | name: "[name].[ext]",
74 | outputPath: "fonts/",
75 | },
76 | },
77 | ],
78 | },
79 | ],
80 | },
81 | };
82 |
--------------------------------------------------------------------------------
/openintel/urls.py:
--------------------------------------------------------------------------------
1 | """Open Synthesis URL Configuration.
2 |
3 | See the Django documentation for more information:
4 | * https://docs.djangoproject.com/en/2.1/ref/urls/
5 | * https://docs.djangoproject.com/en/2.1/topics/http/urls/
6 | """
7 |
8 | import notifications.urls
9 | from django.conf import settings
10 | from django.contrib import admin
11 | from django.contrib.sitemaps.views import sitemap
12 | from django.urls import include, path, re_path
13 | from django.views.generic import TemplateView
14 |
15 | from openach import views
16 | from openach.sitemap import BoardSitemap
17 |
18 | ACCOUNT_REQUIRED = getattr(settings, "ACCOUNT_REQUIRED", False)
19 |
20 | # NOTE: Django's API doesn't follow constant naming convention for 'sitemaps' and 'urlpatterns'
21 |
22 | # https://docs.djangoproject.com/en/1.10/ref/contrib/sitemaps/#initialization
23 | sitemaps = { # pylint: disable=invalid-name
24 | "board": BoardSitemap,
25 | }
26 |
27 | urlpatterns = [ # pylint: disable=invalid-name
28 | path("admin/", admin.site.urls),
29 | path("robots.txt", views.site.robots, name="robots"),
30 | path(
31 | "contribute.json",
32 | TemplateView.as_view(
33 | template_name="contribute.json", content_type="application/json"
34 | ),
35 | ),
36 | path(
37 | "accounts/signup/",
38 | views.site.CaptchaSignupView.as_view(),
39 | name="account_signup",
40 | ),
41 | path("accounts//", views.profiles.profile, name="profile"),
42 | path("accounts/profile/", views.profiles.private_profile, name="private_profile"),
43 | path("accounts/", include("allauth.urls")),
44 | path("comments/", include("django_comments.urls")),
45 | path(
46 | "inbox/notifications/", include(notifications.urls, namespace="notifications")
47 | ),
48 | path("invitations/", include("invitations.urls", namespace="invitations")),
49 | path("i18n/", include("django.conf.urls.i18n")),
50 | path("", include("openach.urls")),
51 | re_path(
52 | r"\.well-known/acme-challenge/(?P[a-zA-Z0-9\-_]+)$",
53 | views.site.certbot,
54 | ),
55 | ]
56 |
57 |
58 | if settings.DEBUG:
59 | import debug_toolbar
60 |
61 | urlpatterns = [
62 | path("__debug__/", include(debug_toolbar.urls)),
63 | ] + urlpatterns
64 |
65 |
66 | if not ACCOUNT_REQUIRED: # pylint: disable=invalid-name
67 | # Only allow clients to view the sitemap if the admin is running a public instance
68 | urlpatterns.insert(
69 | 0,
70 | path(
71 | r"sitemap.xml",
72 | sitemap,
73 | {"sitemaps": sitemaps},
74 | name="django.contrib.sitemaps.views.sitemap",
75 | ),
76 | ) # nopep8
77 |
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ main ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ main ]
20 | schedule:
21 | - cron: '40 19 * * 6'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript', 'python' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
37 | # Learn more:
38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
39 |
40 | steps:
41 | - name: Checkout repository
42 | uses: actions/checkout@v6
43 |
44 | # Initializes the CodeQL tools for scanning.
45 | - name: Initialize CodeQL
46 | uses: github/codeql-action/init@v4
47 | with:
48 | languages: ${{ matrix.language }}
49 | # If you wish to specify custom queries, you can do so here or in a config file.
50 | # By default, queries listed here will override any specified in a config file.
51 | # Prefix the list here with "+" to use these queries and those in the config file.
52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
53 |
54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
55 | # If this step fails, then you should remove it and run the build manually (see below)
56 | - name: Autobuild
57 | uses: github/codeql-action/autobuild@v4
58 |
59 | # ℹ️ Command-line programs to run using the OS shell.
60 | # 📚 https://git.io/JvXDl
61 |
62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
63 | # and modify them (or add more) to build your code if your project
64 | # uses a compiled language
65 |
66 | #- run: |
67 | # make bootstrap
68 | # make release
69 |
70 | - name: Perform CodeQL Analysis
71 | uses: github/codeql-action/analyze@v4
72 |
--------------------------------------------------------------------------------
/openach-frontend/src/js/notify.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Open Synthesis, an open platform for intelligence analysis
3 | * Copyright (C) 2016 Open Synthesis Contributors. See CONTRIBUTING.md
4 | * file at the top-level directory of this distribution.
5 | *
6 | * This program is free software: you can redistribute it and/or modify
7 | * it under the terms of the GNU General Public License as published by
8 | * the Free Software Foundation, either version 3 of the License, or
9 | * (at your option) any later version.
10 | *
11 | * This program is distributed in the hope that it will be useful,
12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | * GNU General Public License for more details.
15 | *
16 | * You should have received a copy of the GNU General Public License
17 | * along with this program. If not, see .
18 | */
19 |
20 | // Adapted from: https://github.com/django-notifications/django-notifications/blob/master/notifications/static/notifications/notify.js
21 |
22 | // sessionStorage key for the last known number of unread notifications
23 | const NOTIFICATION_KEY = "unread_notifications";
24 | const NOTIFY_REFRESH_PERIOD_MILLIS = 15 * 1000;
25 | const MAX_RETRIES = 5;
26 |
27 | /**
28 | * Returns a function that queries the number of unread notifications and sets the text of badge to the number.
29 | *
30 | * Re-queries the server every NOTIFY_REFRESH_PERIOD_MILLIS milliseconds. Stops querying the server if more than
31 | * MAX_RETRIES consecutive requests fail.
32 | *
33 | * @param badge the badge JQuery selector
34 | * @param {string} url the url to request
35 | * @returns {Function} function that updates the unread notification count
36 | */
37 | function fetch_api_data(badge, url) {
38 | let consecutiveMisfires = 0;
39 | return function () {
40 | $.get(url, function (data) {
41 | consecutiveMisfires = 0;
42 | badge.text(data.unread_count);
43 | window.sessionStorage.setItem(NOTIFICATION_KEY, data.unread_count);
44 | })
45 | .fail(function () {
46 | consecutiveMisfires++;
47 | })
48 | .always(function () {
49 | if (consecutiveMisfires <= MAX_RETRIES) {
50 | setTimeout(fetch_api_data(badge, url), NOTIFY_REFRESH_PERIOD_MILLIS);
51 | } else {
52 | badge.text("!");
53 | badge.prop("title", "No connection to server");
54 | }
55 | });
56 | };
57 | }
58 |
59 | // NOTE: in practice, there will only be one element that has a data-notify-api-url attribute
60 | $("[data-notify-api-url]").each(function (index, badge) {
61 | const elt = $(badge);
62 | setTimeout(fetch_api_data(elt, elt.data("notify-api-url")), 1000);
63 | const previous = window.sessionStorage.getItem(NOTIFICATION_KEY);
64 | if (previous != null) {
65 | elt.text(previous);
66 | }
67 | });
68 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Responsible Disclosure Policy
2 |
3 | Open Synthesis Responsible Disclosure Policy
4 |
5 | We take the security of our systems seriously, and we value the security community. The responsible disclosure of
6 | security vulnerabilities helps us ensure the security and privacy of our users.
7 |
8 | **Guidelines**
9 |
10 | We require that all researchers:
11 | * Make every effort to avoid privacy violations, degradation of user experience, disruption to production systems, and
12 | destruction of data during security testing;
13 | * Perform research only within the scope set out below;
14 | * Use the identified communication channels to report vulnerability information to us; and
15 | * Keep information about any vulnerabilities you’ve discovered confidential between yourself and Open Synthesis until
16 | we’ve had [60] days to resolve the issue.
17 |
18 |
19 | If you follow these guidelines when reporting an issue to us, we commit to:
20 | * Not pursue or support any legal action related to your research;
21 | * Work with you to understand and resolve the issue quickly (including an initial confirmation of your report within
22 | 72 hours of submission);
23 | * Recognize your contribution on our Security Researcher Hall of Fame, if you are the first to report the issue and we
24 | make a code or configuration change based on the issue.
25 |
26 |
27 | **Scope**
28 | * https://www.opensynthesis.org
29 | * https://open-synthesis-sandbox.herokuapp.com/
30 |
31 |
32 | **Out of scope**
33 | Any services hosted by 3rd party providers and services are excluded from scope. These services include, but are not
34 | limited to:
35 | * GitHub
36 | * Gitter
37 | * Rollbar
38 | * SendGrid
39 |
40 |
41 | In the interest of the safety of our users, staff, the Internet at large and you as a security researcher, the
42 | following test types are excluded from scope:
43 | * Findings from physical testing such as office access (e.g. open doors, tailgating)
44 | * Findings derived primarily from social engineering (e.g. phishing, vishing)
45 | * Findings from applications or systems not listed in the 'Scope' section
46 | * UI and UX bugs and spelling mistakes
47 | * Network level Denial of Service (DoS/DDoS) vulnerabilities
48 |
49 | Things we do not want to receive:
50 | * Personally identifiable information (PII)
51 | * Credit card holder data
52 |
53 |
54 | **How to report a security vulnerability?**
55 | If you believe you’ve found a security vulnerability in one of our products or platforms please send it to us by
56 | emailing security@opensynthesis.org. Please include the following details with your report:
57 |
58 | * Description of the location and potential impact of the vulnerability;
59 | * A detailed description of the steps required to reproduce the vulnerability (POC scripts, screenshots, and compressed
60 | screen captures are all helpful to us); and
61 | * Your name/handle and a link for recognition in our Hall of Fame.
62 |
63 | If you’d like to encrypt the information, please use the following PGP key: https://keybase.io/tschiller.
64 |
--------------------------------------------------------------------------------
/openach/templates/teams/_member_panel.html:
--------------------------------------------------------------------------------
1 | {% load i18n %}
2 |
3 |
54 |
--------------------------------------------------------------------------------
/openach/views/profiles.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from django.conf import settings
4 | from django.contrib import messages
5 | from django.contrib.auth.decorators import login_required
6 | from django.contrib.auth.models import User
7 | from django.shortcuts import get_object_or_404, render
8 | from django.utils.translation import gettext as _
9 | from django.views.decorators.cache import cache_page
10 | from django.views.decorators.http import require_http_methods, require_safe
11 |
12 | from openach.decorators import account_required
13 | from openach.forms import SettingsForm
14 | from openach.metrics import (
15 | user_boards_contributed,
16 | user_boards_created,
17 | user_boards_evaluated,
18 | )
19 |
20 | PAGE_CACHE_TIMEOUT_SECONDS = getattr(settings, "PAGE_CACHE_TIMEOUT_SECONDS", 60)
21 |
22 | logger = logging.getLogger(__name__) # pylint: disable=invalid-name
23 |
24 |
25 | @require_http_methods(["HEAD", "GET", "POST"])
26 | @login_required
27 | def private_profile(request):
28 | """Return a view of the private profile associated with the authenticated user and handle settings."""
29 | user = request.user
30 |
31 | if request.method == "POST":
32 | form = SettingsForm(request.POST, instance=user.settings)
33 | if form.is_valid():
34 | form.save()
35 | messages.success(request, _("Updated account settings."))
36 | else:
37 | form = SettingsForm(instance=user.settings)
38 |
39 | context = {
40 | "user": user,
41 | "boards_created": user_boards_created(user, viewing_user=user)[:5],
42 | "boards_contributed": user_boards_contributed(user, viewing_user=user),
43 | "board_voted": user_boards_evaluated(user, viewing_user=user),
44 | "meta_description": _("Account profile for user {name}").format(name=user),
45 | "notifications": request.user.notifications.unread(),
46 | "settings_form": form,
47 | }
48 | return render(request, "boards/profile.html", context)
49 |
50 |
51 | @require_safe
52 | @cache_page(PAGE_CACHE_TIMEOUT_SECONDS)
53 | def public_profile(request, account_id):
54 | """Return a view of the public profile associated with account_id."""
55 | user = get_object_or_404(User, pk=account_id)
56 | context = {
57 | "user": user,
58 | "boards_created": user_boards_created(user, viewing_user=request.user)[:5],
59 | "boards_contributed": user_boards_contributed(user, viewing_user=request.user),
60 | "board_voted": user_boards_evaluated(user, viewing_user=request.user),
61 | "meta_description": _("Account profile for user {name}").format(name=user),
62 | }
63 | return render(request, "boards/public_profile.html", context)
64 |
65 |
66 | @require_http_methods(["HEAD", "GET", "POST"])
67 | @account_required
68 | def profile(request, account_id):
69 | """Return a view of the profile associated with account_id.
70 |
71 | If account_id corresponds to the authenticated user, return the private profile view. Otherwise return the public
72 | profile.
73 | """
74 | return (
75 | private_profile(request)
76 | if request.user.id == int(account_id)
77 | else public_profile(request, account_id)
78 | )
79 |
--------------------------------------------------------------------------------
/openach/tests/test_models.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.core.exceptions import ValidationError
4 | from django.test import TestCase
5 | from django.utils import timezone
6 |
7 | from openach.models import Board
8 | from openach.tests.common import PrimaryUserTestCase, remove
9 |
10 |
11 | class RemovableModelManagerTests(TestCase):
12 | def test_objects_does_not_include_removed(self):
13 | """Test that after an object is marked as removed, it doesn't appear in the query set."""
14 | board = Board.objects.create(
15 | board_title="Title", board_desc="Description", pub_date=timezone.now()
16 | )
17 | self.assertEqual(Board.objects.count(), 1)
18 | remove(board)
19 | self.assertEqual(Board.objects.count(), 0)
20 | self.assertEqual(Board.all_objects.count(), 1)
21 |
22 |
23 | class BoardMethodTests(TestCase):
24 | def test_was_published_recently_with_future_board(self):
25 | """Test that was_published_recently() returns False for board whose pub_date is in the future."""
26 | time = timezone.now() + datetime.timedelta(days=30)
27 | future_board = Board(pub_date=time)
28 | self.assertIs(future_board.was_published_recently(), False)
29 |
30 | def test_was_published_recently_with_old_question(self):
31 | """Test that was_published_recently() returns False for boards whose pub_date is older than 1 day."""
32 | time = timezone.now() - datetime.timedelta(days=30)
33 | old_board = Board(pub_date=time)
34 | self.assertIs(old_board.was_published_recently(), False)
35 |
36 | def test_was_published_recently_with_recent_question(self):
37 | """Test that was_published_recently() returns True for boards whose pub_date is within the last day."""
38 | time = timezone.now() - datetime.timedelta(hours=1)
39 | recent_board = Board(pub_date=time)
40 | self.assertIs(recent_board.was_published_recently(), True)
41 |
42 | def test_board_url_without_slug(self):
43 | """Test to make sure we can grab the URL of a board that has no slug."""
44 | self.assertIsNotNone(Board(id=1).get_absolute_url())
45 |
46 | def test_board_url_with_slug(self):
47 | """Test to make sure we can grab the URL of a board that has a slug."""
48 | slug = "test-slug"
49 | self.assertTrue(slug in Board(id=1, board_slug=slug).get_absolute_url())
50 |
51 |
52 | class BoardTitleTests(PrimaryUserTestCase):
53 | def test_board_title_special_characters(self):
54 | """Test to make sure certain characters are allowed/disallowed in titles."""
55 | board = Board(
56 | board_desc="Test Board Description",
57 | creator=self.user,
58 | pub_date=timezone.now(),
59 | )
60 | fail_titles = [
61 | "Test Board Title!",
62 | "Test Board @ Title",
63 | "Test #Board Title",
64 | "Test Board Title++++",
65 | ]
66 | for title in fail_titles:
67 | board.board_title = title
68 | self.assertRaises(ValidationError, board.full_clean)
69 |
70 | pass_titles = [
71 | "Test Böard Titlé",
72 | "Test (Board) Title?",
73 | "Test Board & Title",
74 | "Test Board/Title",
75 | ]
76 | for title in pass_titles:
77 | board.board_title = title
78 | board.full_clean()
79 |
--------------------------------------------------------------------------------
/openach/tests/test_admin.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | from django.conf import settings
4 | from django.contrib.auth.models import User
5 | from django.core import mail
6 | from django.test import TestCase, override_settings
7 |
8 | DEFAULT_FROM_EMAIL = getattr(settings, "DEFAULT_FROM_EMAIL", "admin@localhost")
9 |
10 |
11 | class AccountManagementTests(TestCase):
12 | """Project-specific account management tests. General tests should be in the django-allauth library."""
13 |
14 | username = "testuser"
15 |
16 | email = "testemail@google.com"
17 |
18 | valid_data = {
19 | "username": username,
20 | "email": email,
21 | "password1": "testpassword1!",
22 | "password2": "testpassword1!",
23 | }
24 |
25 | @override_settings(
26 | INVITATIONS_INVITATION_ONLY=False, INVITE_REQUEST_URL="https://google.com"
27 | )
28 | def test_can_show_signup_form(self):
29 | """Test that a non-logged-in user can view the sign-up form."""
30 | response = self.client.get("/accounts/signup/")
31 | self.assertTemplateUsed("/account/email/signup.html")
32 | self.assertNotContains(response, "invitation")
33 |
34 | @override_settings(
35 | INVITATIONS_INVITATION_ONLY=True, INVITE_REQUEST_URL="https://google.com"
36 | )
37 | def test_can_show_invite_url(self):
38 | """Test that a non-logged-in user can view the sign-up form that has an invite link."""
39 | response = self.client.get("/accounts/signup/")
40 | self.assertContains(response, "invitation")
41 |
42 | @override_settings(ACCOUNT_EMAIL_REQUIRED=True)
43 | def test_email_address_required(self):
44 | """Test that signup without email is rejected."""
45 | response = self.client.post(
46 | "/accounts/signup/", data={**self.valid_data, "email": ""}
47 | )
48 | self.assertTemplateUsed("account/signup.html")
49 | # behavior is for the form-group of the email address to have has-error and to have its help text set
50 | self.assertContains(response, "This field is required.")
51 |
52 | @override_settings(
53 | ACCOUNT_EMAIL_REQUIRED=True, ACCOUNT_EMAIL_VERIFICATION="mandatory"
54 | )
55 | @patch("allauth.account.signals.email_confirmation_sent.send")
56 | def test_account_signup_flow(self, mock):
57 | """Test that the user receives a confirmation email when they signup for an account with an email address."""
58 | response = self.client.post("/accounts/signup/", data=self.valid_data)
59 | self.assertRedirects(response, expected_url="/accounts/confirm-email/")
60 |
61 | self.assertEqual(mock.call_count, 1)
62 | self.assertEqual(len(mail.outbox), 1, "No confirmation email sent")
63 |
64 | # The example.com domain comes from django.contrib.sites plugin
65 | self.assertEqual(
66 | mail.outbox[0].subject, "[example.com] Please Confirm Your E-mail Address"
67 | )
68 | self.assertListEqual(mail.outbox[0].to, [self.email])
69 | self.assertEqual(mail.outbox[0].from_email, DEFAULT_FROM_EMAIL)
70 |
71 | @override_settings(ACCOUNT_EMAIL_REQUIRED=False)
72 | def test_settings_created(self):
73 | """Test that a settings object is created when the user is created."""
74 | self.client.post("/accounts/signup/", data=self.valid_data)
75 | user = User.objects.get(username=self.username)
76 | self.assertIsNotNone(user.settings, "User settings object not created")
77 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Open Synthesis
2 |
3 | [](https://travis-ci.org/twschiller/open-synthesis)
4 | [](https://coveralls.io/github/twschiller/open-synthesis?branch=master)
5 | [](https://codeclimate.com/github/twschiller/open-synthesis)
6 | [](LICENSE.md)
7 |
8 | ## 2024-03-09: THIS REPOSITORY IS IN MAINTENANCE MODE
9 |
10 | This repository is in maintenance mode. I will be applying security dependency updates, but am not accepting issues/PRs.
11 |
12 | ## Goals
13 |
14 | The purpose of the Open Synthesis project is to empower the public to synthesize vast amounts of information into actionable conclusions.
15 |
16 | To this end, the platform and its governance aims to be:
17 |
18 | 1. Inclusive
19 | 2. Transparent
20 | 3. Meritocratic
21 |
22 | Our approach is to take best practices from the intelligence and business communities and adapt them to work with internet
23 | communities.
24 |
25 | ## Analysis of Competing Hypotheses (ACH)
26 |
27 | Initially, the platform will support the [Analysis of Competing Hypotheses (ACH) framework.](https://en.wikipedia.org/wiki/Analysis_of_competing_hypotheses)
28 | ACH was developed by Richards J. Heuer, Jr. for use at the United States Central Intelligence Agency (CIA).
29 |
30 | The ACH framework is a good candidate for public discourse because:
31 |
32 | * ACH's hypothesis generation and evidence cataloging benefit from a diversity of perspectives
33 | * ACH's process for combining the viewpoints of participants is straightforward and robust
34 | * ACH can account for unreliable evidence, e.g., due to deception
35 |
36 | The initial implementation will be similar to [competinghypotheses.org](http://competinghypotheses.org/). However, we
37 | will adapt the implementation to address the challenges of public discourse.
38 |
39 | ## Platform Design Principles
40 |
41 | The platform will host the analysis of politically sensitive topics. Therefore, its design must strike a balance between
42 | freedom of speech, safety, and productivity. More specific concerns include:
43 |
44 | * Open-Source Licensing and Governance
45 | * Privacy
46 | * Accessibility
47 | * Internationalization and Localization
48 | * Moderation vs. Censorship
49 |
50 | ## Deploying to Heroku
51 |
52 | Detailed instructions for deploying your own instance can be found on the
53 | [Custom Deployments wiki page](https://github.com/twschiller/open-synthesis/wiki/Custom-Deployments).
54 |
55 | [](https://heroku.com/deploy)
56 |
57 | ## Copyright
58 |
59 | Copyright (C) 2016-2023 Open Synthesis Contributors. See CONTRIBUTING.md file at the top-level directory of this
60 | distribution.
61 |
62 | This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
63 | Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
64 | any later version.
65 |
66 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
67 | warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
68 | more details.
69 |
70 | You should have received a copy of the GNU Affero General Public License along with this program.
71 | If not, see .
72 |
--------------------------------------------------------------------------------