From dc5e6f8e5cc905031a0a3320fc6d12b16c3d16e2 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Fri, 22 Jul 2016 19:02:07 +0200 Subject: [PATCH 01/23] Add initial layout for notifications framework --- graphs/models.py | 12 ++++++- graphs/templates/base.html | 5 ++- graphs/templates/graphs/notifications.html | 39 ++++++++++++++++++++++ graphs/urls.py | 3 ++ graphs/views.py | 9 +++++ 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 graphs/templates/graphs/notifications.html diff --git a/graphs/models.py b/graphs/models.py index 722896ac..88d7c579 100644 --- a/graphs/models.py +++ b/graphs/models.py @@ -10,7 +10,7 @@ ex. 'id' for user table would be 'user_id' ''' -from sqlalchemy import Column, Integer, String, ForeignKey, Table, Index, ForeignKeyConstraint +from sqlalchemy import Column, Integer, String, ForeignKey, Table, Index, ForeignKeyConstraint, Boolean from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, backref from sqlalchemy.types import TIMESTAMP @@ -310,6 +310,16 @@ class Edge(Base): ForeignKeyConstraint([user_id, graph_id, tail_node_id], [Node.user_id, Node.graph_id, Node.node_id], ondelete="CASCADE", onupdate="CASCADE"), {}) #no relationship specified +class share_graph_event(Base): + __tablename__ = 'share_graph_event' + id = Column(Integer, autoincrement=True, primary_key=True) + graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + owner_id = Column(String, ForeignKey('group.owner_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + group_id = Column(String, ForeignKey('group.group_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + member_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + share_time = Column(TIMESTAMP, nullable = False) + is_active = Column(Boolean) + #Create indices Index('graph_public_idx', Graph.public) Index('graph_owner_idx', Graph.user_id) diff --git a/graphs/templates/base.html b/graphs/templates/base.html index a022f40d..e8c7d612 100644 --- a/graphs/templates/base.html +++ b/graphs/templates/base.html @@ -109,7 +109,10 @@ {% endif %}
  • Features
  • Help
  • -
  • About Us
  • +
  • About Us
  • + {% if uid != None %} +
  • Notifications
  • + {% endif %} diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html new file mode 100644 index 00000000..e9125f85 --- /dev/null +++ b/graphs/templates/graphs/notifications.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% block content %} +{% load staticfiles %} + +
    +
    +

    Group1

    +
    + + + + + + + + +
    seth.mridul@gmail.com shared graph some_name_of_graph with the group Group 1.
    seth.mridul@gmail.com shared graph some_other_graph with the group Group 1.
    +
    + +
    +
    +

    Group 2

    +
    + + + + + + + + + + + +
    seth.mridul@gmail.com shared graph new_graph with the group Group 2.
    seth.mridul@gmail.com shared graph test_graph with the group Group 2.
    seth.mridul@gmail.com shared graph random_graph with the group Group 2.
    + +
    + +{% endblock %} \ No newline at end of file diff --git a/graphs/urls.py b/graphs/urls.py index 7aee8be0..7d97258a 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -22,6 +22,9 @@ url(r'^graphs/public/$', views.public_graphs, name='public_graphs'), url(r'^graphs/upload/$', views.upload_graph_through_ui, name='upload_graph_through_ui'), + # notifications page + url(r'^notifications/$', views.notifications, name='notifications'), + # view graph page. This contains regular expression to catch url in the form of the following: # /graphs/email_address/graph_id/ # regex from http://www.regular-expressions.info/email.html diff --git a/graphs/views.py b/graphs/views.py index 01ff87ea..99c19c3d 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -297,6 +297,15 @@ def _graphs_page(request, view_type): return render(request, 'graphs/graphs.html', context) +def notifications(request): + # context of the view to be passed in for rendering + context = {} + # handle login + context = login(request) + # Checks to see if a user is currently logged on + uid = request.session['uid'] + return render(request, 'graphs/notifications.html', context) + def upload_graph_through_ui(request): if request.method == 'POST': From 3d9a85dc1f056f6c2758c3c2489ac2a28a31551c Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Fri, 22 Jul 2016 19:19:15 +0200 Subject: [PATCH 02/23] Add spec for share graph event db functions --- graphs/util/db.py | 59 ++++++++++++++++++++++++++++++++++++++++++ graphs/util/db_test.py | 0 2 files changed, 59 insertions(+) create mode 100644 graphs/util/db_test.py diff --git a/graphs/util/db.py b/graphs/util/db.py index 8fe69510..4a8b2635 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -4742,3 +4742,62 @@ def constructResponse(statusCode, message, error): response['Error'] = error return response + + +def add_share_graph_event(graph_id, owner_id, group_id, member_id): + ''' + Add a new share graph event to the table. + After sharing the graph with a group this function will create + a share graph event for all the users in that group + + @param graph_id: id of the graph shared + @param owner_id: owner of the graph which is shared + @param group_id: id of the grop + @param member_id: id of the member the graph is shared + ''' + pass + +def update_share_graph_event(event_id, active): + ''' + Update the share graph event. Change its active state. + If active is True then the notification is not read/clicked. + + @param event_id: id of the share graph event + @param active: Boolean value, update the state of event + ''' + pass + + +def delete_share_graph_event(event_id, member_id): + ''' + Delete the share graph event from the table for the member + + @param event_id: id of the share graph event + @param member_id: id of the member + + ''' + pass + + +def get_share_graph_event_by_member_id(member_id): + ''' + Return all the share graph events for a user + + @param member_id: id of the user + ''' + pass + +def get_share_graph_event_by_id(event_id): + ''' + Return share graph event notification + + @param event_id: id of the event + ''' + pass + + +def get_all_share_graph_event(): + ''' + Return all the share graph events. + ''' + pass diff --git a/graphs/util/db_test.py b/graphs/util/db_test.py new file mode 100644 index 00000000..e69de29b From c79c1bd7ef35f3349c121b770faa993665cf6d34 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Fri, 22 Jul 2016 19:55:25 +0200 Subject: [PATCH 03/23] Add boilerplate test --- graphs/util/db_test.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/graphs/util/db_test.py b/graphs/util/db_test.py index e69de29b..14362255 100644 --- a/graphs/util/db_test.py +++ b/graphs/util/db_test.py @@ -0,0 +1,12 @@ +from graphs.models import share_graph_event +from django.test import TestCase +from db import (get_all_share_graph_event, get_share_graph_event_by_id, + get_share_graph_event_by_member_id, delete_share_graph_event, + update_share_graph_event, add_share_graph_event) + + +class share_graph_event_test(TestCase): + def basic_test(self): + share_event = add_share_graph_event() + # add tests after adding logic + From 5cd7500525950fe6ed050624cde708ffb50c7514 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Sun, 24 Jul 2016 16:53:52 +0200 Subject: [PATCH 04/23] Add error page if user is not logged in for notifications --- graphs/models.py | 11 +++++++++++ graphs/templates/graphs/notifications.html | 4 +++- graphs/views.py | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/graphs/models.py b/graphs/models.py index 88d7c579..067042f4 100644 --- a/graphs/models.py +++ b/graphs/models.py @@ -312,12 +312,23 @@ class Edge(Base): class share_graph_event(Base): __tablename__ = 'share_graph_event' + # unique id for each share graph event id = Column(Integer, autoincrement=True, primary_key=True) + # id of the graph shared graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + # id of the owner of the graph which is shared owner_id = Column(String, ForeignKey('group.owner_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + # id of the group the graph is shared in group_id = Column(String, ForeignKey('group.group_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + # id of the member of the group. + # Hence there can be multiple share graph events if a owner shares a grap + # with a group. A share graph event will be created for all the memebers + # of the group except the owner of the graph (the one who shared it). member_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + # timestamp at which the share graph event occured share_time = Column(TIMESTAMP, nullable = False) + # Boolean value to track if notifications is read or not. + # if True then the notification is active, i.e not read is_active = Column(Boolean) #Create indices diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index e9125f85..25062619 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -2,6 +2,8 @@ {% block content %} {% load staticfiles %} +{% if uid != None %} +

    Group1

    @@ -35,5 +37,5 @@

    Group 2

    - +{% endif %} {% endblock %} \ No newline at end of file diff --git a/graphs/views.py b/graphs/views.py index 99c19c3d..798b7bfa 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -304,6 +304,9 @@ def notifications(request): context = login(request) # Checks to see if a user is currently logged on uid = request.session['uid'] + if uid is None: + context['Error'] = "You need to be logged in to view notifications" + return render(request, 'graphs/error.html', context) return render(request, 'graphs/notifications.html', context) def upload_graph_through_ui(request): From d507c44f0b24198292d079ee2d71405e15c3d45d Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Thu, 28 Jul 2016 18:08:34 +0200 Subject: [PATCH 05/23] Update ShareGraphEvent model and implement functions in db.py --- graphs/models.py | 18 ++++++++----- graphs/util/db.py | 66 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/graphs/models.py b/graphs/models.py index 067042f4..f87bc1ad 100644 --- a/graphs/models.py +++ b/graphs/models.py @@ -10,10 +10,10 @@ ex. 'id' for user table would be 'user_id' ''' -from sqlalchemy import Column, Integer, String, ForeignKey, Table, Index, ForeignKeyConstraint, Boolean +from sqlalchemy import Column, Integer, String, ForeignKey, Table, Index, ForeignKeyConstraint, Boolean, UniqueConstraint from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, backref -from sqlalchemy.types import TIMESTAMP +from sqlalchemy.types import TIMESTAMP, Boolean from django.db import models from django.conf import settings from sqlalchemy import create_engine @@ -310,14 +310,14 @@ class Edge(Base): ForeignKeyConstraint([user_id, graph_id, tail_node_id], [Node.user_id, Node.graph_id, Node.node_id], ondelete="CASCADE", onupdate="CASCADE"), {}) #no relationship specified -class share_graph_event(Base): +class ShareGraphEvent(Base): __tablename__ = 'share_graph_event' # unique id for each share graph event id = Column(Integer, autoincrement=True, primary_key=True) # id of the graph shared - graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + graph_id = Column(String, nullable=False) # id of the owner of the graph which is shared - owner_id = Column(String, ForeignKey('group.owner_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) + owner_id = Column(String, nullable=False) # id of the group the graph is shared in group_id = Column(String, ForeignKey('group.group_id', ondelete="CASCADE", onupdate="CASCADE"), nullable=False) # id of the member of the group. @@ -329,7 +329,13 @@ class share_graph_event(Base): share_time = Column(TIMESTAMP, nullable = False) # Boolean value to track if notifications is read or not. # if True then the notification is active, i.e not read - is_active = Column(Boolean) + event_active = Column(Boolean, nullable=False) + # We use ForeignKeyConstraint for graph_id and owner_id + # because this is the only to define a composite foreign key + __table_args__ = ( + UniqueConstraint('graph_id', 'owner_id', 'group_id', 'member_id'), + ForeignKeyConstraint([graph_id, owner_id], [Graph.graph_id, Graph.user_id], ondelete="CASCADE", onupdate="CASCADE"), + ) #Create indices Index('graph_public_idx', Graph.public) diff --git a/graphs/util/db.py b/graphs/util/db.py index 4a8b2635..787e0c00 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -4755,17 +4755,37 @@ def add_share_graph_event(graph_id, owner_id, group_id, member_id): @param group_id: id of the grop @param member_id: id of the member the graph is shared ''' - pass + # Create database connection + db_session = data_connection.new_session() + + # Get the current time + cur_time = datetime.now() + + new_event = models.ShareGraphEvent(graph_id=graph_id, owner_id=owner_id, group_id=group_id, member_id=member_id, share_time=cur_time, event_active=True) + db_session.add(new_event) + db_session.commit() + db_session.close() + -def update_share_graph_event(event_id, active): +def update_share_graph_event(event_id, active, member_id): ''' Update the share graph event. Change its active state. If active is True then the notification is not read/clicked. @param event_id: id of the share graph event @param active: Boolean value, update the state of event + @param member_id: id of the user, the logged in user. ''' - pass + db_session = data_connection.new_session() + + try: + event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() + event.event_active = active + db_session.commit() + db_session.close() + except NoResultFound: + db_session.close() + return 'Event not found' def delete_share_graph_event(event_id, member_id): @@ -4776,7 +4796,15 @@ def delete_share_graph_event(event_id, member_id): @param member_id: id of the member ''' - pass + db_session = data_connection.new_session() + try: + event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() + db_session.delete(event) + db_session.commit() + db_session.close() + except NoResultFound: + db_session.close() + return 'Event not found' def get_share_graph_event_by_member_id(member_id): @@ -4785,19 +4813,41 @@ def get_share_graph_event_by_member_id(member_id): @param member_id: id of the user ''' - pass + # Create database connection + db_session = data_connection.new_session() + + try: + events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).all() + db_session.close() + return events + except NoResultFound: + db_session.close() + return None -def get_share_graph_event_by_id(event_id): + +def get_share_graph_event_by_id(event_id, member_id): ''' Return share graph event notification @param event_id: id of the event + @param member_id: id of the logged in user ''' - pass + db_session = data_connection.new_session() + try: + event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() + db_session.close() + return event + except NoResultFound: + db_session.close() + return 'Event not found' def get_all_share_graph_event(): ''' Return all the share graph events. ''' - pass + db_session = data_connection.new_session() + events = db_session.query(models.ShareGraphEvent).all() + db_session.close() + return events + From 1dd3d6b2a005ecdfa61e0259deccef5e280afbe0 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Thu, 28 Jul 2016 18:11:00 +0200 Subject: [PATCH 06/23] update login error text --- graphs/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphs/views.py b/graphs/views.py index 798b7bfa..305228a2 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -305,7 +305,7 @@ def notifications(request): # Checks to see if a user is currently logged on uid = request.session['uid'] if uid is None: - context['Error'] = "You need to be logged in to view notifications" + context['Error'] = "Please log in to view notifications." return render(request, 'graphs/error.html', context) return render(request, 'graphs/notifications.html', context) From 47feb76cb945e7705eae1541c539db76c2223e3e Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Thu, 28 Jul 2016 18:45:25 +0200 Subject: [PATCH 07/23] create new event after sharing graph with group --- graphs/util/db.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/graphs/util/db.py b/graphs/util/db.py index 787e0c00..a134636b 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -3648,7 +3648,9 @@ def share_graph_with_group(owner, graph, groupId, groupOwner): # If they're an owner or a group member, they can add graph to the group if group_owner != None or group_member != None: new_shared_graph = models.GroupToGraph(group_id = groupId, group_owner = groupOwner, user_id = owner, graph_id = graph, modified = graph_exists.modified) - + members = get_group_members(groupOwner, groupId) + for member in members: + add_share_graph_event(graph, owner, groupId, member.user_id) db_session.add(new_shared_graph) db_session.commit() else: From 0e9465fc802a493aedcaadfe4d791e6badb67bcb Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Mon, 1 Aug 2016 01:04:07 +0530 Subject: [PATCH 08/23] add support for reading notifications --- graphs/models.py | 2 +- graphs/static/graphs/js/notifications.js | 21 ++++++++++++ graphs/templates/graphs/notifications.html | 39 +++++++++------------- graphs/urls.py | 1 + graphs/util/db.py | 31 ++++++++++++----- graphs/views.py | 23 +++++++++++++ 6 files changed, 85 insertions(+), 32 deletions(-) create mode 100644 graphs/static/graphs/js/notifications.js diff --git a/graphs/models.py b/graphs/models.py index f87bc1ad..263387c2 100644 --- a/graphs/models.py +++ b/graphs/models.py @@ -329,7 +329,7 @@ class ShareGraphEvent(Base): share_time = Column(TIMESTAMP, nullable = False) # Boolean value to track if notifications is read or not. # if True then the notification is active, i.e not read - event_active = Column(Boolean, nullable=False) + is_active = Column(Boolean, nullable=False) # We use ForeignKeyConstraint for graph_id and owner_id # because this is the only to define a composite foreign key __table_args__ = ( diff --git a/graphs/static/graphs/js/notifications.js b/graphs/static/graphs/js/notifications.js new file mode 100644 index 00000000..1dba1ce1 --- /dev/null +++ b/graphs/static/graphs/js/notifications.js @@ -0,0 +1,21 @@ +$(document).ready(function() { + + + /** + * Mark notification as read through the UI. + */ + $(".read_notification").click(function (e) { + var uid = $(this).val(); + var nid = $(this).attr('id'); + + $.post('../../../read_notification/', { + 'uid': uid, + 'nid': nid + }, function (data) { + if (data.Error) { + return alert(data.Error); + } + window.location.reload(); + }); + }); +}); \ No newline at end of file diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 25062619..311b1fa1 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -1,41 +1,34 @@ {% extends 'base.html' %} {% block content %} {% load staticfiles %} + {% if uid != None %} +
    +{% for group, events in notifications.items %} +{% if events.length != None %}
    -

    Group1

    +

    {{ group }}

    + {% for event in events %} + {% if event.owner_id != uid %} + {% if event.is_active != 0 %} - - - - + + + {% endif %} + {% endif %} + {% endfor %}
    seth.mridul@gmail.com shared graph some_name_of_graph with the group Group 1.
    seth.mridul@gmail.com shared graph some_other_graph with the group Group 1. {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}.
    - - + {% endif %} {% endblock %} \ No newline at end of file diff --git a/graphs/urls.py b/graphs/urls.py index 7d97258a..eaa63a75 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -93,6 +93,7 @@ url(r'^api/users/(?P.+)/graph/makeGraphPrivate/(?P.+)/$', views.make_graph_private, name='make_graph_private'), url(r'^api/users/(?P.+)/graphs/$', views.view_all_graphs_for_user, name='view_all_graphs_for_user'), + # Group REST API endpoints url(r'^api/groups/get/(?P.+)/(?P.+)/$', views.get_group, name='get_group'), url(r'^api/groups/get/$', views.get_groups, name='get_groups'), diff --git a/graphs/util/db.py b/graphs/util/db.py index a134636b..9883931c 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -4763,7 +4763,7 @@ def add_share_graph_event(graph_id, owner_id, group_id, member_id): # Get the current time cur_time = datetime.now() - new_event = models.ShareGraphEvent(graph_id=graph_id, owner_id=owner_id, group_id=group_id, member_id=member_id, share_time=cur_time, event_active=True) + new_event = models.ShareGraphEvent(graph_id=graph_id, owner_id=owner_id, group_id=group_id, member_id=member_id, share_time=cur_time, is_active=True) db_session.add(new_event) db_session.commit() db_session.close() @@ -4782,12 +4782,13 @@ def update_share_graph_event(event_id, active, member_id): try: event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() - event.event_active = active + event.is_active = active db_session.commit() db_session.close() except NoResultFound: db_session.close() - return 'Event not found' + raise EventNotFound + # return 'Event not found' def delete_share_graph_event(event_id, member_id): @@ -4806,12 +4807,15 @@ def delete_share_graph_event(event_id, member_id): db_session.close() except NoResultFound: db_session.close() - return 'Event not found' + raise EventNotFound + # return 'Event not found' def get_share_graph_event_by_member_id(member_id): ''' - Return all the share graph events for a user + Return a dictionary of share graph events for a user keyed by group id. + If all the share graph events for a particular group are inactive, i.e. read + then the group is keyed by None in the dictionary. @param member_id: id of the user ''' @@ -4820,11 +4824,21 @@ def get_share_graph_event_by_member_id(member_id): try: events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).all() + events_group = {} + for event in events: + if event.group_id in events_group: + events_group[event.group_id].append(event) + else: + events_group[event.group_id] = [event] + for group, events in events_group.items(): + if all(event.is_active == 0 for event in events): + events_group[group] = None db_session.close() - return events + return events_group except NoResultFound: db_session.close() - return None + # return None + raise EventNotFound def get_share_graph_event_by_id(event_id, member_id): @@ -4841,7 +4855,8 @@ def get_share_graph_event_by_id(event_id, member_id): return event except NoResultFound: db_session.close() - return 'Event not found' + raise EventNotFound + # return 'Event not found' def get_all_share_graph_event(): diff --git a/graphs/views.py b/graphs/views.py index 305228a2..3939d705 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -304,11 +304,33 @@ def notifications(request): context = login(request) # Checks to see if a user is currently logged on uid = request.session['uid'] + context['notifications'] = db.get_share_graph_event_by_member_id(context['uid']) + context['group_list'] = db.get_all_groups_with_member(context['uid']) + db.get_groups_of_user(context['uid']) if uid is None: context['Error'] = "Please log in to view notifications." return render(request, 'graphs/error.html', context) return render(request, 'graphs/notifications.html', context) + +def read_notification(request): + if request.method == 'POST': + nid = request.POST['nid'] + uid = request.session.get('uid') + + # Check if the user is authenticated + if uid == None: + return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") + + # if the user owns the graph only then allow him to delete it + graph_info = db.get_share_graph_event_by_id(nid, uid) + if graph_info == None: + return HttpResponse(json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), content_type="application/json") + else: + db.update_share_graph_event(nid, 0, uid) + return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share event " + nid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + + + def upload_graph_through_ui(request): if request.method == 'POST': @@ -1021,6 +1043,7 @@ def _groups_page(request, view_type): context['message'] = "It appears as if you are not a member of any group. Please join a group in order for them to appear here." else: context['message'] = "It appears as if there are currently no groups on GraphSpace." + return render(request, 'graphs/groups.html', context) #No public groups anymore From 69828375773aa5c0b765024c0de43d9c6adb7a00 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Mon, 1 Aug 2016 22:44:40 +0530 Subject: [PATCH 09/23] add boilerplate exceptions file --- graphs/exception.py | 4 ++++ graphs/util/db.py | 1 + 2 files changed, 5 insertions(+) create mode 100644 graphs/exception.py diff --git a/graphs/exception.py b/graphs/exception.py new file mode 100644 index 00000000..1e83f079 --- /dev/null +++ b/graphs/exception.py @@ -0,0 +1,4 @@ +from django.core.exceptions import ObjectDoesNotExist + +class EventNotFound(ObjectDoesNotExist): + pass \ No newline at end of file diff --git a/graphs/util/db.py b/graphs/util/db.py index 9883931c..4af25f41 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -21,6 +21,7 @@ from django.conf import settings from django.core.mail import send_mail from json_validator import validate_json, assign_edge_ids, convert_json, verify_json +from graphs.exception import EventNotFound data_connection = db_init.db From ec85ee2784bbd0711f4561ec42bd9954cc0e81fa Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Mon, 1 Aug 2016 23:55:24 +0530 Subject: [PATCH 10/23] Add functionality to read all events of a group --- graphs/static/graphs/js/notifications.js | 20 ++++++++++++ graphs/templates/graphs/notifications.html | 5 +-- graphs/urls.py | 2 ++ graphs/util/db.py | 37 ++++++++++++++++++++++ graphs/views.py | 21 ++++++++++-- 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/graphs/static/graphs/js/notifications.js b/graphs/static/graphs/js/notifications.js index 1dba1ce1..f36d6c44 100644 --- a/graphs/static/graphs/js/notifications.js +++ b/graphs/static/graphs/js/notifications.js @@ -18,4 +18,24 @@ $(document).ready(function() { window.location.reload(); }); }); + + /** + * Mark all notifications as read in a group + */ + $(".read_all_notifications").click(function (e) { + var uid = $(this).val(); + var nid = $(this).attr('id'); + + $.post('../../../read_all_notifications/', { + 'uid': uid, + 'nid': nid + }, function (data) { + if (data.Error) { + return alert(data.Error); + } + window.location.reload(); + }); + }); + + }); \ No newline at end of file diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 311b1fa1..355d82e4 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -7,7 +7,7 @@
    {% for group, events in notifications.items %} -{% if events.length != None %} +{% if events != None %}

    {{ group }}

    @@ -15,7 +15,7 @@

    {{ group }}

    {% for event in events %} {% if event.owner_id != uid %} - {% if event.is_active != 0 %} + {% if event.is_active == 1 %} @@ -25,6 +25,7 @@

    {{ group }}

    {% endfor %}
    {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}.
    +
    {% endif %} {% endfor %} diff --git a/graphs/urls.py b/graphs/urls.py index eaa63a75..13434141 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -80,6 +80,8 @@ url(r'^resetPassword/$', views.resetPassword, name='resetPassword'), url(r'^launchTask/$', views.launchTask, name='launchTask'), url(r'^retrieveTaskCode/$', views.retrieveTaskCode, name='retrieveTaskCode'), + url(r'^read_notification/$', views.read_notification, name='read_notification'), + url(r'^read_all_notifications/$', views.read_all_notifications, name='read_all_notifications'), #REST API diff --git a/graphs/util/db.py b/graphs/util/db.py index 4af25f41..760f061b 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -4869,3 +4869,40 @@ def get_all_share_graph_event(): db_session.close() return events + +def set_share_graph_events_inactive(event_ids, member_id): + ''' + Set all events in the list event_ids as inactive + + @param events_id: list of event ids + @param member_id: id of the logged in user + ''' + db_session = data_connection.new_session() + try: + for event in events_id: + db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.id == event_id).update({"is_active": 0}) + db_session.commit() + db_session.close() + except NoResultFound: + db_session.close() + raise EventNotFound + +def set_share_graph_events_inactive_by_group(group_id, member_id): + ''' + Set all events in a group for a user as inactive + + @param group_id: id of the group + @param member_id: id of the logged in user + ''' + db_session = data_connection.new_session() + try: + + events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.group_id == group_id).all() + for event in events: + event.is_active = 0 + db_session.commit() + db_session.close() + except NoResultFound: + db_session.close() + raise EventNotFound + diff --git a/graphs/views.py b/graphs/views.py index 3939d705..1922afd7 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -321,15 +321,30 @@ def read_notification(request): if uid == None: return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") - # if the user owns the graph only then allow him to delete it - graph_info = db.get_share_graph_event_by_id(nid, uid) - if graph_info == None: + event_info = db.get_share_graph_event_by_id(nid, uid) + if event_info == None: return HttpResponse(json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), content_type="application/json") else: db.update_share_graph_event(nid, 0, uid) return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share event " + nid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") +def read_all_notifications(request): + if request.method == 'POST': + nid = request.POST['nid'] + uid = request.session.get('uid') + + # Check if the user is authenticated + if uid == None: + return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") + + event_info = db.set_share_graph_events_inactive_by_group(nid, uid) + if event_info != None: + return HttpResponse(json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share events " + nid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + + def upload_graph_through_ui(request): From f630f8a4e9489c4da5f742d8adcbba9a53eea5e6 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Fri, 5 Aug 2016 19:35:25 +0530 Subject: [PATCH 11/23] update notifications template --- graphs/templates/graphs/notifications.html | 34 +++++++++++++++++++++- graphs/util/db.py | 15 +++------- graphs/views.py | 21 +++++++++++-- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 355d82e4..52fee4b4 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -5,7 +5,36 @@ {% if uid != None %} -
    + + +
    +
    + +
    +
    +
    +
    + +
    Groups
    +
    +

    All the notifications of the Group you are a part of.

    +
    + + + + {% for group in groups_for_user %} + + + + {% endfor %} +
    {{ group.groupId }}
    +
    +
    +
    + + + {% for group, events in notifications.items %} {% if events != None %}
    @@ -31,5 +60,8 @@

    {{ group }}

    {% endfor %}
    +
    + + {% endif %} {% endblock %} \ No newline at end of file diff --git a/graphs/util/db.py b/graphs/util/db.py index 760f061b..b4431f43 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -3651,6 +3651,8 @@ def share_graph_with_group(owner, graph, groupId, groupOwner): new_shared_graph = models.GroupToGraph(group_id = groupId, group_owner = groupOwner, user_id = owner, graph_id = graph, modified = graph_exists.modified) members = get_group_members(groupOwner, groupId) for member in members: + # TODO: use the current db session instead of creating a new db + # session add_share_graph_event(graph, owner, groupId, member.user_id) db_session.add(new_shared_graph) db_session.commit() @@ -4791,7 +4793,7 @@ def update_share_graph_event(event_id, active, member_id): raise EventNotFound # return 'Event not found' - +# admin function def delete_share_graph_event(event_id, member_id): ''' Delete the share graph event from the table for the member @@ -4825,17 +4827,8 @@ def get_share_graph_event_by_member_id(member_id): try: events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).all() - events_group = {} - for event in events: - if event.group_id in events_group: - events_group[event.group_id].append(event) - else: - events_group[event.group_id] = [event] - for group, events in events_group.items(): - if all(event.is_active == 0 for event in events): - events_group[group] = None db_session.close() - return events_group + return events except NoResultFound: db_session.close() # return None diff --git a/graphs/views.py b/graphs/views.py index 1922afd7..b6ca21e5 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -297,6 +297,20 @@ def _graphs_page(request, view_type): return render(request, 'graphs/graphs.html', context) + +def _group_by_id_notifications(events): + events_group = {} + for event in events: + if event.group_id in events_group: + events_group[event.group_id].append(event) + else: + events_group[event.group_id] = [event] + for group, events in events_group.items(): + if all(event.is_active == 0 for event in events): + events_group[group] = None + return events_group + + def notifications(request): # context of the view to be passed in for rendering context = {} @@ -304,11 +318,14 @@ def notifications(request): context = login(request) # Checks to see if a user is currently logged on uid = request.session['uid'] - context['notifications'] = db.get_share_graph_event_by_member_id(context['uid']) - context['group_list'] = db.get_all_groups_with_member(context['uid']) + db.get_groups_of_user(context['uid']) + events = db.get_share_graph_event_by_member_id(context['uid']) + context['groups_for_user'] = db.groups_for_user(context['uid']) + context['notifications'] = _group_by_id_notifications(events) + # context['group_list'] = db.get_all_groups_with_member(context['uid']) + db.get_groups_of_user(context['uid']) if uid is None: context['Error'] = "Please log in to view notifications." return render(request, 'graphs/error.html', context) + print context return render(request, 'graphs/notifications.html', context) From 4c24e6c272c9a0b862eac4e884398af0fc3875c0 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Sat, 6 Aug 2016 04:16:55 +0530 Subject: [PATCH 12/23] add functionality to view all notifications of a group --- graphs/exception.py | 4 -- graphs/static/graphs/js/notifications.js | 15 +++++ graphs/templates/graphs/notifications.html | 44 +++++++++++++-- graphs/urls.py | 3 + graphs/util/db.py | 64 +++++++++++++++++----- graphs/views.py | 34 +++++++++++- 6 files changed, 140 insertions(+), 24 deletions(-) delete mode 100644 graphs/exception.py diff --git a/graphs/exception.py b/graphs/exception.py deleted file mode 100644 index 1e83f079..00000000 --- a/graphs/exception.py +++ /dev/null @@ -1,4 +0,0 @@ -from django.core.exceptions import ObjectDoesNotExist - -class EventNotFound(ObjectDoesNotExist): - pass \ No newline at end of file diff --git a/graphs/static/graphs/js/notifications.js b/graphs/static/graphs/js/notifications.js index f36d6c44..ad85a55e 100644 --- a/graphs/static/graphs/js/notifications.js +++ b/graphs/static/graphs/js/notifications.js @@ -37,5 +37,20 @@ $(document).ready(function() { }); }); + /** + * Mark all notifications as read in a group + */ + $(".read_all_user_notifications").click(function (e) { + var uid = $(this).val(); + + $.post('../../../read_all_user_notifications/', { + 'uid': uid + }, function (data) { + if (data.Error) { + return alert(data.Error); + } + window.location.reload(); + }); + }); }); \ No newline at end of file diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 52fee4b4..9bb76d00 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -9,7 +9,7 @@
    + right: 100px; float: right;" class="btn btn-default read_all_user_notifications" value="{{ uid }}">Mark all notifications as read

    @@ -18,22 +18,25 @@
    Groups
    -

    All the notifications of the Group you are a part of.

    +

    Notifications

    {% for group in groups_for_user %} - + {% endfor %}
    {{ group.groupId }}{{ group.groupId }}
    -
    +{% if notifications != None %} +{% if check_new_notifications == True %} +
    + {% for group, events in notifications.items %} {% if events != None %} @@ -62,6 +65,39 @@

    {{ group }}

    +{% else %} +
    +
    +
    +

    No new notifications.

    +
    +
    +
    +{% endif %} +{% else %} +
    + + +
    +
    +

    {{ group }}

    +
    + + {% for event in all_events_in_group %} + {% if event.owner_id != uid %} + + + + {% endif %} + {% endfor %} + +
    {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}.
    +
    + +
    + +
    +{% endif %} {% endif %} {% endblock %} \ No newline at end of file diff --git a/graphs/urls.py b/graphs/urls.py index 13434141..28c405bd 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -24,6 +24,7 @@ # notifications page url(r'^notifications/$', views.notifications, name='notifications'), + url(r'^notifications/(?P.+)/$', views.notifications_group, name='notifications_group'), # view graph page. This contains regular expression to catch url in the form of the following: # /graphs/email_address/graph_id/ @@ -82,6 +83,8 @@ url(r'^retrieveTaskCode/$', views.retrieveTaskCode, name='retrieveTaskCode'), url(r'^read_notification/$', views.read_notification, name='read_notification'), url(r'^read_all_notifications/$', views.read_all_notifications, name='read_all_notifications'), + url(r'^read_all_user_notifications/$', views.read_all_user_notifications, name='read_all_user_notifications'), + #REST API diff --git a/graphs/util/db.py b/graphs/util/db.py index b4431f43..d66861b4 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -21,7 +21,6 @@ from django.conf import settings from django.core.mail import send_mail from json_validator import validate_json, assign_edge_ids, convert_json, verify_json -from graphs.exception import EventNotFound data_connection = db_init.db @@ -4790,8 +4789,7 @@ def update_share_graph_event(event_id, active, member_id): db_session.close() except NoResultFound: db_session.close() - raise EventNotFound - # return 'Event not found' + raise NoResultFound # admin function def delete_share_graph_event(event_id, member_id): @@ -4810,8 +4808,7 @@ def delete_share_graph_event(event_id, member_id): db_session.close() except NoResultFound: db_session.close() - raise EventNotFound - # return 'Event not found' + raise NoResultFound def get_share_graph_event_by_member_id(member_id): @@ -4831,8 +4828,7 @@ def get_share_graph_event_by_member_id(member_id): return events except NoResultFound: db_session.close() - # return None - raise EventNotFound + raise NoResultFound def get_share_graph_event_by_id(event_id, member_id): @@ -4849,8 +4845,7 @@ def get_share_graph_event_by_id(event_id, member_id): return event except NoResultFound: db_session.close() - raise EventNotFound - # return 'Event not found' + raise NoResultFound def get_all_share_graph_event(): @@ -4858,9 +4853,26 @@ def get_all_share_graph_event(): Return all the share graph events. ''' db_session = data_connection.new_session() - events = db_session.query(models.ShareGraphEvent).all() - db_session.close() - return events + try: + events = db_session.query(models.ShareGraphEvent).all() + db_session.close() + return events + except NoResultFound: + db_session.close() + raise NoResultFound + + +def set_all_graph_events_inactive_user(member_id): + ''' + ''' + db_session = data_connection.new_session() + try: + db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).update({"is_active": 0}) + db_session.commit() + db_session.close() + except NoResultFound: + db_session.close() + raise NoResultFound def set_share_graph_events_inactive(event_ids, member_id): @@ -4878,7 +4890,7 @@ def set_share_graph_events_inactive(event_ids, member_id): db_session.close() except NoResultFound: db_session.close() - raise EventNotFound + raise NoResultFound def set_share_graph_events_inactive_by_group(group_id, member_id): ''' @@ -4897,5 +4909,29 @@ def set_share_graph_events_inactive_by_group(group_id, member_id): db_session.close() except NoResultFound: db_session.close() - raise EventNotFound + raise NoResultFound + + +def get_share_graph_event_by_group_id(member_id, group_id): + db_session = data_connection.new_session() + try: + events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.group_id == group_id).all() + return events + except NoResultFound: + db_session.close() + raise NoResultFound + + +def check_new_notifications(member_id): + db_session = data_connection.new_session() + try: + events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.is_active == 1).all() + if len(events) != 0: + return True + else: + return False + except NoResultFound: + db_session.close() + raise NoResultFound + diff --git a/graphs/views.py b/graphs/views.py index b6ca21e5..03f521ca 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -321,11 +321,28 @@ def notifications(request): events = db.get_share_graph_event_by_member_id(context['uid']) context['groups_for_user'] = db.groups_for_user(context['uid']) context['notifications'] = _group_by_id_notifications(events) - # context['group_list'] = db.get_all_groups_with_member(context['uid']) + db.get_groups_of_user(context['uid']) + context['check_new_notifications'] = db.check_new_notifications(context['uid']) + print context + if uid is None: + context['Error'] = "Please log in to view notifications." + return render(request, 'graphs/error.html', context) + return render(request, 'graphs/notifications.html', context) + + +def notifications_group(request, groupname): + # context of the view to be passed in for rendering + context = {} + # handle login + context = login(request) + # Checks to see if a user is currently logged on + uid = request.session['uid'] + context['groups_for_user'] = db.groups_for_user(context['uid']) + context['all_events_in_group'] = db.get_share_graph_event_by_group_id(uid, groupname) + context['notifications'] = None + context['group'] = groupname if uid is None: context['Error'] = "Please log in to view notifications." return render(request, 'graphs/error.html', context) - print context return render(request, 'graphs/notifications.html', context) @@ -346,6 +363,19 @@ def read_notification(request): return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share event " + nid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") +def read_all_user_notifications(request): + if request.method == 'POST': + uid = request.session.get('uid') + # Check if the user is authenticated + if uid == None: + return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") + event_info = db.set_all_graph_events_inactive_user(uid) + if event_info != None: + return HttpResponse(json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share events " + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + + def read_all_notifications(request): if request.method == 'POST': nid = request.POST['nid'] From db07aa581a9b2e887f1a46b800607823ddd3c60a Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Thu, 11 Aug 2016 19:13:07 +0530 Subject: [PATCH 13/23] changes --- GraphSpace.egg-info/PKG-INFO | 96 + GraphSpace.egg-info/SOURCES.txt | 28 + GraphSpace.egg-info/dependency_links.txt | 1 + GraphSpace.egg-info/not-zip-safe | 1 + GraphSpace.egg-info/requires.txt | 19 + GraphSpace.egg-info/top_level.txt | 2 + build/lib/graphs/__init__.py | 0 build/lib/graphs/auth/AuthBackend.py | 22 + build/lib/graphs/auth/__init__.py | 0 build/lib/graphs/auth/login.py | 61 + build/lib/graphs/forms.py | 84 + build/lib/graphs/models.py | 363 ++ build/lib/graphs/urls.py | 209 + build/lib/graphs/util/__init__.py | 0 build/lib/graphs/util/db.py | 4733 ++++++++++++++++++++ build/lib/graphs/util/db_conn.py | 53 + build/lib/graphs/util/db_init.py | 19 + build/lib/graphs/util/json_validator.py | 390 ++ build/lib/graphs/util/paginator.py | 72 + build/lib/graphs/views.py | 2462 ++++++++++ build/lib/graphspace/__init__.py | 0 build/lib/graphspace/settings/__init__.py | 0 build/lib/graphspace/settings/base.py | 105 + build/lib/graphspace/settings/local.py | 32 + build/lib/graphspace/settings/testing.py | 32 + build/lib/graphspace/urls.py | 16 + build/lib/graphspace/wsgi.py | 14 + dist/GraphSpace-1.1.0-py2.7.egg | Bin 0 -> 150522 bytes get-pip.py | 2565 +++++++++++ graphs/static/graphs/js/notifications.js | 4 +- graphs/templates/base.html | 2 +- graphs/templates/graphs/notifications.html | 4 +- graphs/urls.py | 2 +- graphs/views.py | 2 +- 34 files changed, 11387 insertions(+), 6 deletions(-) create mode 100644 GraphSpace.egg-info/PKG-INFO create mode 100644 GraphSpace.egg-info/SOURCES.txt create mode 100644 GraphSpace.egg-info/dependency_links.txt create mode 100644 GraphSpace.egg-info/not-zip-safe create mode 100644 GraphSpace.egg-info/requires.txt create mode 100644 GraphSpace.egg-info/top_level.txt create mode 100644 build/lib/graphs/__init__.py create mode 100644 build/lib/graphs/auth/AuthBackend.py create mode 100644 build/lib/graphs/auth/__init__.py create mode 100644 build/lib/graphs/auth/login.py create mode 100644 build/lib/graphs/forms.py create mode 100644 build/lib/graphs/models.py create mode 100644 build/lib/graphs/urls.py create mode 100644 build/lib/graphs/util/__init__.py create mode 100644 build/lib/graphs/util/db.py create mode 100644 build/lib/graphs/util/db_conn.py create mode 100644 build/lib/graphs/util/db_init.py create mode 100644 build/lib/graphs/util/json_validator.py create mode 100644 build/lib/graphs/util/paginator.py create mode 100644 build/lib/graphs/views.py create mode 100644 build/lib/graphspace/__init__.py create mode 100644 build/lib/graphspace/settings/__init__.py create mode 100644 build/lib/graphspace/settings/base.py create mode 100644 build/lib/graphspace/settings/local.py create mode 100644 build/lib/graphspace/settings/testing.py create mode 100644 build/lib/graphspace/urls.py create mode 100644 build/lib/graphspace/wsgi.py create mode 100644 dist/GraphSpace-1.1.0-py2.7.egg create mode 100644 get-pip.py diff --git a/GraphSpace.egg-info/PKG-INFO b/GraphSpace.egg-info/PKG-INFO new file mode 100644 index 00000000..1b070b33 --- /dev/null +++ b/GraphSpace.egg-info/PKG-INFO @@ -0,0 +1,96 @@ +Metadata-Version: 1.1 +Name: GraphSpace +Version: 1.1.0 +Summary: The interactive graph sharing website. +Home-page: http://graphspace.org +Author: adb +Author-email: adb@vt.edu +License: GNU GENERAL PUBLIC LICENSE +Description: GraphSpace + ================ + + GraphSpace is running at http://graphspace.org + + GraphSpace has three dummy users: + + 1. Username: user1@example.com Password: user1 + 2. Username: user2@example.com Password: user2 + 3. Username: user3@example.com Password: user3 + + Requirements + =================================== + 1. Python v2.7.10 + 2. sqlite3 v3.8.10 + 3. virtualenv + + Running GraphSpace locally + =================================== + + In order to run GraphSpace, please install sqlite3 and both the Python runtime and development environments. We have tested GraphSpace with Python v2.7.10 and sqlite3 v3.8.10. GraphSpace does not support Python v3. GraphSpace performs best on either Mozilla Firefox or Google Chrome browsers. The following steps describe how to install Python packages required by GraphSpace, download the GraphSpace code, and set up and start the server. The following instructions should apply to computers running a version of the Linux or OS X operating systems. + + 1. Download the GraphSpace code by running `git clone https://github.com/Murali-group/GraphSpace.git` or by downloading the latest release: https://github.com/Murali-group/GraphSpace/releases. + 2. Visit the GraphSpace directory: `cd GraphSpace` + 3. Create a virtual environment for the project: `virtualenv venv` + 4. Start using the virtual environment: `source venv/bin/activate` + 5. Install graphspace: `sh install.sh` + 6. Finally, start the GraphSpace server: `python manage.py runserver --settings=graphspace.settings.local` + 7. Visit `http://localhost:8080` and enjoy using GraphSpace! + + Running GraphSpace on Apache + =================================== + + This section describes the steps required to launch GraphSpace on a server that has `apache2` running on it. First, please follow the steps in **Running GraphSpace locally**. Next, execute the instructions below. + + 1. Follow instructions 1-5 in `Running GraphSpace locally` + 2. Set `URL_PATH` to the URL where your server will be running. *Note: Please add the ending '/' character at the end of this value: For example: http://graphspace.org/* + 3. Modify the `PATH` to point to where GraphSpace directory exists. *Note: Please add the ending '/' character at the end of this value: For example: /home/ubuntu/GraphSpace/* + 4. Visit the `apache2` directory: `cd /path_to/apache2`. An example of the full path to this directory is `/etc/apache2`. + 5. Navigate to the `sites-enabled` directory: `cd sites-enabled` + 6. Create a file called `graphspace.conf` and access this file using admin privileges: `sudo vim graphspace.conf' + 7. Inside this file, copy and paste following lines, after replacing `path_to_GraphSpace` with the name of the directory where you downloaded GraphSpace: + + ``` + WSGIDaemonProcess GraphSpace python-path=/path_to_GraphSpace:/path_to_GraphSpace/venv/lib/python2.7/site-packages/ python-eggs=/path_to_python_eggs + WSGIProcessGroup GraphSpace + WSGIScriptAlias / /path_to_GraphSpace/graphspace/wsgi.py + + + + Order deny,allow + Require all granted + + + + Alias /static/ /path_to_GraphSpace/graphs/static/ + + + Require all granted + + + + Options Indexes FollowSymLinks + AllowOverride None + Require all granted + + ``` + + 8. Install module to recognize Django application through apache2: `sudo apt-get install libapache2-mod-wsgi` + 9. Give permission to access static files through apache2. Navigate outside GraphSpace and type: `chmod 777 GraphSpace` + 10. Create a directory for python-eggs. `mkdir /path_to_python_eggs` + 11. Give permission to access static files through apache2. `chmod 777 /path_to_python_eggs` + 12. Restart the apache server. On a computer running Ubuntu, the command is `sudo service apache2 restart` + + Refer to https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/modwsgi/ if any problems occur with the setup. + + Testing the GraphSpace REST API + ================================= + + 1. Follow the instructions above to run GraphSpace locally or on Apache2. + 2. Navigate to the tests directory in GraphSpace: `cd tests` + 2. Enter python command to run test cases: `python restapi_test.py` + + This script will test all the REST API commands supported by the GraphSpace server. If something fails, it will display the error. + +Platform: UNKNOWN +Classifier: Environment :: Web Environment +Classifier: Framework :: Django diff --git a/GraphSpace.egg-info/SOURCES.txt b/GraphSpace.egg-info/SOURCES.txt new file mode 100644 index 00000000..19d5850d --- /dev/null +++ b/GraphSpace.egg-info/SOURCES.txt @@ -0,0 +1,28 @@ +setup.py +GraphSpace.egg-info/PKG-INFO +GraphSpace.egg-info/SOURCES.txt +GraphSpace.egg-info/dependency_links.txt +GraphSpace.egg-info/not-zip-safe +GraphSpace.egg-info/requires.txt +GraphSpace.egg-info/top_level.txt +graphs/__init__.py +graphs/forms.py +graphs/models.py +graphs/urls.py +graphs/views.py +graphs/auth/AuthBackend.py +graphs/auth/__init__.py +graphs/auth/login.py +graphs/util/__init__.py +graphs/util/db.py +graphs/util/db_conn.py +graphs/util/db_init.py +graphs/util/json_validator.py +graphs/util/paginator.py +graphspace/__init__.py +graphspace/urls.py +graphspace/wsgi.py +graphspace/settings/__init__.py +graphspace/settings/base.py +graphspace/settings/local.py +graphspace/settings/testing.py \ No newline at end of file diff --git a/GraphSpace.egg-info/dependency_links.txt b/GraphSpace.egg-info/dependency_links.txt new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GraphSpace.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/GraphSpace.egg-info/not-zip-safe b/GraphSpace.egg-info/not-zip-safe new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GraphSpace.egg-info/not-zip-safe @@ -0,0 +1 @@ + diff --git a/GraphSpace.egg-info/requires.txt b/GraphSpace.egg-info/requires.txt new file mode 100644 index 00000000..9dcf238a --- /dev/null +++ b/GraphSpace.egg-info/requires.txt @@ -0,0 +1,19 @@ +alabaster==0.7.8 +Babel==2.3.4 +decorator==4.0.10 +Django==1.9.7 +django-analytical==2.2.1 +docutils==0.12 +imagesize==0.7.1 +Jinja2==2.8 +MarkupSafe==0.23 +networkx==1.11 +poster==0.8.1 +py-bcrypt==0.4 +Pygments==2.1.3 +pytz==2016.4 +requests==2.10.0 +six==1.10.0 +snowballstemmer==1.2.1 +Sphinx==1.4.4 +SQLAlchemy==1.0.14 diff --git a/GraphSpace.egg-info/top_level.txt b/GraphSpace.egg-info/top_level.txt new file mode 100644 index 00000000..b2105e84 --- /dev/null +++ b/GraphSpace.egg-info/top_level.txt @@ -0,0 +1,2 @@ +graphs +graphspace diff --git a/build/lib/graphs/__init__.py b/build/lib/graphs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/graphs/auth/AuthBackend.py b/build/lib/graphs/auth/AuthBackend.py new file mode 100644 index 00000000..346ab8ea --- /dev/null +++ b/build/lib/graphs/auth/AuthBackend.py @@ -0,0 +1,22 @@ +import bcrypt +from graphs.util import db + +def authenticate(username=None, password=None): + # check the username/password and return a User + user = db.emailExists(username) + + if user != None: + hashed_pw = user.password + + #check password. if the password matches, return a + #User object with associated information + if bcrypt.hashpw(password, hashed_pw) == hashed_pw: + user_obj = {} + user_obj['user_id'] = user.user_id + user_obj['password'] = user.password + user_obj['admin'] = user.admin + + return user_obj + else: + return None + diff --git a/build/lib/graphs/auth/__init__.py b/build/lib/graphs/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/graphs/auth/login.py b/build/lib/graphs/auth/login.py new file mode 100644 index 00000000..68236a3f --- /dev/null +++ b/build/lib/graphs/auth/login.py @@ -0,0 +1,61 @@ +from graphs.auth import AuthBackend +from graphs.forms import LoginForm, RegisterForm +from graphs.util import db +from django.conf import settings + +def login(request): + ''' + Handles the login request from the webpage. + ''' + #context of the view to be passed in for rendering + context = {} + message = '' + login_form = LoginForm(); + register_form = RegisterForm(); + URL_PATH = settings.URL_PATH + + if request.method == 'POST': + + user = AuthBackend.authenticate(username=request.POST['user_id'], password=request.POST['pw']) + login_form = LoginForm(request.POST) + if user is not None: + message = '%s, Welcome to GraphSpace!' % user['user_id'] + request.session['uid'] = user['user_id'] + request.session['admin'] = user['admin'] + context = {'login_form': login_form, 'user': user, 'uid': user['user_id'], 'admin': user['admin'], "Error": None, "url": URL_PATH} + return context + else: + login_form = LoginForm() + context['login_form'] = login_form + context['register_form'] = register_form + context['Error'] = "User/Password not recognized!" + message = 'Information you have given does not match our records. Please try again.' + context['url'] = URL_PATH + return context + # when a user is already logged in or not logged in at all. + else: + # the user did not enter any information into the login form. + # in this case, there may be already be a logged-in user. + if 'uid' in request.session: + #there is a currently logged-in user. + uid = request.session['uid'] + else: + #there is no one logged in. + uid = None + context['url'] = URL_PATH + context['Error'] = "Not logged in!" + + if uid is not None: + context['uid'] = request.session['uid'] + context['admin'] = request.session['admin'] + context['Error'] = None + message = 'Welcome to GraphSpace, %s!' % request.session['uid'] + context['url'] = URL_PATH + return context + else: + request.session['uid'] = None + context['login_form'] = login_form + context['register_form'] = RegisterForm() + context['url'] = URL_PATH + context["Error"] = None + return context diff --git a/build/lib/graphs/forms.py b/build/lib/graphs/forms.py new file mode 100644 index 00000000..b5555e50 --- /dev/null +++ b/build/lib/graphs/forms.py @@ -0,0 +1,84 @@ +''' +See https://docs.djangoproject.com/en/dev/topics/forms/ for details. +''' + +from django import forms +from graphs.util import db + +class LoginForm(forms.Form): + ''' + Login Form used to show login fields in GraphSpace webpages. + This form is located within the top navbar. + ''' + + # attrs to specify extra html attributes + user_id = forms.CharField(max_length=100, required=False, widget=forms.TextInput(attrs={'placeholder': 'Email', 'class': 'form-control', 'size': '13', 'id': 'email'})) + pw = forms.CharField(required=False, widget=forms.PasswordInput(attrs={'placeholder': 'Password', 'class': 'form-control', 'size': '13', 'id': 'pw'})) + +class SearchForm(forms.Form): + ''' + Search form used to perform search on GraphSpace + ''' + + def __init__(self, *args, **kwargs): + ''' + Initialize the form. A keyword argument 'placeholder' may be + given. + + This can be customized to specify additional parameters if it + needs to. + ''' + if 'placeholder' in kwargs: + self.placeholder = kwargs.pop('placeholder') + # must be called after 'placeholder' is popped from kwargs + super(SearchForm, self).__init__(*args, **kwargs) + self.fields['search'].widget = forms.TextInput(attrs={'placeholder': self.placeholder, 'class': 'form-control', 'type': 'text', 'name': 'search'}) + else: + super(SearchForm, self).__init__(*args, **kwargs) + self.fields['search'].widget = forms.TextInput(attrs={'class': 'form-control', 'type': 'text', 'name': 'search'}) + + search = forms.CharField(required=False, label='', max_length=100) + +class RegisterForm(forms.Form): + ''' + Register form to help create an account for a new user. + ''' + + user_id = forms.CharField(required=False, label='Email', max_length=100, + widget=forms.TextInput(attrs={'class': 'form-control', 'type': 'text', 'size': '25', 'id': 'user_id'})) + password = forms.CharField(required=False, label='Password', widget=forms.PasswordInput(attrs={'class': 'form-control', 'size': '25', 'id': 'password'})) + verify_password = forms.CharField(required=False, label='Verify Password', widget=forms.PasswordInput(attrs={'class': 'form-control', 'size': '25', 'id': 'verify_password'})) + + def clean_user_id(self): + ''' + Form validation to check if the user id already exist + in the database. + + https://docs.djangoproject.com/en/1.6/ref/forms/validation/#cleaning-a-specific-field-attribute + ''' + cleaned_data = super(RegisterForm, self).clean() + user_id = cleaned_data["user_id"] + + check_user = db.emailExists(user_id) + + if check_user == None: + return user_id + else: + return None + + def clean(self): + ''' + Form validation to check if two passwords provided are + equivalent. + + https://docs.djangoproject.com/en/1.6/ref/forms/validation/#cleaning-a-specific-field-attribute + ''' + cleaned_data = super(RegisterForm, self).clean() + pw = cleaned_data.get("password") + vpw = cleaned_data.get("verify_password") + + if pw and vpw: + if pw != vpw: + raise forms.ValidationError("Passwords do not match.") + + return cleaned_data \ No newline at end of file diff --git a/build/lib/graphs/models.py b/build/lib/graphs/models.py new file mode 100644 index 00000000..d3e70bb2 --- /dev/null +++ b/build/lib/graphs/models.py @@ -0,0 +1,363 @@ +''' +This code produces almost the same schema as the original schema used in +the current live version of GraphSpace (as of June 28, 2014). +There are two differences: + 1. The schema produced by this code does not specify data lengths. + ex. VARCHAR instead of VARCHAR(x) + 2. Tables in the original schema contain a column named 'id'. 'id' is + a built-in function in Python so it is not a good practice to use + it as a variable. Thus 'id' columns are renamed as 'tablename_id'. + ex. 'id' for user table would be 'user_id' +''' + +from sqlalchemy import Column, Integer, String, ForeignKey, Table, Index, ForeignKeyConstraint +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, backref +from sqlalchemy.types import TIMESTAMP +from django.db import models +from django.conf import settings +from sqlalchemy import create_engine + +import bcrypt + +# Construct a base class for declarative class definitions. +# The new base class will be given a metaclass that produces appropriate Table objects +# and makes the appropriate mapper() calls based on the information provided +# declarativly in the class and any subclasses of the class. +# (Taken from SQLAlchemy API reference) +Base = declarative_base() + +#======================== Junction Tables ========================== +# Junction tables for specifying many to many relationships. + +# This table stores group-user pairs. A group may contain many users and a user +# may belong to many groups. The name of the table is 'group_to_user'. +# It has two columns, 'user_id' and 'group_id', which together constitute the +# primary key. 'user_id' is a foreign key referring to the 'user_id' column in +# the 'user' table. 'group_id' is a foreign key referring to the 'group_id' column +# in the 'group' table. + +# For each graph, this table stores the groups that the graph belongs to. +# Note that a graph may belong to multiple groups. + +# For each graph, this table stores tags that the graph has. A graph can have i +# many tags, and a tag can belong to many graphs. + + +#=================== End of Junction Tables =================== + +# ================== Table Definitions =================== +class GroupToUser(Base): + '''The class representing the schema of the group_to_user table.''' + __tablename__ = 'group_to_user' + + user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + group_id = Column(String, ForeignKey('group.group_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + group_owner = Column(String, ForeignKey('group.owner_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + +class Feedback(Base): + __tablename__ = 'feedback' + + id = Column(Integer, autoincrement=True, primary_key=True) + graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE")) + user_id = Column(String, ForeignKey('graph.user_id', ondelete="CASCADE", onupdate="CASCADE")) + layout_id = Column(String, ForeignKey('layout.layout_id', ondelete="CASCADE", onupdate="CASCADE")) + text = Column(String, nullable = False) + created = Column(TIMESTAMP, nullable = False) + +class GroupToGraph(Base): + '''The class representing the schema of the group_to_graph table.''' + __tablename__ = 'group_to_graph' + + group_id = Column(String, ForeignKey('group.group_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + group_owner = Column(String, ForeignKey('group.owner_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + user_id = Column(String, ForeignKey('graph.user_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + modified = Column(TIMESTAMP, nullable = False) + +class GraphToTag(Base): + '''The class representing the schema of the graph_to_tag table.''' + __tablename__ = 'graph_to_tag' + + graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + user_id = Column(String, ForeignKey('graph.user_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) + tag_id = Column(String, ForeignKey('graph_tag.tag_id'), primary_key=True) + +class TaskCode(Base): + '''The class representing the schema of the task_code table.''' + __tablename__ = 'task_code' + hit_id = Column(String, ForeignKey('task.hit_id', ondelete="CASCADE", onupdate="CASCADE")) + code = Column(String, primary_key = True) + created = Column(TIMESTAMP, nullable = False) + +class User(Base): + '''The class representing the schema of the user table.''' + __tablename__ = 'user' + + user_id = Column(String, primary_key = True) + password = Column(String, nullable = False) + admin = Column(Integer, nullable = True) + + # one to many relationships. TODO: add cascades + # at most one user can create a graph layout + layouts = relationship("Layout") + # each group has at most one user who created it. See the owner_id column in the 'Group' class. + owned_groups = relationship("Group") + # each graph has at most one creator. + graphs = relationship("Graph") + # ??? + password_reset = relationship("PasswordReset") + +# TODO: change name to Group here and in the db. +class Group(Base): + __tablename__ = 'group' + + group_id = Column(String, primary_key = True) + # TODO: describe the difference bewteen group_id and name. + name = Column(String, nullable = False) + # Each group has one owner, who must be in the user table. The foreign key + # statement corresponds to the owned_groups relationship in the 'User' class. + owner_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False, primary_key = True) + description = Column(String, nullable = False) + + # This line creates the many-to-many relationship between the User class and the i + # Group class through the group_to_user junction table. Specifically, + # it links the 'User' class to the current class using the group_to_user junction + # table; this is a many to one relationship from 'User' to 'group_to_user'. + # The backref argument establishes the many to one relationship from 'Group' + # to 'group_to_user'. An equivalent way to link the two classes is to instead + # add the following line to User: + # groups = relationship('Group', secondary=group_to_user, backref='user') + # users = relationship('User', backref='group') + # # specifies many-to-many relationship with Graph table + # graphs = relationship('Graph', backref='group') + +class Graph(Base): + __tablename__ = 'graph' + + # The graph_id and user_id together specify the primary key. + graph_id = Column(String, primary_key = True) #id + user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key = True) + json = Column(String, nullable = False) + created = Column(TIMESTAMP, nullable = False) + modified = Column(TIMESTAMP, nullable = False) + public = Column(Integer, nullable = True) + shared_with_groups = Column(Integer, nullable = True) + default_layout_id = Column(String, nullable = True) + + # specify one to many relationships + # layouts = relationship("Layout") + # # Each node can belong to at most one graph. See the 'Node' class for details. + # nodes = relationship("Node") + + # groups = relationship("Group", backref='graph') + + #specify many to many relationship with GraphTag + # tags = relationship("GraphTag", secondary=graph_to_tag, backref='graph') + +class Task(Base): + ''' + Table that represents the task table. + ''' + __tablename__ = 'task' + + task_id = Column(Integer, autoincrement=True, primary_key=True) + task_owner = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) + user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) + graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) + layout_id = Column(String, ForeignKey('layout.layout_id', ondelete="CASCADE", onupdate="CASCADE")) + created = Column(TIMESTAMP, nullable = False) + hit_id=Column(String, nullable=False) + worker_id=Column(String, nullable=False) + submitted=Column(Integer, nullable=True) + task_type=Column(String, nullable=False) + +class GraphTag(Base): + ''' + Table of tags that are assigned to each graph to categorize them. + ''' + __tablename__ = 'graph_tag' + tag_id = Column(String, primary_key = True) #id + +class Feature(Base): + ''' + Table that holds all the features. + ''' + __tablename__ = 'feature' + + id = Column(Integer, autoincrement=True, primary_key=True) + user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE")) + graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = True) + layout_id = Column(String, ForeignKey('layout.layout_id', ondelete="CASCADE", onupdate="CASCADE")) + created = Column(TIMESTAMP, nullable = False) + distance_vector = Column(String, nullable = True) + pairwise_vector = Column(String, nullable = True) + num_changes = Column(Integer, nullable = False) + time_spent = Column(Integer, nullable = False) + events = Column(String, nullable = False) + +class Layout(Base): + ''' + Table of Layouts to specify how the graphs are viewed on GraphSpace. + User created layouts will be stored in this table. + ''' + __tablename__ = 'layout' + + layout_id = Column(Integer, autoincrement=True, primary_key=True) + # A descriptive name for the layout, provided by the owner_id when creating the i + # layout in GraphSpace. + layout_name = Column(String, nullable = False) + + # The id of the user who created the layout. The foreign key constraint ensures + # this person is present in the 'user' table. Not that owner_id need not be the + # same as user_id since (graph_id, user_id) uniquely identify the graph. + # In other words, the owner_id can be the person other than the one who created + # the graph (user_id). An implicit rule is that owner_id must belong to some + # group that this graph belongs to. However, the database does not enforce this + # constraint explicitly. + # TODO: Add a database constraint that checks this rule. + owner_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) + # id of the graph that the layout is for + graph_id = Column(String, nullable = False) + # id of the user who owns the graph specified by graph_id + user_id = Column(String, nullable = False) + # graph layout data in JSON format + json = Column(String, nullable = False) + public = Column(Integer, nullable = True) + shared_with_groups = Column(Integer, nullable = True) + # when was the last time layout was modified + times_modified = Column(Integer, nullable = False) + # If layout is updated (task), save the original layout + original_json = Column(String, nullable = True) + + # SQLAlchemy's way of creating a multi-column foreign key constraint. + __table_args__ = (ForeignKeyConstraint([graph_id, user_id], [Graph.graph_id, Graph.user_id], ondelete="CASCADE", onupdate="CASCADE"), {}) + +class LayoutStatus(Base): + ''' + Table of layout acceptances/rejections. + ''' + __tablename__ = 'layout_status' + + id = Column(Integer, autoincrement=True, primary_key=True) + graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE")) + user_id = Column(String, ForeignKey('graph.user_id', ondelete="CASCADE", onupdate="CASCADE")) + layout_id = Column(Integer, nullable = False) + triangle_rating = Column(Integer, nullable = False) + rectangle_rating = Column(Integer, nullable = False) + shape_rating = Column(Integer, nullable = False) + color_rating = Column(Integer, nullable = False) + created = Column(TIMESTAMP, nullable = False) + submitted_by = Column(String, nullable = True) + +class Node(Base): + ''' + Table of nodes used in graphs. Same node can be in many graphs, but they are + considered to be distinct. + ''' + __tablename__ = 'node' + + # The primary key contains three columns: node_id, graph_id, and user_id. The same node may appear in different graphs but we consider them to be distinct. + node_id = Column(String, primary_key = True) + label = Column(String, nullable = False) + user_id = Column(String, primary_key = True) + graph_id = Column(String, primary_key = True) + modified = Column(TIMESTAMP, primary_key = True) + + # Foregin key contraint to idientify the graph that this node belong to + __table_args__ = (ForeignKeyConstraint([user_id, graph_id], [Graph.user_id, Graph.graph_id], ondelete="CASCADE", onupdate="CASCADE"), {}) + + # one to many relationship with Edge, since a node can have many edges. + heads = relationship("Edge", foreign_keys="[Edge.user_id, Edge.graph_id, Edge.head_node_id]") + tails = relationship("Edge", foreign_keys="[Edge.user_id, Edge.graph_id, Edge.head_node_id]") + +class PasswordReset(Base): + __tablename__ = 'password_reset' + + id = Column(Integer, primary_key = True, autoincrement = True) + user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) + code = Column(String, nullable = False) + created = Column(TIMESTAMP, nullable = False) + + #no relationship specified + +class Edge(Base): + ''' + Table of edges that are used on graphs. + ''' + __tablename__ = 'edge' + + # 3 column primary keys are used to determine which two nodes that this edge connect. + # each edge connects a head node to a tail node. + # head node + user_id = Column(String, nullable = False) + graph_id = Column(String, nullable = False) + head_node_id = Column(String, nullable = False) + # head_node_label column was added to speed up the similar terms search query on edges. The lookup on two tables was taking too much time. + # This column is part of index edge_idx_head_label_tail_label + head_node_label = Column(String, nullable = False) + + # tail node + tail_node_id = Column(String, nullable = False) + # tail_node_label column was added to speed up the similar terms search query on edges. The lookup on two tables was taking too much time. + # This column is part of index edge_idx_head_label_tail_label + tail_node_label = Column(String, nullable = False) + + # label of this edge + edge_id = Column(String, nullable = True) + # inicates whether this edge is directed or not. + directed = Column(Integer, nullable = True) + + id = Column(Integer, autoincrement=True, primary_key=True) + + # Foreign key contraints to determine each node. + __table_args__ = ( + ForeignKeyConstraint([user_id, graph_id, head_node_id], [Node.user_id, Node.graph_id, Node.node_id], ondelete="CASCADE", onupdate="CASCADE"), + ForeignKeyConstraint([user_id, graph_id, tail_node_id], [Node.user_id, Node.graph_id, Node.node_id], ondelete="CASCADE", onupdate="CASCADE"), {}) + #no relationship specified + +#Create indices +Index('graph_public_idx', Graph.public) +Index('graph_owner_idx', Graph.user_id) +# Table: group. Columns: owner_id +Index('group_idx_owner_id_group_id', Group.owner_id, Group.group_id) +# Table: graph. Columns: user_id +Index('graph_idx_user_graph_id', Graph.user_id, Graph.graph_id) +# Table: graph. Columns: user_id, modified, public +Index('graph_idx_user_id_modified_id_public', Graph.user_id, Graph.graph_id, Graph.modified, Graph.public) +# Table: graph. Columns: modified, user_id, graph_id, public +# Table: graph_to_tag. Columns: graph_id, user_id +Index('graph_to_tag_idx_graph_id_user_id', GraphToTag.graph_id, GraphToTag.user_id, GraphToTag.tag_id) +# Table: graph_to_tag. Columns: tag_id +Index('graph_to_tag_idx_tag_id', GraphToTag.tag_id) +Index('graph_to_tag_idx_user_id', GraphToTag.user_id, GraphToTag.graph_id) +# Table: group_to_graph. Columns: graph_id, user_id +Index('group_to_graph_idx_graph_id_user_id', GroupToGraph.graph_id, GroupToGraph.user_id, GroupToGraph.group_id, GroupToGraph.group_owner) +# Table: group_to_graph. Columns: group_id +Index('group_to_graph_idx_group_id', GroupToGraph.group_id, GroupToGraph.group_owner) +Index('group_to_graph_graph_idx', GroupToGraph.graph_id) +Index('group_to_graph_group_idx', GroupToGraph.group_id) +Index('group_to_graph_user_idx', GroupToGraph.user_id) +# Table: group_to_graph. Columns: graph_id, user_id, group_id. +# Table: group_to_user. Columns: group_id +Index('group_to_user_idx_group_id', GroupToUser.group_id, GroupToUser.user_id, GroupToUser.group_owner) +# Table: group_to_user. Columns: user_id +Index('group_to_user_idx_user_id', GroupToUser.user_id) +# Table: layout. Columns: graph_id, user_id +#Index('layout_idx_graph_id_user_id', Layout.graph_id, Layout.user_id) +# Table: layout. Columns: owner_id +#Index('layout_idx_owner_id', Layout.owner_id) +# Table: node. Columns: graph_id, user_id +Index('node_idx_graph_id_user_id', Node.graph_id, Node.user_id, Node.node_id, Node.label) +Index('node_index_label_graph_id', Node.label) +Index('node_index_node_id_graph_id', Node.node_id) + +Index('edge_idx_head_id_tail_id', Edge.head_node_id, Edge.tail_node_id) +Index('edge_idx_head_label_tail_label', Edge.head_node_label, Edge.tail_node_label) +# Create an engine that stores data in the local directory's +# sqlalchemy_example.db file. +engine = create_engine(settings.DATABASE_LOCATION, echo=False) + +# Create all tables in the engine. This is equivalent to "Create Table" +# statements in raw SQL. +Base.metadata.create_all(engine) diff --git a/build/lib/graphs/urls.py b/build/lib/graphs/urls.py new file mode 100644 index 00000000..7aee8be0 --- /dev/null +++ b/build/lib/graphs/urls.py @@ -0,0 +1,209 @@ +from django.conf.urls import patterns, url +from graphs import views + +urlpatterns = patterns('', + # name parameter indicates the name of the view. This name + # can be accessed from respective html files using Django + # template language. + + url(r'^submitEvaluation/$', views.submitEvaluation, name='submitEvaluation'), + url(r'^submitExpertEvaluation/$', views.submitExpertEvaluation, name='submitExpertEvaluation'), + url(r'^saveFeedback/$', views.saveFeedback, name='saveFeedback'), + url(r'^getFeedback/$', views.getFeedback, name='getFeedback'), + url(r'^$', views.index, name='index'), + url(r'^index/$', views.index, name='index'), + url(r'^index/logout/$', views.logout, name='logout'), + url(r'^download/$', views.download, name='download'), + url(r'^image/$', views.image, name='image'), + + # graphs page + url(r'^graphs/$', views.graphs, name='graphs'), + url(r'^graphs/shared/$', views.shared_graphs, name='shared_graphs'), + url(r'^graphs/public/$', views.public_graphs, name='public_graphs'), + url(r'^graphs/upload/$', views.upload_graph_through_ui, name='upload_graph_through_ui'), + + # view graph page. This contains regular expression to catch url in the form of the following: + # /graphs/email_address/graph_id/ + # regex from http://www.regular-expressions.info/email.html + # and are variable names that are passed as parameters + # into the views.view_graph function. + url(r'^task/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/layout/update/$', views.update_layout, name='update_layout'), + url(r'^graphs/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/layout/$', views.save_layout, name='save_layout'), + url(r'^graphs/design/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/layout/$', views.save_layout, name='save_layout'), + url(r'^graphs/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/$', views.view_graph, name='view_graph'), + + url(r'^json/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/$', views.view_json, name='view_json'), + + # groups page + url(r'^groups/$', views.groups, name='groups'), + url(r'^groups/member/$', views.groups_member, name='groups_member'), + url(r'^groups/(?P.+)/(?P.+)/$', views.graphs_in_group, name="graphs_in_group"), + url(r'^add/(?P.+)/$', views.create_group, name='create_group'), + url(r'^delete/group/$', views.delete_group_through_ui, name='delete_group_through_ui'), + url(r'^unsubscribe/group/$', views.unsubscribe_from_group, name='unsubscribe_from_group'), + + url(r'^retrieveIDs/$', views.retrieveIDs, name='retrieveIDs'), + url(r'^changeDescription/$', views.change_description_through_ui, name='change_description_through_ui'), + url(r'^addMember/$', views.add_member_through_ui, name='add_member_through_ui'), + url(r'^removeMember/$', views.remove_member_through_ui, name='remove_member_through_ui'), + + # help page + url(r'^help/$', views.help, name='help'), + url(r'^help/tutorial$', views.help_tutorial, name='help_tutorial'), + url(r'^help/programmers/$', views.help_programmers, name='help_programmers'), + url(r'^help/graphs/$', views.help_graphs, name='help_graphs'), + url(r'^help/restapi/$', views.help_restapi, name='help_restapi'), + url(r'^help/jsonref/$', views.help_jsonref, name='help_jsonref'), + url(r'^help/about/$', views.help_about, name='help_about'), + url(r'^features/$', views.features, name='features'), + + + # hack to get all HGTV graphs working + url(r'^images/legend.png$', views.renderImage, name='renderImage'), + + # Utility methods + url(r'^register/$', views.register, name='register'), + url(r'^changeLayoutName/$', views.changeLayoutName, name='changeLayoutName'), + url(r'^deleteLayout/$', views.deleteLayout, name='deleteLayout'), + url(r'^getGroupsForGraph/$', views.getGroupsForGraph, name='getGroupsForGraph'), + url(r'^shareGraphWithGroups/$', views.shareGraphWithGroups, name='shareGraphWithGroups'), + url(r'^shareLayoutWithGroups/$', views.shareLayoutWithGroups, name='shareLayoutWithGroups'), + url(r'^makeLayoutPublic/$', views.makeLayoutPublic, name='makeLayoutPublic'), + url(r'^setDefaultLayout/$', views.setDefaultLayout, name='setDefaultLayout'), + url(r'^removeDefaultLayout/$', views.removeDefaultLayout, name='removeDefaultLayout'), + url(r'^deleteGraph/$', views.deleteGraph, name='deleteGraph'), + url(r'^forgot/$', views.sendResetEmail, name='forgot'), + url(r'^reset/$', views.resetLink, name='reset'), + url(r'^resetPassword/$', views.resetPassword, name='resetPassword'), + url(r'^launchTask/$', views.launchTask, name='launchTask'), + url(r'^retrieveTaskCode/$', views.retrieveTaskCode, name='retrieveTaskCode'), + + #REST API + + # Graph REST API endpoints + url(r'^api/users/(?P.+)/graph/add/(?P.+)/$', views.upload_graph, name='upload_graph'), + url(r'^api/users/(?P.+)/graph/get/(?P.+)/$', views.retrieve_graph, name='retrieve_graph'), + url(r'^api/users/(?P.+)/graph/exists/(?P.+)/$', views.graph_exists, name='graph_exists'), + url(r'^api/users/(?P.+)/graph/update/(?P.+)/$', views.update_graph, name='update_graph'), + url(r'^api/users/(?P.+)/graph/delete/(?P.+)/$', views.remove_graph, name='remove_graph'), + url(r'^api/users/(?P.+)/graph/makeGraphPublic/(?P.+)/$', views.make_graph_public, name='make_graph_public'), + url(r'^api/users/(?P.+)/graph/makeGraphPrivate/(?P.+)/$', views.make_graph_private, name='make_graph_private'), + url(r'^api/users/(?P.+)/graphs/$', views.view_all_graphs_for_user, name='view_all_graphs_for_user'), + + # Group REST API endpoints + url(r'^api/groups/get/(?P.+)/(?P.+)/$', views.get_group, name='get_group'), + url(r'^api/groups/get/$', views.get_groups, name='get_groups'), + url(r'^api/groups/add/(?P.+)/(?P.+)/$', views.add_group, name='add_group'), + url(r'^api/groups/delete/(?P.+)/(?P.+)/$', views.delete_group, name='delete_group'), + url(r'^api/users/(?P.+)/groups/$', views.get_group_for_user, name='get_group_for_user'), + url(r'^api/groups/(?P.+)/(?P.+)/adduser/(?P.+)/$', views.add_user_to_group, name='add_user_to_group'), + url(r'^api/groups/(?P.+)/(?P.+)/removeuser/(?P.+)/$', views.remove_user_from_group, name='remove_user_from_group'), + url(r'^api/users/graphs/(?P.+)/share/(?P.+)/(?P.+)/$', views.share_graph, name='share_graph'), + url(r'^api/users/graphs/(?P.+)/unshare/(?P.+)/(?P.+)/$', views.unshare_graph, name='unshare_graph'), + + # Tag REST API endpoints + url(r'^api/tags/user/(?P.+)/(?P.+)/makePublic/$', views.make_all_graphs_for_tag_public, name='make_all_graphs_for_tag_public'), + url(r'^api/tags/user/(?P.+)/(?P.+)/makePrivate/$', views.make_all_graphs_for_tag_private, name='make_all_graphs_for_tag_private'), + url(r'^api/tags/user/(?P.+)/(?P.+)/delete/$', views.delete_all_graphs_for_tag, name='delete_all_graphs_for_tag'), + url(r'^api/tags/user/(?P.+)/(?P.+)/$', views.get_all_tags_for_graph, name='get_all_tags_for_graph'), + url(r'^api/tags/user/(?P.+)/$', views.get_tags_for_user, name='get_tags_for_user'), + + + # THE FOLLOWING SECTION CONTAINS ALL LINKS THAT ARE ALREADY IN GRAPHSPACE BUT WITHOUT THE TRAILING '/' CHARACTER + url(r'^index$', views.index, name='index'), + url(r'^index/logout$', views.logout, name='logout'), + url(r'^download$', views.download, name='download'), + + # graphs page + url(r'^graphs$', views.graphs, name='graphs'), + url(r'^graphs/shared$', views.shared_graphs, name='shared_graphs'), + url(r'^graphs/public$', views.public_graphs, name='public_graphs'), + url(r'^graphs/upload$', views.upload_graph_through_ui, name='upload_graph_through_ui'), + + # view graph page. This contains regular expression to catch url in the form of the following: + # /graphs/email_address/graph_id/ + # regex from http://www.regular-expressions.info/email.html + # and are variable names that are passed as parameters + # into the views.view_graph function. + url(r'^graphs/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/layout$', views.save_layout, name='save_layout'), + url(r'^graphs/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.view_graph, name='view_graph'), + url(r'^graphs/design/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.design_graph, name='design_graph'), + url(r'^task/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.view_task, name='view_task'), + url(r'^approveExpert$', views.approve_task_expert, name='approve_task_expert'), + url(r'^approve/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.approve_task, name='approve_task'), + + url(r'^json/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.view_json, name='view_json'), + + # groups page + url(r'^groups$', views.groups, name='groups'), + url(r'^groups/member$', views.groups_member, name='groups_member'), + url(r'^groups/(?P.+)/(?P.+)$', views.graphs_in_group, name="graphs_in_group"), + url(r'^add/(?P.+)$', views.create_group, name='create_group'), + url(r'^delete/group$', views.delete_group_through_ui, name='delete_group_through_ui'), + url(r'^unsubscribe/group$', views.unsubscribe_from_group, name='unsubscribe_from_group'), + + url(r'^retrieveIDs$', views.retrieveIDs, name='retrieveIDs'), + url(r'^changeDescription$', views.change_description_through_ui, name='change_description_through_ui'), + url(r'^addMember$', views.add_member_through_ui, name='add_member_through_ui'), + url(r'^removeMember$', views.remove_member_through_ui, name='remove_member_through_ui'), + + # help page + url(r'^help$', views.help, name='help'), + url(r'^help/tutorial$', views.help_tutorial, name='help_tutorial'), + url(r'^help/programmers$', views.help_programmers, name='help_programmers'), + url(r'^help/graphs$', views.help_graphs, name='help_graphs'), + url(r'^help/restapi$', views.help_restapi, name='help_restapi'), + url(r'^help/jsonref$', views.help_jsonref, name='help_jsonref'), + url(r'^help/about$', views.help_about, name='help_about'), + url(r'^features$', views.features, name='features'), + + + # hack to get all HGTV graphs working + url(r'^images/legend.png$', views.renderImage, name='renderImage'), + + # Utility methods + url(r'^register$', views.register, name='register'), + url(r'^changeLayoutName$', views.changeLayoutName, name='changeLayoutName'), + url(r'^deleteLayout$', views.deleteLayout, name='deleteLayout'), + url(r'^getGroupsForGraph$', views.getGroupsForGraph, name='getGroupsForGraph'), + url(r'^shareGraphWithGroups$', views.shareGraphWithGroups, name='shareGraphWithGroups'), + url(r'^shareLayoutWithGroups$', views.shareLayoutWithGroups, name='shareLayoutWithGroups'), + url(r'^makeLayoutPublic$', views.makeLayoutPublic, name='makeLayoutPublic'), + url(r'^setDefaultLayout$', views.setDefaultLayout, name='setDefaultLayout'), + url(r'^removeDefaultLayout$', views.removeDefaultLayout, name='removeDefaultLayout'), + url(r'^deleteGraph$', views.deleteGraph, name='deleteGraph'), + url(r'^forgot$', views.sendResetEmail, name='forgot'), + url(r'^reset$', views.resetLink, name='reset'), + url(r'^resetPassword$', views.resetPassword, name='resetPassword'), + + #REST API + + # Graph REST API endpoints + url(r'^api/users/(?P.+)/graph/add/(?P.+)$', views.upload_graph, name='upload_graph'), + url(r'^api/users/(?P.+)/graph/get/(?P.+)$', views.retrieve_graph, name='retrieve_graph'), + url(r'^api/users/(?P.+)/graph/update/(?P.+)$', views.update_graph, name='update_graph'), + url(r'^api/users/(?P.+)/graph/delete/(?P.+)$', views.remove_graph, name='remove_graph'), + url(r'^api/users/(?P.+)/graph/makeGraphPublic/(?P.+)$', views.make_graph_public, name='make_graph_public'), + url(r'^api/users/(?P.+)/graph/makeGraphPrivate/(?P.+)$', views.make_graph_private, name='make_graph_private'), + url(r'^api/users/(?P.+)/graphs$', views.view_all_graphs_for_user, name='view_all_graphs_for_user'), + + # Group REST API endpoints + url(r'^api/groups/get/(?P.+)/(?P.+)$', views.get_group, name='get_group'), + url(r'^api/groups/get$', views.get_groups, name='get_groups'), + url(r'^api/groups/add/(?P.+)/(?P.+)$', views.add_group, name='add_group'), + url(r'^api/groups/delete/(?P.+)/(?P.+)$', views.delete_group, name='delete_group'), + url(r'^api/users/(?P.+)/groups$', views.get_group_for_user, name='get_group_for_user'), + url(r'^api/groups/(?P.+)/(?P.+)/adduser/(?P.+)$', views.add_user_to_group, name='add_user_to_group'), + url(r'^api/groups/(?P.+)/(?P.+)/removeuser/(?P.+)$', views.remove_user_from_group, name='remove_user_from_group'), + url(r'^api/users/graphs/(?P.+)/share/(?P.+)/(?P.+)$', views.share_graph, name='share_graph'), + url(r'^api/users/graphs/(?P.+)/unshare/(?P.+)/(?P.+)$', views.unshare_graph, name='unshare_graph'), + + # Tag REST API endpoints + url(r'^api/tags/user/(?P.+)/(?P.+)/makePublic$', views.make_all_graphs_for_tag_public, name='make_all_graphs_for_tag_public'), + url(r'^api/tags/user/(?P.+)/(?P.+)/makePrivate$', views.make_all_graphs_for_tag_private, name='make_all_graphs_for_tag_private'), + url(r'^api/tags/user/(?P.+)/(?P.+)/delete$', views.delete_all_graphs_for_tag, name='delete_all_graphs_for_tag'), + url(r'^api/tags/user/(?P.+)/(?P.+)$', views.get_all_tags_for_graph, name='get_all_tags_for_graph'), + url(r'^api/tags/user/(?P.+)$', views.get_tags_for_user, name='get_tags_for_user'), + + ) + diff --git a/build/lib/graphs/util/__init__.py b/build/lib/graphs/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/graphs/util/db.py b/build/lib/graphs/util/db.py new file mode 100644 index 00000000..1d11ab9c --- /dev/null +++ b/build/lib/graphs/util/db.py @@ -0,0 +1,4733 @@ +import hmac +import json +import math +import random +import sqlite3 as lite +import string +import urllib +import uuid +import xml.etree.ElementTree as ET +from collections import Counter, defaultdict +from datetime import datetime +from hashlib import sha1 + +import bcrypt +from sqlalchemy import or_, and_ +from sqlalchemy.orm.exc import NoResultFound + +import graphs.models as models +import graphs.util.db_init as db_init +import requests +from django.conf import settings +from django.core.mail import send_mail +from json_validator import validate_json, assign_edge_ids, convert_json, verify_json + +data_connection = db_init.db + +# Name of the database that is being used as the backend storage +DB_NAME = settings.DB_FULL_PATH +URL_PATH = settings.URL_PATH +AWSACCESSKEYID = settings.AWSACCESSKEYID +SECRETKEY = settings.SECRETKEY + +AWS_URL = 'https://mechanicalturk.sandbox.amazonaws.com' + +def saveFeedback(feedback, graph_id, user_id, layout_owner, layout_name): + #create a new db session + db_session = data_connection.new_session() + + new_feedback = models.Feedback(id= None, graph_id = graph_id, user_id = user_id, layout_owner = layout_owner, layout_name = layout_name, text=feedback, created=datetime.now()) + db_session.add(new_feedback) + db_session.commit() + db_session.close() + +def getFeedback(graph_id, user_id, layout_owner, layout_name): + #create a new db session + db_session = data_connection.new_session() + + layout = db_session.query(models.Layout).filter(models.Layout.owner_id == layout_owner).filter(models.Layout.layout_name == layout_name).first() + + try: + feedback = db_session.query(models.Feedback.text).filter(models.Feedback.graph_id == graph_id).filter(models.Feedback.user_id == user_id).filter(models.Feedback.layoud_id == layout.layout_id).all() + db_session.close() + return feedback + except NoResultFound: + db_session.close() + return [] + +def add_everyone_to_password_reset(): + ''' + Adds all users to password reset table (cold-start). + Only use this once so we know crypto algorithm used + for forgot password functionalities + + ''' + + #create a new db session + db_session = data_connection.new_session() + + try: + # Get all users that are currently in the user table + user_ids = db_session.query(models.User.user_id).all() + + # Go through each username and add it to the password_reset table + for user_id in user_ids: + # This is done to remove the unicode encoding and simply + # extract the string + user_id = user_id[0] + add_user_to_password_reset(user_id, db_session = db_session) + + except NoResultFound: + print "There are no users in the database" + return None + + db_session.close() + +def add_user_to_password_reset(email, db_session=None): + ''' + Adds a specific user to password_reset table. + If email is in this, it automatically sends email to change + password for that account the next time the user logs on + + :param email: Email of the user for GraphSpace + :param db_session: database connection (See sqlalchemy.org for more information) + ''' + # Check if current user exists + curUser = emailExists(email) + + # If user doesn't exist, return out + if curUser == None: + return + + # Get database connection + db_session = db_session or data_connection.new_session() + + # Get the user if they exist + user_id = db_session.query(models.PasswordReset).filter(models.PasswordReset.user_id == email).first() + + # Generate unique code that GraphSpace will use to identify + # which user is trying to reset their password + code = id_generator() + + # If user needs to already reset their password, simply update the code + # for everytime they want to reset the password + + # If user doesn't have to already change their password, add them to the appropriate table + if user_id == None: + + # Create new entry to be inserted into password_reset table + reset_user = models.PasswordReset(id = None, user_id = email, code = code, created = datetime.now()) + db_session.add(reset_user) + # Commit the changes to the database + db_session.commit() + else: + #Update the code for the existing user already needing to reset their password + user_id.code = code + # Commit the changes to the database + db_session.commit() + +def emailExists(email): + ''' + Checks to see if a user's email exists. + + :param email: Email of user + :return boolean: True if user exists, false otherwise + ''' + #create a new db session + db_session = data_connection.new_session() + + try: + # Get the user if they exist + user = db_session.query(models.User).filter(models.User.user_id == email).one() + # Get the string representation from the tuple + db_session.close() + return user + except NoResultFound: + db_session.close() + return None + +def need_to_reset_password(email): + ''' + Checks to see if a user needs to reset their password. + If email is in password_reset email, they do, otherwise, not. + + :param email: Email of the user in GraphSpace + ''' + #create a new db session + db_session = data_connection.new_session() + + try: + # If email exists in password_reset table, then the user has to reset their password + user_id = db_session.query(models.PasswordReset.user_id).filter(models.PasswordReset.user_id == email).one() + return True + except NoResultFound: + return None + + db_session.close() + +def sendForgotEmail(email): + ''' + Emails the user to reset their password. + + :param email of user + ''' + + #create a new db session + db_session = data_connection.new_session() + + # Retrieve reset code attached to email + reset_code = db_session.query(models.PasswordReset.code).filter(models.PasswordReset.user_id == email).first() + + if reset_code == None: + return None + + # Construct email message + mail_title = 'Password Reset Information for GraphSpace!' + message = 'Please go to the following url to reset your password: ' + URL_PATH + 'reset/?id=' + reset_code[0] + emailFrom = "GraphSpace Admin" + + # Sends email to respective user + send_mail(mail_title, message, emailFrom, [email], fail_silently=False) + db_session.close() + return "Email Sent!" + +def retrieveResetInfo(reset_code): + ''' + Retrieves the reset information for a user (for comparing which user it is). + + :param reset_code: Code that the user has to match to HTTP GET request + :return account: Account associated with the code + ''' + + #create a new db session + db_session = data_connection.new_session() + + try: + # Obtain email attached to code -> code that was send to email address + # This is a verification step to ensure code is legit + user_id_to_reset = db_session.query(models.PasswordReset.user_id).filter(models.PasswordReset.code == reset_code).one() + # Retrieve string from unicode + user_id_to_reset = user_id_to_reset[0] + db_session.close() + return user_id_to_reset + except NoResultFound: + print "Code provided is not correct" + db_session.close() + return None + +def resetPassword(username, password, code): + ''' + Updates password information about a user. + + :param username: Email of user + :param password: Password of user + :param code: Code that is attached to this user to match if correct user submits password request + ''' + + #create a new db session + db_session = data_connection.new_session() + + # if code in password_reset table doesn't match the user trying to reset the password, don't allow them to + user = db_session.query(models.PasswordReset).filter(models.PasswordReset.code == code).first() + + if user == None: + return "Incorrect code provided" + + if user.user_id != username: + return "Invalid code provided for current user" + + try: + # Hash password + password = bcrypt.hashpw(password, bcrypt.gensalt()) + # Update the password for the user (after encryption of course) + user_to_reset_pw_for = db_session.query(models.User).filter(models.User.user_id == username).first() + user_to_reset_pw_for.password = password + + # Remove user's account from password_reset table + delete_from_password_reset = db_session.query(models.PasswordReset).filter(models.PasswordReset.user_id == username).all() + for acct in delete_from_password_reset: + db_session.delete(acct) + db_session.commit() + db_session.close() + return "Password updated successfully" + except Exception as ex: + print ex + print "Password not updated correctly" + db_session.close() + return None + +#### ONE TIME CODE -- KEEP FOR REFERENCE +def reUploadInconsistentGraphs(data): + con = None + try: + incosistent_graphs = open("inconsistency.txt", "a") + con = lite.connect(DB_NAME) + cur = con.cursor() + graphs_processed = 1 + for graph in data: + + graph_id = graph[0] + user_id = graph[1] + graph_json = json.loads(graph[2]) + created = graph[3] + modified = graph[4] + public = graph[5] + unlisted = graph[6] + default_layout_id = graph[7] + + print "Processing Graph: ", graph_id, " owned by: ", user_id, "\n", graphs_processed, " processed so far" + graphs_processed += 1 + + if 'data' in graph_json: + graph_json = json.loads(convert_json(graph[2])) + + node_list = [] + + for node in graph_json['graph']['nodes']: + node_list.append(str(node['data']['id'])) + + cur.execute('select node_id from node where graph_id=? and user_id =?', (graph_id, user_id)) + + nodes = cur.fetchall() + + mark_for_deletion = False + + if len(nodes) != len(node_list): + print "Nodes don't match" + mark_for_deletion = True + + unspecified_nodes = "" + + for node in nodes: + node = str(node[0]) + if node not in node_list: + print "Unspecified node: ", node + unspecified_nodes += node + ", " + mark_for_deletion = True + + if mark_for_deletion == True: + incosistent_graphs.write(graph_id + '\t' + user_id + "\t" + created + "\t" + modified + "\t" + unspecified_nodes + "\n" ) + cur.execute('delete from graph where graph_id = ? and user_id = ?', (graph_id, user_id)) + cur.execute('delete from node where graph_id = ? and user_id = ?', (graph_id, user_id)) + cur.execute('delete from edge where graph_id = ? and user_id = ?', (graph_id, user_id)) + cur.execute('delete from graph_to_tag where graph_id=? and user_id=?', (graph_id, user_id)) + con.commit() + result = insert_graph(user_id, graph_id, graph[2], created=created, modified=modified, public=public, unlisted=unlisted, default_layout_id=default_layout_id, skip=True) + if result != None: + print result + else: + print "Reinserted: " + graph_id + + print "Done processing" + incosistent_graphs.close() + except lite.Error, e: + print 'Error %s:' % e.args[0] + + finally: + if con: + con.close() + +def checkPublicNodeEdgeConsistency(): + ''' + Goes through public graph JSONs in GraphSpace database and makes sure + that the node and edge table have the appropriate + values and nothing that shouldn't be there. + + ''' + con = None + try: + con = lite.connect(DB_NAME) + cur = con.cursor() + + cur.execute('select * from graph') + data = cur.fetchall() + + if data == None: + return + + reUploadInconsistentGraphs(data) + + except lite.Error, e: + print 'Error %s:' % e.args[0] + + finally: + if con: + con.close() + +def checkNodeEdgeConsistencyOfUser(user_id): + ''' + Goes through JSONs in GraphSpace database and makes sure + that the node and edge table have the appropriate + values and nothing that shouldn't be there. + + ''' + con = None + try: + con = lite.connect(DB_NAME) + cur = con.cursor() + + cur.execute('select * from graph where user_id=?', (user_id, )) + + data = cur.fetchall() + reUploadInconsistentGraphs(data) + + except lite.Error, e: + print 'Error %s:' % e.args[0] + + finally: + if con: + con.close() + + + # END CONVERSIONS + +def id_generator(size=20, chars=string.ascii_uppercase + string.digits): + ''' + Generates an unique alphanumeric ID of specific size. + + :param size: Size of random string + :param chars: Subset of characters to generate random string of + :return string: Random string that adhere to the parameter properties + ''' + return ''.join(random.choice(chars) for _ in range(size)) + +def get_valid_user(username, password): + ''' + Checks to see if a user/password combination exists. + + :param username: Email of the user in GraphSpace + :param password: Password of the user + :return username: | None if wrong information + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Get user if they exist in the database + valid_user = db_session.query(models.User).filter(models.User.user_id == username).one() + # If hashed password != the hashed password in the database, user trying to log in is not a valid user of GraphSpace + if bcrypt.hashpw(password, valid_user.password) != valid_user.password: + db_session.close() + return None + + db_session.close() + return valid_user + except NoResultFound: + db_session.close() + return None + +def get_graph(user_id, graph_id): + ''' + Gets the graph. + + @param user_id: Owner of graph + @param graph_id: ID of graph + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Gets graph + graph = db_session.query(models.Graph).filter(models.Graph.user_id == user_id).filter(models.Graph.graph_id == graph_id).one() + db_session.close() + return graph + except NoResultFound: + db_session.close() + return None + +def graph_exists(user_id, graph_id): + ''' + Checks to if graph exists. + + @param user_id: Owner of graph + @param graph_id: ID of graph + ''' + graph = get_graph(user_id, graph_id) + + if graph == None: + return False + else: + return True + +def get_default_layout(uid, gid): + ''' + Gets the default layout for a graph. + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Retrieve the specific graph + graph_being_searched = get_graph(uid, gid) + + # Retrieve the saved layout from the database + default_layout = db_session.query(models.Layout).filter(models.Layout.layout_id == graph_being_searched.default_layout_id).one() + db_session.close() + + # Convert JSON to cytoscape recognized format + return json.dumps({"json": cytoscapePresetLayout(json.loads(default_layout.json))}) + except NoResultFound: + db_session.close() + return json.dumps(None) + +def get_default_layout_id(uid, gid): + ''' + Gets the default layout for a graph. + ''' + # Get the graph + graph = get_graph(uid, gid) + + if graph != None: + return graph.default_layout_id + else: + return None + +def get_layout(layout_id): + ''' + Gets the layout of ID. + + @param layout_id: Id of layout + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Gets the layout for that specific ID + layout = db_session.query(models.Layout).filter(models.Layout.layout_id == layout_id).one() + db_session.close() + return layout + except NoResultFound: + db_session.close() + return None + +def get_default_layout_name(uid, gid): + ''' + Gets the default layout for a graph. + ''' + # Get the specific graph + graph = get_graph(uid, gid) + + # If the graph exists and has a default layout id + if graph != None and graph.default_layout_id != None: + + # Get the layout from the database + default_layout = get_layout(graph.default_layout_id) + + # If the layout exists, return its name + if default_layout != None: + return default_layout.layout_name + else: + return None + +def set_task_layout_context(request, context, uid, gid, layout_name, layout_owner, approve=None, expert=None): + + context["Error"] = None + layout_to_view = get_default_layout(uid, gid) + context['default_layout'] = get_default_layout_id(uid, gid) + context['layout_name'] = get_default_layout_name(uid, gid) + + # Get all of the available layouts for this graph + db_session = data_connection.new_session() + + layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).first() + + if layout != None: + graph_json = get_layout_for_graph(layout.layout_name, layout.owner_id, gid, uid, layout.user_id) + + if approve: + if expert: + task_exists = db_session.query(models.Task).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.task_type == "APPROVE_TASK").filter(models.Task.worker_id == "EXPERT_WORKER").first() + else: + task_exists = db_session.query(models.Task).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.task_type == "APPROVE_TASK").filter(models.Task.worker_id != "EXPERT_WORKER").first() + else: + if expert: + task_exists = db_session.query(models.Task).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.task_type == "LAYOUT_TASK").filter(models.Task.worker_id == "EXPERT_WORKER").first() + else: + task_exists = db_session.query(models.Task).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.task_type == "LAYOUT_TASK").filter(models.Task.worker_id != "EXPERT_WORKER").first() + + if task_exists != None: + layout_to_view = json.dumps({"json": graph_json}) + context['layout_name'] = layout.layout_name + context['layout_owner'] = layout.owner_id + context["layout_to_view"] = layout_to_view + context['hit_id'] = task_exists.hit_id + return context + + context["layout_to_view"] = json.dumps({"json": None}) + return context + +def set_layout_context(request, context, uid, gid): + ''' + Sets the entire context of a graph to be viewed. This is needed for sending information to the front-end + :param request: HTTP Request of graph to view + :param context: Dictionary containing all the variables to send to the front-end + :param uid: The owner of the graph + :param gid: Graph name to view + :return context: Filled in dictionary with all variables to send to the front-end + ''' + layout_to_view = None + + # if there is a layout specified in the request (query term), then render that layout + if len(request.GET.get('layout', '')) > 0: + + # If the layout is not one of the automatic layout algorithms + if request.GET.get('layout') != 'default_breadthfirst' and request.GET.get('layout') != 'default_concentric' and request.GET.get('layout') != 'default_circle' and request.GET.get('layout') != 'default_cose' and request.GET.get('layout') != 'default_grid': + + # Check to see if the user is logged in + loggedIn = None + if 'uid' in context: + loggedIn = context['uid'] + + # Based on the logged in user and the graph, check to see if + # there exists a layout that matches the query term + graph_json = get_layout_for_graph(request.GET.get('layout'), request.GET.get('layout_owner'), gid, uid, loggedIn) + + # If the layout either does not exist or the user is not allowed to see it, prompt them with an error + if graph_json == None: + context['Error'] = "Layout: " + request.GET.get('layout') + " either does not exist or " + uid + " has not shared this layout yet. Click here to view this graph without the specified layout." + + # Return layout JSON + layout_to_view = json.dumps({"json": graph_json}) + context["layout_owner"] = request.GET.get('layout_owner') + + # Still set the default layout for the graph, if it exists + context['default_layout'] = get_default_layout_id(uid, gid) + else: + + # If there is a layout that is an automatic algorithm, simply + # return the default layout because the front-end JavaScript library + # handles the movement clientside + layout_to_view = get_default_layout(uid, gid) + context['default_layout'] = layout_to_view + + # Set layout name to add to the query term + context['layout_name'] = request.GET.get('layout') + else: + # If there is no layout specified, simply return the default layout + # if it exists + layout_to_view = get_default_layout(uid, gid) + context['default_layout'] = get_default_layout_id(uid, gid) + context['layout_name'] = get_default_layout_name(uid, gid) + + context['default_layout_name'] = get_default_layout_name(uid, gid) + # send layout information to the front-end + + # Pass information to the template + context['layout_to_view'] = layout_to_view + context['layout_urls'] = URL_PATH + "graphs/" + uid + "/" + gid + "?layout=" + + # If user is logged in, display my layouts and shared layouts + if 'uid' in context: + context['my_layouts'] = get_my_layouts_for_graph(uid, gid, context['uid']) + my_shared_layouts = get_my_shared_layouts_for_graph(uid, gid, context['uid']) + all_layouts_for_graph = get_shared_layouts_for_graph(uid, gid, context['uid']) + get_public_layouts_for_graph(uid, gid) + my_shared_layouts + unique_layouts = dict() + + # Filter out all the duplicate layouts + for layout in all_layouts_for_graph: + key = layout.graph_id + layout.user_id + layout.owner_id + layout.layout_name + if (key not in unique_layouts): + unique_layouts[key] = layout + + context['shared_layouts'] = unique_layouts.values() + + my_shared_layout_names = [] + # Get names of the layouts for comparison + for layout in my_shared_layouts: + if layout.layout_name not in my_shared_layout_names: + my_shared_layout_names.append(layout.layout_name) + + context['my_shared_layouts'] = my_shared_layout_names + + else: + # Otherwise only display public layouts + context['my_layouts'] = [] + context['shared_layouts'] = get_public_layouts_for_graph(uid, gid) + + # Check to see if task is launched for graph + exists = task_exists(gid, uid) + + print exists, gid, uid + + context["crowd_layouts"] = get_crowd_layouts_for_graph("MTURK_Worker", gid) + context['task_launched'] = exists + + return context + +def submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, shape_rating, color_rating, hit_id, expert=None): + ''' + Submits evaluation for a layout + + @param uid: Owner of graph + @param gid: Name of graph + @param layout_name: Name of layout + @param layout_owner: Owner of layout + @param evaluation: Evaluation of layout + ''' + + db_session = data_connection.new_session() + + layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).first() + + # If layout doesn't exist, return + if layout == None: + return + + # If it's an expert, than delete the task at once without paying worker + if expert: + # Add this evaluation to database + layout_eval = models.LayoutStatus(id=None, graph_id=gid, user_id=uid, layout_id=layout.layout_id, triangle_rating=triangle_rating, rectangle_rating=rectangle_rating, shape_rating=shape_rating, color_rating=color_rating, created=datetime.now(), submitted_by="EXPERT_WORKER") + db_session.add(layout_eval) + + task = db_session.query(models.Task).filter(models.Task.graph_id == gid).filter(models.Task.user_id == uid).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.worker_id == "EXPERT_WORKER").first() + db_session.delete(task) + db_session.commit() + db_session.close() + return "Done" + + # Add this evaluation to database + layout_eval = models.LayoutStatus(id=None, graph_id=gid, user_id=uid, layout_id=layout.layout_id, triangle_rating=triangle_rating, rectangle_rating=rectangle_rating, shape_rating=shape_rating, color_rating=color_rating, created=datetime.now(), submitted_by="MTURK_Worker") + + db_session.add(layout_eval) + db_session.commit() + + # Get the task associated for this graph + task = db_session.query(models.Task).filter(models.Task.hit_id == hit_id).filter(models.Task.task_type == "APPROVE_TASK").first() + + if task == None: + return None + + submit = task.submitted + + # If layout has had 5 people look at it, then delete the task, otherwise increment submission count + if task.submitted == 5: + db_session.delete(task) + else: + task.submitted = submit + 1 + + db_session.commit() + + task_code = db_session.query(models.TaskCode.code).filter(models.TaskCode.hit_id == hit_id).first() + db_session.close() + + return task_code + +def get_crowd_layouts_for_graph(uid, gid): + ''' + Gets all the layouts submitted by crowdworkers. + @param uid: Owner of graph + @param gid: Name of graph + ''' + # Get database connection + db_session = data_connection.new_session() + + try: + # Get all the layouts for this graph. + crowd_layouts = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.owner_id == uid).all() + db_session.close() + return crowd_layouts + except NoResultFound: + db_session.close() + return [] + +def retrieve_cytoscape_json(graphjson): + ''' + Converts JSON to CytoscapeJS standards + + :param graphjson: JSON of graph to render on CytoscapeJS + :return JSON: CytoscapeJS-compatible graphname + ''' + + temp_json = json.loads(graphjson)['graph'] + + # for Cytoscape.js, if data is in properties, then we need to convert (main difference) + if 'data' in temp_json: + return convert_json(graphjson) + else: + return graphjson + +def get_base_urls(view_type): + ''' + Assigns urls to the blue buttons viewed at the graphs/ page + + :param view_type: Type of view (shared, public etc) + :return URL: Link to the specified view_type + ''' + + # Modify the url of the buttons depending on the page that the user is on + if view_type == 'shared': + return URL_PATH + "graphs/shared/" + elif view_type == 'public': + return URL_PATH + "graphs/public/" + elif view_type == 'all': + return URL_PATH + "graphs/all/" + else: + return URL_PATH + "graphs/" + +def get_all_info_for_graph(uid, gid): + ''' + Returns JSON, public, and graph id of the graph + + @param uid: Owner of graph + @param graph_id: ID of graph + ''' + # Get the graph + graph = get_graph(uid, gid) + + if graph == None: + return None + + graph.json = verify_json(graph.json) + + return (graph.json, graph.public, graph.graph_id) + +def get_graphs_for_view_type(context, view_type, uid, request): + ''' + Gets the graphs that are associated with a certain view from the user + + :param context: Dictionary containing values to pass to front-end + :param view_type: Type of view to render (my graphs, shared, public) + :param uid: Owner of the graph + :param request: Get request + :return context: Dictionary containing values to pass to front-end + ''' + + # Lists to hold all tag terms and search terms that are beign queried + tag_list = [] + search_list = [] + + # Keep track of type of search that user specified + search_type = None + + # Partial search may be thought of as "contains" matching + # Exact search may be though of as "identical" matching + if 'partial_search' in request.GET: + search_type = 'partial_search' + elif 'full_search' in request.GET: + search_type = 'full_search' + + # Get search terms from query + search_terms = request.GET.get(search_type) + + # Get tag terms from query + tag_terms = request.GET.get('tags') or request.GET.get('tag') + + # Get ordered terms for query (ordered being if they want to sort table by its columns) + order_by = request.GET.get('order') + + # Extract tags from query + if tag_terms and len(tag_terms) > 0: + cleaned_tags = tag_terms.split(',') + client_side_tags = "" + # Goes through each tag, making it a string + # so the url will contain those tags as a part + # of the query string + for tags in xrange(len(cleaned_tags)): + cleaned_tags[tags] = cleaned_tags[tags].strip() + # If user enters in a blank tag, delete it + if len(cleaned_tags[tags]) == 0: + del cleaned_tags[tags] + # Multiple tags are distinguished by commas, so we add them here + client_side_tags = client_side_tags + cleaned_tags[tags] + ',' + + # Remove the last comma since nothing comes after last tag + client_side_tags = client_side_tags[:len(client_side_tags) - 1] + + # Set tags to the cleaned tags we formulated from the query + # This is done to append to URL of the different view_types we can have + # For example: buttons containing My Graphs, Shared, and Public will + # have query string of tags appended to end of URL + # This happens in front-end (See graphs/templates/graphs/graphs.html) + context['tags'] = client_side_tags + + # This is for the side menu, each tag has its own button + context['tag_terms'] = cleaned_tags + + # Cleaned list of tags ready to be queried in view_graphs method + tag_list = cleaned_tags + + # Extract search terms from query + if search_terms and len(search_terms) > 0: + # Set to true so that front-end will know to apend + # a search term to all views (My Graphs, Shared, Public) + context['search_result'] = True + + # Split up search terms by comma + cleaned_search_terms = search_terms.split(',') + + # Search string to formulate that will contain all search terms + client_side_search = "" + + # Goes through each search term, making it a string + # so the url will contain those searches as a part + # of the query string + for i in xrange(len(cleaned_search_terms)): + cleaned_search_terms[i] = cleaned_search_terms[i].strip() + # Deleted no length search terms + if len(cleaned_search_terms[i]) == 0: + del cleaned_search_terms[i] + + # This is for the side menu, each search item has its own button + client_side_search = client_side_search + cleaned_search_terms[i] + ',' + + # Remove last comma + client_side_search = client_side_search[:len(client_side_search) - 1] + + # All context variables will be recognized in the front end + # See (See graphs/templates/graphs/graphs.html) + context['search_word'] = client_side_search + + # Type of search (partial or exact) -> Used to fill in radio button + context['search_type'] = search_type + + # Search terms (Used to append URL to view types: My Graphs, Shared, Public) + context['search_word_terms'] = cleaned_search_terms + + # Cleaned list of search terms to be queried on + search_list = cleaned_search_terms + + # If there is no one logged in, display only public graph results + # my_graphs represents all matching graphs which I own + # shared_graphs represents all matching graphs which are shared with me + # public graphs represent all matching graphs available to everyone + + # In order to produce the number of graphs returned that match the query + # (for the My Graphs, Shared, and Public buttons), I am also retrieving the len + # of matched graphs for each view_type. This feature was requesed by Murali + + # For every query, we need to make request for all the view types (shared, public, my graphs) + # because we want to notify the user the number of graphs that are available for each view + # that matches the queries + if uid == None: + context['graph_list'] = view_graphs(uid, search_type, search_terms, tag_list, 'public') + context['my_graphs'] = 0 + context['shared_graphs'] = 0 + if context['graph_list'] == None: + context['public_graphs'] = 0 + else: + context['public_graphs'] = len(context['graph_list']) + else: + if view_type == 'my graphs': + context['graph_list'] = view_graphs(uid, search_type, search_list, tag_list, view_type) + context['my_graphs'] = len(context['graph_list']) + context['shared_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'shared')) + context['public_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'public')) + elif view_type == 'shared': + context['graph_list'] = view_graphs(uid, search_type, search_list, tag_list, view_type) + context['my_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'my graphs')) + context['shared_graphs'] = len(context['graph_list']) + context['public_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'public')) + else: + context['graph_list'] = view_graphs(uid, search_type, search_list, tag_list, view_type) + context['my_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'my graphs')) + context['shared_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'shared')) + context['public_graphs'] = len(context['graph_list']) + + #If user has requested the graphs to be ordered in a certain manner, order them as requested + if order_by: + context['graph_list'] = order_information(order_by, search_terms, context['graph_list']) + else: + # By default, all graphs are ordered via descending modified date (as per Anna's request) + context['graph_list'] = order_information("modified_descending", search_terms, context['graph_list']) + + return context + +def setDefaultLayout(layoutName, graph_id, graph_owner): + ''' + Sets default layout of graph. + + @param layoutName: name of layout + @param graph_id: ID of graph + @param graph_owner: Owner of graph + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Check to see if graph exists + graph = db_session.query(models.Graph).filter(models.Graph.user_id == graph_owner).filter(models.Graph.graph_id == graph_id).first() + + if graph == None: + return "It appears as if the graph requested does not exist." + + # Check to see if the layout is either shared or exists in the database + layout = db_session.query(models.Layout).filter(models.Layout.graph_id == graph_id).filter(models.Layout.layout_name == layoutName).filter(or_(models.Layout.shared_with_groups == 1, models.Layout.public == 1)).first() + + if layout == None: + return "You can't set a layout as default layout for graph unless layout is shared and the graph is public!" + + # Update the default layout of the current graph + graph.default_layout_id = layout.layout_id + + db_session.commit() + db_session.close() + return None + except NoResultFound: + db_session.close() + return "Can't set default layout of layout that doesn't exist or you can't access." + +def removeDefaultLayout(layoutName, graph_id, graph_owner): + ''' + Removes default layout of graph. + + @param layoutName: name of layout + @param graph_id: ID of graph + @param graph_owner: Owner of graph + ''' + # Create database connection + db_session = data_connection.new_session() + + # Get graph being viewed + graph = db_session.query(models.Graph).filter(models.Graph.graph_id == graph_id).filter(models.Graph.user_id == graph_owner).first() + + if graph == None: + return "Graph does not exist!" + + # Get the layout to see if it exists + layout = db_session.query(models.Layout).filter(models.Layout.layout_id == models.Graph.default_layout_id).first() + + if layout == None: + return "Layout does not exist for this graph!" + + # If the default layout is deleted, update + # graph so that it has no default layout + graph.default_layout_id = None + db_session.commit() + db_session.close() + return None + + +def order_information(order_term, search_terms, graphs_list): + ''' + Orders all graph tuples based on order_term. + + :param order_term Term to order by (example, graph, modified, owner) + :param search_terms Search terms in query (Needed because all search terms add two column (link to graph and node labels) which offsets references by 2) + :param graph_list Tuples of graphs + :return sorted_list Sorted list of graph tuples according to order_term + ''' + # Each order_term corresponds to sortable columns in the graph tables + if search_terms: + if order_term == 'graph_ascending': + return sorted(graphs_list, key=lambda graph: graph[0]) + elif order_term == 'graph_descending': + return sorted(graphs_list, key=lambda graph: graph[0], reverse=True) + elif order_term == 'modified_ascending': + return sorted(graphs_list, key=lambda graph: graph[4]) + elif order_term == 'modified_descending': + return sorted(graphs_list, key=lambda graph: graph[4], reverse=True) + elif order_term == 'owner_ascending': + return sorted(graphs_list, key=lambda graph: graph[2]) + elif order_term == 'owner_descending': + return sorted(graphs_list, key=lambda graph: graph[2], reverse=True) + else: + return graphs_list + else: + if order_term == 'graph_ascending': + return sorted(graphs_list, key=lambda graph: graph.graph_id) + elif order_term == 'graph_descending': + return sorted(graphs_list, key=lambda graph: graph.graph_id, reverse=True) + elif order_term == 'modified_ascending': + return sorted(graphs_list, key=lambda graph: graph.modified) + elif order_term == 'modified_descending': + return sorted(graphs_list, key=lambda graph: graph.modified, reverse=True) + elif order_term == 'owner_ascending': + return sorted(graphs_list, key=lambda graph: graph.user_id) + elif order_term == 'owner_descending': + return sorted(graphs_list, key=lambda graph: graph.user_id, reverse=True) + else: + return graphs_list + +def view_graphs(uid, search_type, search_terms, tag_terms, view_type): + ''' + Gets the graphs that are associated with a certain view from the user + + :param uid: Owner of the graph + :param search_type: Type of search (partial or full) + :param search_terms: Criteria that to filter graphs + :param tag_terms: Only display graphs with these tags + :return context: Dictionary containing values to pass to front-end + ''' + + # If there are graphs that fit search and tag criteria + if search_terms and tag_terms and len(search_terms) > 0 and len(tag_terms) > 0: + actual_graphs = [] + + # Get all graphs that contain all the search terms + search_result_graphs = search_result(uid, search_type, search_terms, view_type) + + # Get all graphs that contain all the tag terms + tag_result_graphs = tag_result(uid, tag_terms, view_type) + + tag_graphs = [x[0] for x in tag_result_graphs] + actual = [x[0] for x in actual_graphs] + + # If it is not already part of final graphs returned, add it in + for graph in search_result_graphs: + if graph[0] in tag_graphs and graph[0] not in actual: + actual_graphs.append(graph) + + return actual_graphs + + # If there are only tag terms + elif tag_terms and len(tag_terms) > 0: + return tag_result(uid, tag_terms, view_type) + # If there are only search terms + elif search_terms and len(search_terms) > 0: + return search_result(uid, search_type, search_terms, view_type) + # Just display the graphs + else: + return view_graphs_of_type(view_type, uid) + +def tag_result(uid, tag_terms, view_type): + ''' + Gets all graphs that contain the specified tags for a user and a view_type. + + :param uid: Owner of graph + :param tag_terms: Tags that all graphs must contain + :param view_type: Type of view to display the graphs in (shared, public) + :return Graphs: [graphs] + ''' + if len(tag_terms) > 0: + # Place holder that stores all the graphs + initial_graphs_with_tags = [] + + # Create database connection + db_session = data_connection.new_session() + + # Go through all the tag terms, based on the view type and append them the initial place holder + for tag in tag_terms: + intial_graphs_with_tags = [] + + if view_type == 'my graphs': + try: + intial_graphs_with_tags += db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).filter(models.Graph.graph_id == models.GraphToTag.graph_id).filter(models.Graph.user_id == models.GraphToTag.user_id).filter(models.GraphToTag.tag_id == tag).filter(models.Graph.user_id == uid).all() + except NoResultFound: + print 'No graphs that you own match the tag term' + + elif view_type == 'shared': + + # Append all graphs that are shared with groups that the user is a member of + intial_graphs_with_tags += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.modified, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == models.GroupToUser.group_id).filter(models.GroupToGraph.group_owner == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == uid).filter(models.GraphToTag.tag_id == tag).filter(models.GraphToTag.graph_id == models.GroupToGraph.graph_id).filter(models.GraphToTag.user_id == models.GroupToGraph.user_id).all() + # Append all graphs that the user shared for any groups that they own + intial_graphs_with_tags += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.modified, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == models.Group.group_id).filter(models.Group.owner_id == uid).filter(models.GroupToGraph.group_owner == models.Group.owner_id).filter(models.GraphToTag.tag_id == tag).filter(models.GraphToTag.graph_id == models.GroupToGraph.graph_id).filter(models.GraphToTag.user_id == models.GroupToGraph.user_id).all() + else: + try: + intial_graphs_with_tags += db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).filter(models.Graph.graph_id == models.GraphToTag.graph_id).filter(models.Graph.user_id == models.GraphToTag.user_id).filter(models.GraphToTag.tag_id == tag).filter(models.Graph.public == 1).all() + except NoResultFound: + print 'No graphs that you own match the tag term' + + # Go through and count the list of occurrences of matched graph + graph_repititions = defaultdict(int) + + # Counting the number of occurences + for graph in intial_graphs_with_tags: + graph_repititions[graph] += 1 + + # Go through and aggregate all graph together + graph_mappings = defaultdict(list) + + # If the number of times a graph appears matches the number of search terms + # it is a graph we want (simulating the and operator for all search terms) + for graph in intial_graphs_with_tags: + + # Graph matches all search terms + if graph_repititions[graph] == len(tag_terms): + + # If we haven't seen this graph yet + if graph not in graph_mappings: + graph_mappings[graph] = graph + + # Go through all the graphs and insert tags for the graphs that match all search terms + return graph_mappings.values() + + else: + return [] + +def search_result(uid, search_type, search_terms, view_type): + ''' + Returns the graphs that match the search terms and the view type. + + :param uid: Owner of the graph + :param search_type: Type of search to perform (partial or full) + :param search_terms: Terms to search for + :param view_type: Type of view to render the graphs for + :return Graphs: [graphs] + ''' + + # If it is a search type that is not recognized, return empty list + if search_type != 'partial_search' and search_type != 'full_search': + return [] + + # Make into list if it is not a lsit + if not isinstance(search_terms, list): + search_terms = search_terms.split(',') + + # If there are any search terms + if len(search_terms) > 0: + + # List to keep track of all matched graphs + initial_graphs_from_search = [] + + # Get connection to database + data_session = data_connection.new_session() + + # Go through each search term, aggregating + # all graphs that match the specific search term + for search_word in search_terms: + # matched_graphs contains a list of all graphs that match the specific search term + matched_graphs = [] + # First, we check to see if there are any graphs that have a graph name that matches the search term + matched_graphs += find_all_graphs_containing_search_word(uid, search_type, search_word, view_type, data_session) + + # ":" indicates that search_word may be an edge + if ':' in search_word: + # append all graphs that contain an edge which matches the search_word + matched_graphs += find_all_graphs_containing_edges(uid, search_type, search_word, view_type, data_session) + # otherwise append all graphs that contain a node which matches the search word + else: + matched_graphs += find_all_graphs_containing_nodes(uid, search_type, search_word, view_type, data_session) + + # Go through all matched graphs + # If there is a graph that appears multiple times in the list + # combine their result. + # Effectively, a graph may appear at most one time for each search word + matched_graphs = combine_similar_graphs(matched_graphs) + + # Add condensed tuples to list of graphs matched + initial_graphs_from_search += matched_graphs + + # Go through and count the list of occurrences of matched graph + graph_repititions = defaultdict(int) + + # Counting the number of occurences + for graph_tuple in initial_graphs_from_search: + key = graph_tuple[0] + graph_tuple[4] + graph_repititions[key] += 1 + + # Go through and aggregate all graph together + graph_mappings = defaultdict(list) + + # If the number of times a graph appears matches the number of search terms + # it is a graph we want (simulating the and operator for all search terms) + for graph_tuple in initial_graphs_from_search: + key = graph_tuple[0] + graph_tuple[4] + + graph_tuple = list(graph_tuple) + + # Placeholder for tags of the graph + graph_tuple.insert(1, "") + + # Graph matches all search terms + if graph_repititions[key] == len(search_terms): + + # If we haven't seen this graph yet + if key not in graph_mappings: + graph_mappings[key] = tuple(graph_tuple) + else: + # Combine result of previous tuple + old_tuple = list(graph_mappings[key]) + + # If there is already a matching node/edge id + if len(old_tuple[2]) > 0 and len(graph_tuple[2]) > 0: + old_tuple[2] += ", " + graph_tuple[2] + old_tuple[3] += ", " + graph_tuple[3] + # Otherwise, simply insert this graph tuples id + else: + old_tuple[2] += graph_tuple[2] + old_tuple[3] += graph_tuple[3] + + graph_mappings[key] = tuple(old_tuple) + + # Go through all the graphs and insert tags for the graphs that match all search terms + return graph_mappings.values() + else: + return [] + +def combine_similar_graphs(matched_graphs): + ''' + Go through list of all matched graphs and combine results if graph appears multiple times. + + @param matched_graphs: List of graphs, nodes, edges that all have reference to graph id via respective models (SQLAlchemy) + ''' + graph_entry = dict() + + # Go through all the matching graphs/nodes/edges depending on the type of match + for graph in matched_graphs: + # If graph contains a matching node + if hasattr(graph, 'node_id'): + key = graph.graph_id + graph.user_id + # If graph has not been encountered yet, insert new tuple + if key not in graph_entry: + # Construct new entry + new_graph_entry = (graph.graph_id, graph.node_id + "(" + graph.label + ")", graph.label, graph.modified, graph.user_id) + + # Add to dict + graph_entry[key] = new_graph_entry + else: + # If graph has been discovered, append node id details to graph tuple + cur_graph_entry = list(graph_entry[key]) + if len(cur_graph_entry[1]) == 0: + cur_graph_entry[1] += graph.node_id + "(" + graph.label + ")" + cur_graph_entry[2] += graph.label + else: + cur_graph_entry[1] += ", " + graph.node_id + "(" + graph.label + ")" + cur_graph_entry[2] += ", " + graph.label + + # Add modified entry + graph_entry[key] = tuple(cur_graph_entry) + + # If graph contains a matching edge + elif hasattr(graph, 'head_node_id'): + key = graph.graph_id + graph.user_id + + # If graph has been encountered yet, insert new tuple + if key not in graph_entry: + graph_info = get_graph(graph.user_id, graph.graph_id) + + # Construct new entry + new_graph_entry = (graph.graph_id, graph.edge_id + "(" + graph.head_node_id + "-" + graph.tail_node_id + ")", graph.edge_id, graph_info.modified, graph_info.user_id, graph_info.public) + + # Add to dict + graph_entry[key] = new_graph_entry + else: + # If graph already has been encountered + cur_graph_entry = list(graph_entry[key]) + if len(cur_graph_entry[1]) == 0: + cur_graph_entry[1] += graph.edge_id + "(" + graph.head_node_id + "-" + graph.tail_node_id + ")" + cur_graph_entry[2] += graph.edge_id + else: + cur_graph_entry[1] += ", " + graph.edge_id + "(" + graph.head_node_id + "-" + graph.tail_node_id + ")" + cur_graph_entry[2] += ", " + graph.edge_id + + # Add appended entry + graph_entry[key] = tuple(cur_graph_entry) + + # If graph contains a term that is in the id of the graph + else: + key = graph.graph_id + graph.user_id + # If graph has not yet been encountered, append tuple to list of graphs encountered + if key not in graph_entry: + # Create new entry + new_graph_entry = (graph.graph_id, "", "", graph.modified, graph.user_id) + + # Add new entry to dict + graph_entry[key] = new_graph_entry + + return graph_entry.values() + +def find_all_graphs_containing_search_word(uid, search_type, search_word, view_type, db_session): + ''' + Finds graphs that have the matching graph name. + + :param uid: Owner of the graph + :param search_type: Type of search (full_search or partial_search) + :param search_word: Graph names being searched for + :param view_type: Type of view to limit the graphs to (my graphs, shared, public) + :param cur: Database cursor + :return Graphs: [Graphs] + ''' + matched_graphs = [] + # Return all graphs that have a graph name that partially matches the search word + if search_type == 'partial_search': + # Select graphs that match the given view type + if view_type == "my graphs": + matched_graphs = db_session.query(models.Graph.graph_id, models.Graph.user_id, models.Graph.modified).filter(models.Graph.graph_id.like("%" + search_word + "%")).filter(models.Graph.user_id == uid).all() + elif view_type == "shared": + matched_graphs = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.group_id == models.GroupToUser.group_id).filter(models.GroupToGraph.group_owner == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == uid).filter(models.GroupToGraph.graph_id.like("%" + search_word + "%")).all() + matched_graphs += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.graph_id.like("%" + search_word + "%")).filter(models.Group.owner_id == uid).filter(models.Group.group_id == models.GroupToGraph.group_id).filter(models.Group.owner_id == models.GroupToGraph.group_owner).all() + elif view_type == "public": + matched_graphs = db_session.query(models.Graph.graph_id, models.Graph.user_id, models.Graph.modified, models.Graph.public).filter(models.Graph.graph_id.like("%" + search_word + "%")).filter(models.Graph.public == 1).all() + + # Return all graphs that have a gaph name that exactly matches the search word + elif search_type == 'full_search': + # Select graphs that match the given view type + if view_type == "my graphs": + matched_graphs = db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id, models.Graph.public).filter(models.Graph.graph_id == search_word).filter(models.Graph.user_id == uid).all() + elif view_type == "shared": + matched_graphs = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.group_id == models.GroupToUser.group_id).filter(models.GroupToGraph.group_owner == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == uid).filter(models.GroupToGraph.graph_id == search_word).all() + matched_graphs += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.graph_id == search_word).filter(models.Group.owner_id == uid).filter(models.Group.group_id == models.GroupToGraph.group_id).filter(models.Group.owner_id == models.GroupToGraph.group_owner).all() + elif view_type == "public": + matched_graphs = db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id, models.Graph.public).filter(models.Graph.graph_id == search_word).filter(models.Graph.public == 1).all() + + graph_dict = dict() + + # Remove duplicates for all graphs that match have the same graph matching search term + for graph in matched_graphs: + key = graph.graph_id + graph.user_id + if key in graph_dict: + continue + else: + graph_dict[key] = graph + + return graph_dict.values() + +def find_all_graphs_containing_edges(uid, search_type, search_word, view_type, db_session): + ''' + Finds graphs that have the edges that are being searched for. + + :param uid: Owner of the graph + :param search_type: Type of search (partial_search or full_search) + :param search_word: Edge being searched for + :param view_type: Type of view to limit the graphs to + :param cur: Database cursor + :return Edges: [Edges] + ''' + + # List to keep track of all graphs that contain edges that match the search_word + initial_graphs_matching_edges = [] + + # Separate the edge into its two node ID's + # This is done because in the database, an edge ID is comprised of target:source nodes + node_ids = search_word.split(":") + + # Get head and tail node references + head_node = node_ids[0] + tail_node = node_ids[1] + + # List of all head node ids + head_nodes = [] + + # List of all tail node ids + tail_nodes = [] + + # Match all edges that contain the edges that exactly match the search_word + # TODO: Currently the match queries are case sensitive. In future we would like to make these queries case insensitive. + if search_type == "full_search": + + search_query = or_( + and_(models.Edge.head_node_id == head_node, models.Edge.tail_node_id == tail_node), + and_(models.Edge.tail_node_id == head_node, models.Edge.head_node_id == tail_node), + and_(models.Edge.head_node_label == head_node, models.Edge.tail_node_label == tail_node), + and_(models.Edge.tail_node_label == head_node, models.Edge.head_node_label == tail_node)) + + elif search_type == "partial_search": + + search_query = or_( + and_(models.Edge.head_node_id >= head_node, models.Edge.head_node_id <= head_node + 'zzz', + models.Edge.tail_node_id >= tail_node, models.Edge.tail_node_id <= tail_node + "zzz"), + and_(models.Edge.tail_node_id >= head_node, models.Edge.tail_node_id <= head_node + "zzz", + models.Edge.head_node_id >= tail_node, models.Edge.head_node_id <= tail_node + "zzz"), + and_(models.Edge.head_node_label >= head_node, models.Edge.head_node_label <= head_node + 'zzz', + models.Edge.tail_node_label >= tail_node, models.Edge.tail_node_label <= tail_node + "zzz"), + and_(models.Edge.tail_node_label >= head_node, models.Edge.tail_node_label <= head_node + 'zzz', + models.Edge.head_node_label >= tail_node, models.Edge.head_node_label <= tail_node + "zzz")) + + + # # Go through head and tail nodes to see if there are any graphs + # # that match the given view type (my graphs, shared, public). + # # In other words, return all graphs that having matching edges + # # for the given view type. + # + # # TODO: ASK MURALI ABOUT BIDIRECTION EDGES + # + + if view_type == "public": + initial_graphs_matching_edges = db_session.query( models.Edge ).filter(search_query).filter(models.Edge.graph_id == models.Graph.graph_id ).filter( models.Graph.public == 1 ).all() + elif view_type == "shared": + initial_graphs_matching_edges += db_session.query(models.Edge).filter(models.GroupToGraph.user_id == uid).filter(models.Edge.graph_id == models.GroupToGraph.graph_id).filter(search_query).filter(models.Edge.graph_id == models.Graph.graph_id).all() + else: + initial_graphs_matching_edges += db_session.query(models.Edge).filter(search_query).filter(models.Edge.graph_id == models.Graph.graph_id).filter(models.Edge.user_id == uid).all() + + graph_dict = dict() + + # Remove duplicates for all graphs that match have the same edge matching search term + for edge in initial_graphs_matching_edges: + key = edge.head_node_id + edge.graph_id + edge.user_id + edge.tail_node_id + edge.edge_id + if key in graph_dict: + continue + else: + graph_dict[key] = edge + + return graph_dict.values() + # else: + # return [] + +def find_all_graphs_containing_nodes(uid, search_type, search_word, view_type, db_session): + ''' + Finds graphs that have the nodes that are being searched for. + + :param uid: Owner of the graph + :param search_type: Type of search (partial_search or full_search) + :param search_word: Node being searched for + :param view_type: Type of view to limit the graphs to + :param db_session: Database session + :return Nodes: [Nodes] + ''' + + # Graphs that contained nodes matching the search_word + initial_graphs_matching_nodes = [] + + # If search type wants to partially match node + if view_type == "my graphs": + + if search_type == "partial_search": + # Get all partially matching nodes containing the label + initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label.like("%" + search_word + "%")).filter(models.Node.user_id == uid).all() + + # Get all partially matching nodes containing the node id + initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id.like("%" + search_word + "%")).filter(models.Node.user_id == uid).all() + else: + # Get all partially matching nodes containing the label + initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label == search_word).filter(models.Node.user_id == uid).all() + + # Get all partially matching nodes containing the node id + initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id == search_word).filter(models.Node.user_id == uid).all() + + # Shared graphs + elif view_type == "shared": + # Get all the groups that a user is a member of + groups_user_belongs_to = db_session.query(models.GroupToUser.group_id, models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == uid).all() + + # Get all graphs that are part of groups that the user belongs to + graphs_in_group = list() + + # Go through each group and add graphs keys to the set + for single_group in groups_user_belongs_to: + group_id = single_group.group_id + group_owner = single_group.group_owner + + graphs_in_group += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == group_id).filter(models.GroupToGraph.group_owner == group_owner).all() + + # Go through all groups that the user owns + groups_user_owns = db_session.query(models.Group).filter(models.Group.owner_id == uid).all() + + for single_group in groups_user_owns: + graphs_in_group += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == single_group.group_id).filter(models.GroupToGraph.group_owner == single_group.owner_id).all() + + if search_type == "partial_search": + # Get all graphs that contain a partially matched label and user does not own (since it's shared) + all_matched_node_graphs = db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label.like("%" + search_word + "%")).all() + + # Collect all graphs that are shared with user and matches terms + final_graphs = [] + + # Go through all matched graphs to see which graphs + # are also shared with user and take the intersection + for matched in all_matched_node_graphs: + search_graph = (matched.graph_id, matched.user_id) + if search_graph in graphs_in_group: + final_graphs.append(matched) + + # Get all graphs that contain a partially matched node and user does not own (since it's shared) + all_matched_node_graphs = db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id.like("%" + search_word + "%")).all() + + # Go through all matched graphs to see which graphs + # are also shared with user and take the intersection + for matched in all_matched_node_graphs: + search_graph = (matched.graph_id, matched.user_id) + if search_graph in graphs_in_group: + final_graphs.append(matched) + + initial_graphs_matching_nodes = final_graphs + else: + # Get all graphs that contain a partially matched label and user does not own (since it's shared) + all_matched_node_graphs = db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label == search_word).all() + + # Collect all graphs that are shared with user and matches terms + final_graphs = [] + + # Go through all matched graphs to see which graphs + # are also shared with user and take the intersection + for matched in all_matched_node_graphs: + search_graph = (matched.graph_id, matched.user_id) + if search_graph in graphs_in_group: + final_graphs.append(matched) + + # Get all graphs that contain a partially matched node and user does not own (since it's shared) + all_matched_node_graphs = db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id == search_word).all() + + # Go through all matched graphs to see which graphs + # are also shared with user and take the intersection + for matched in all_matched_node_graphs: + search_graph = (matched.graph_id, matched.user_id) + if search_graph in graphs_in_group: + final_graphs.append(matched) + + initial_graphs_matching_nodes = final_graphs + # public graphs + else: + if search_type == "partial_search": + # Get all partially matching nodes containing the label + initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label.like("%" + search_word + "%")).filter(models.Node.graph_id == models.Graph.graph_id).filter(models.Node.user_id == models.Graph.user_id).filter(models.Graph.public == 1).all() + + # Get all partially matching nodes containing the node id + initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id.like("%" + search_word + "%")).filter(models.Node.graph_id == models.Graph.graph_id).filter(models.Node.user_id == models.Graph.user_id).filter(models.Graph.public == 1).all() + else: + # Get all partially matching nodes containing the label + initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label == search_word).filter(models.Node.graph_id == models.Graph.graph_id).filter(models.Node.user_id == models.Graph.user_id).filter(models.Graph.public == 1).all() + + # Get all partially matching nodes containing the node id + initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id == search_word).filter(models.Node.graph_id == models.Graph.graph_id).filter(models.Node.user_id == models.Graph.user_id).filter(models.Graph.public == 1).all() + + graph_dict = dict() + + # Remove duplicates for all graphs that match have the same node id and label matching search term + for graph in initial_graphs_matching_nodes: + key = graph.graph_id + graph.user_id + graph.label + graph.node_id + if key in graph_dict: + continue + else: + graph_dict[key] = graph + + return graph_dict.values() + +def uploadCyjsFile(username, graphJSON, title): + ''' + Uploads a .cyjs file as a JSON via /upload. + + @param username: Owner of graph + @param graphJSON: CYJS of graph + @param tile: Title of graph + ''' + has_node_positions = True + try: + # Create JSON stucture for GraphSpace recognized JSON + parseJson = {"graph": {"edges": [], "nodes": []}, "metadata": {}} + + # Load JSON from string + csjs = json.loads(graphJSON) + + # If there is no elements that exist in the provided JSON + if 'elements' not in csjs: + return {"Error": "No elements property inside of file!"} + + # If there is no nodes that exist in the provided JSON + if 'nodes' not in csjs['elements']: + return {"Error": "File must contain nodes property in elements dictionary!"} + + # If there is no edges that exist in the provided JSON + if 'edges' not in csjs['elements']: + return {"Error": "File must contain edges property in elements dictionary!"} + + node_positions = [] + # Go through nodes and translate properties so CytoscapeJS may render + for node in csjs['elements']['nodes']: + + # Container for translated node + tempNode = {"data": {}} + + # Copy over ID + tempNode['data']['id'] = node['data']['id'] + + if 'position' in node and 'x' in node['position'] and 'y' in node['position']: + tempNode['data']['x'] = node['position']['x'] + tempNode['data']['y'] = node['position']['y'] + node_positions.append({ + 'x': node['position']['x'], + 'y': node['position']['y'], + 'id': node['data']['id'] + }) + else: + has_node_positions = False + + # Change color property to background color + if 'node_fillColor' in node['data'] and len(node['data']['node_fillColor']) > 0: + # tempNode['data']['background_color'] = rgb_to_hex(node['data']['node_fillColor']) + tempNode['data']['background_color'] = node['data']['node_fillColor'] + + # If user wants to display something in node, add 'content' + if 'name' in node['data']: + tempNode['data']['content'] = node['data']['name'] + + # No shape is provided as far as I know, so I pad in an ellipse + tempNode['data']['shape'] = "ellipse" + parseJson['graph']['nodes'].append(tempNode) + + # Go through all the edges + for edge in csjs['elements']['edges']: + + tempEdge = {"data": {}} + + # Copy over source and target + tempEdge['data']['source'] = edge['data']['source'] + tempEdge['data']['target'] = edge['data']['target'] + + # If there is a name property, it will be in a popup + if 'name' in edge['data']: + tempEdge['data']['popup'] = edge['data']['name'] + + # Add edges to json + parseJson['graph']['edges'].append(tempEdge) + + # If there is a title in the graph + if 'name' in csjs['data']: + parseJson['metadata']['name'] = csjs['data']['name'] + else: + parseJson['metadata']['name'] = "temp_graph" + + # No tags or description since CYJS doesn't give me any + parseJson['metadata']['tags'] = [] + parseJson['metadata']['description'] = "" + + title = title or parseJson['metadata']['name'] + + # Insert converted graph to GraphSpace and provide URL + # for logged in user + + if username != None: + result = insert_graph(username, title, json.dumps(parseJson)) + if result == None: + if has_node_positions: + save_layout(title, username, 'cyjs_layout', username, json.dumps(node_positions), False, False, default=True) + return {"Success": URL_PATH + "graphs/" + username + "/" + title} + else: + return {"Error": result} + else: + # Create a unique user and insert graph for that name + public_user_id = "Public_User_" + str(uuid.uuid4()) + '@temp.com' + public_user_id = public_user_id.replace('-', '_') + + first_request = create_public_user(public_user_id) + + if first_request == None: + import time + start = time.time() + result = insert_graph(public_user_id, title, json.dumps(parseJson)) + if result == None: + if has_node_positions: + save_layout(title, public_user_id, 'cyjs_layout', public_user_id, json.dumps(node_positions), False, False, default=True) + return {"Success": URL_PATH + "graphs/" + public_user_id + "/" + title} + else: + return {"Error": result} + else: + return {"Error": result} + except Exception as ex: + return {"Error": "Seems to be an error with " + ex.message + " property."} + +def uploadJSONFile(username, graphJSON, title): + ''' + Uploads JSON file to GraphSpace via /upload. + + @param username: Owner of graph + @param graphJSON: JSON of graph + @param title: Title of graph + + ''' + + try: + # Loads JSON format + parseJson = json.loads(graphJSON) + + # Creates metadata tag + if 'metadata' not in parseJson: + parseJson['metadata'] = {} + + # If name is not provided, name is data + if 'name' not in parseJson['metadata']: + parseJson['metadata']['name'] = "graph_" + str(datetime.now()) + + title = title or parseJson['metadata']['name'] + + # Insert converted graph to GraphSpace and provide URL + # for logged in user + if username != None: + result = insert_graph(username, title, json.dumps(parseJson)) + if result == None: + return {"Success": URL_PATH + "graphs/" + username + "/" + title} + else: + return {"Error": result} + else: + # Create a unique user and insert graph for that name + public_user_id = "Public_User_" + str(uuid.uuid4()) + '@temp.com' + public_user_id = public_user_id.replace('-', '_') + + first_request = create_public_user(public_user_id) + + if first_request == None: + result = insert_graph(public_user_id, title, json.dumps(parseJson)) + if result == None: + return {"Success": URL_PATH + "graphs/" + public_user_id + "/" + title} + else: + return {"Error": result} + else: + return {"Error": result} + except Exception as ex: + return {"Error": ex} + +def delete_30_day_old_anon_graphs(): + # Create database connection + db_session = data_connection.new_session() + + # If there are any graphs owned by a public user that are older than 30 days, delete them + try: + graph = db_session.query(models.Graph).filter(models.Graph.user_id.like("%Public_User_%")).filter(models.Graph.created >= date('now', '-30 day')) + + db_session.delete(graph) + db_session.commit() + db_session.close() + except NoResultFound: + db_session.close() + +def rgb_to_hex(rgb): + # Quick wrapper method to + # convert rgb values to hex values + rgbTuple = rgb.split(',') + rgbNum = [] + for tup in rgbTuple: + try: + rgbNum.append(int(tup)) + except ValueError: + rgbNum.append(tup); + + + rgbNum = tuple(rgbNum) + return '#%02x%02x%02x' % rgbNum + +def create_public_user(public_user_id): + ''' + Creates a public user (temporary) + + @param public_user_id: Id of user + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Create a public user and add to database + public_user = models.User(user_id = public_user_id, password = "public", admin = 0) + db_session.add(public_user) + db_session.commit() + db_session.close() + return None + except NoResultFound: + db_session.close() + + +def delete_public_user(): + ''' + Deletes all public users from database. + ''' + + # Create database connection + db_session = data_connection.new_session() + + try: + # Delete all public users + public_users = db_session.query(models.User).filter(models.User.password == "public").all() + + for user in public_user: + db_session.delete(user) + + db_session.commit() + db_session.close() + + except NoResultFound: + db_session.close() + return None + +def find_edge(uid, gid, edge_to_find, search_type): + ''' + Finds the id of the edge inside graph + Used for highlighting elements inside the graph + + :param uid: Owner of graph + :param gid: Name of graph that is being viewed + :param edge_to_find: Edge that is being searched for + :param search_type: Partial or full matching + :return ID: [ID of edge] + ''' + # Create database connection + db_session = data_connection.new_session() + + # Extract nodes from input + head_node = edge_to_find.split(':')[0] + tail_node = edge_to_find.split(':')[1] + + # List containing edges + edge_list = [] + + # Filter by search type + if search_type == "partial_search": + # If there is a head and tail node + if len(head_node) > 0 and len(tail_node) > 0: + + # Find node id's that are being searched for (source and target nodes) + head_nodes = find_node(uid, gid, head_node, 'partial_search') + tail_nodes = find_node(uid, gid, tail_node, 'partial_search') + + # Go through all permutations of head and tail node + # to account for undirected edges + for i in xrange(len(tail_nodes)): + for j in xrange(len(head_nodes)): + + try: + # Aggregate all matching edges (DO THIS TWO TIMES SO ORDER OF HEAD OR TAIL NODE DOESN'T MATTER... THIS IS TO RESOLVE UNDIRECTED EDGE SEARCHING) + matching_edges = db_session.query(models.Edge).filter(models.Edge.head_node_id == head_nodes[j]).filter(models.Edge.tail_node_id == tail_nodes[i]).filter(models.Edge.user_id == uid).filter(models.Edge.graph_id == gid).all() + edge_list += matching_edges + + # # Aggregate all matching edges (DO THIS TWO TIMES SO ORDER OF HEAD OR TAIL NODE DOESN'T MATTER... THIS IS TO RESOLVE UNDIRECTED EDGE SEARCHING) + # matching_edges = db_session.query(models.Edge).filter(models.Edge.tail_node_id == head_nodes[j]).filter(models.Edge.head_node_id == tail_nodes[i]).filter(models.Edge.user_id == uid).filter(models.Edge.graph_id == gid).all() + # edge_list += matching_edges + + except NoResultFound: + print "No matching edges" + + else: + + # Find node id's that are being searched for (source and target nodes) + head_nodes = find_node(uid, gid, head_node, 'full_search') + tail_nodes = find_node(uid, gid, tail_node, 'full_search') + + # Go through all permutations of head and tail node + # to account for undirected edges + for i in xrange(len(tail_nodes)): + for j in xrange(len(head_nodes)): + + # If both nodes exist, find label between them + if tail_node != None and head_node != None: + + try: + # Aggregate all matching edges (DO THIS TWO TIMES SO ORDER OF HEAD OR TAIL NODE DOESN'T MATTER... THIS IS TO RESOLVE UNDIRECTED EDGE SEARCHING) + matching_edges = db_session.query(models.Edge).filter(models.Edge.head_node_id == head_nodes[j]).filter(models.Edge.tail_node_id == tail_nodes[i]).filter(models.Edge.user_id == uid).filter(models.Edge.graph_id == gid).all() + edge_list += matching_edges + + # # Aggregate all matching edges (DO THIS TWO TIMES SO ORDER OF HEAD OR TAIL NODE DOESN'T MATTER... THIS IS TO RESOLVE UNDIRECTED EDGE SEARCHING) + # matching_edges = db_session.query(models.Edge).filter(models.Edge.tail_node_id == head_node).filter(models.Edge.head_node_id == tail_node).filter(models.Edge.user_id == uid).filter(models.Edge.graph_id == gid).all() + # edge_list += matching_edges + + except NoResultFound: + print "No matching edges" + + # Get all labels from edges + edge_labels = [] + for edge in edge_list: + edge_labels.append(edge.edge_id) + + return edge_labels + +def find_node(uid, gid, node_to_find, search_type): + ''' + Finds the id of the node inside graph + Used for highlighting elements inside the graph + + :param uid: Owner of graph + :param gid: Name of graph that is being viewed + :param search_type: partial or full matching + :param node_to_find: Node that is being searched for + :param search_type: Partial or full matching + :return ID: [ID of node] + ''' + + # Create database connection + db_session = data_connection.new_session() + + try: + id_list = [] + # Filter by search types + if search_type == "partial_search": + + # Get all matching labels + labels = db_session.query(models.Node.node_id).filter(models.Node.label.like("%" + node_to_find + "%")).filter(models.Node.user_id == uid).filter(models.Node.graph_id == gid).all() + + # Get all matching ids + node_ids = db_session.query(models.Node.node_id).filter(models.Node.node_id.like("%" + node_to_find + "%")).filter(models.Node.user_id == uid).filter(models.Node.graph_id == gid).all() + + for label in labels: + if label not in id_list: + id_list.append(label[0]) + + for node_id in node_ids: + if node_id not in id_list: + id_list.append(node_id[0]) + + else: + # Get all matching labels + labels = db_session.query(models.Node.node_id).filter(models.Node.label == node_to_find).filter(models.Node.user_id == uid).filter(models.Node.graph_id == gid).all() + + # Get all matching ids + node_ids = db_session.query(models.Node.node_id).filter(models.Node.node_id == node_to_find).filter(models.Node.user_id == uid).filter(models.Node.graph_id == gid).all() + + # if label != None and label not in id_list: + # id_list.append(label[0]) + + # if node_id != None and node_id not in id_list: + # id_list.append(node_id[0]) + + for label in labels: + if label not in id_list: + id_list.append(label[0]) + + for node_id in node_ids: + if node_id not in id_list: + id_list.append(node_id[0]) + + db_session.close() + return id_list + except NoResultFound: + db_session.close() + return [] + +def intersect(a, b): + return list(set(a) & set(b)) + +def add_unique_to_list(listname, data): + ''' + Adds all unique items to the specified list + Also checks to see if the length is > 0 for each item + inserted into the list + + :param listname: List to put unique elements in + :param data: Possible duplicate data to search through + :return listname: [Unique elements] + ''' + for element in data: + if element not in listname and len(element) > 0: + listname.append(element) + + return listname + +# -------------------------- REST API ------------------------------- + +def insert_graph(username, graphname, graph_json, created=None, modified=None, public=0, shared_with_groups=0, default_layout_id=None): + ''' + Inserts a uniquely named graph under a username. + + :param username: Email of user in GraphSpace + :param graphname: Name of graph to insert + :param graph_json: JSON of graph + :param created: When was graph created + :param public: Is graph public? + :param shared_with_groups: Is graph shared with any groups? + :param default_layout_id: Default layout of the graph + ''' + + # Check to see if graph already exists + graph_exists = get_graph(username, graphname) + + # If graph already exists for user, alert them + if graph_exists != None: + return 'Graph ' + graphname + ' already exists for ' + username + '!' + + # Create database connection + db_session = data_connection.new_session() + + validationErrors = validate_json(graph_json) + + if validationErrors != None: + return validationErrors + + # Get the current time + curTime = datetime.now() + + # Load JSON string into JSON structure + graphJson = json.loads(graph_json) + + # Needed for old graphs, converts CytoscapeWeb to CytoscapeJS standard + if 'data' in graphJson['graph']: + graphJson = json.loads(convert_json(graph_json)) + + # Attach ID's to each edge for traversing the element + graphJson = assign_edge_ids(graphJson) + + nodes = graphJson['graph']['nodes'] + + # If we're not passed in any time values, use the current time as timestamps + if modified == None and created == None: + modified = curTime + created = curTime + + # If we're given a creation time but no modified time, use current time + elif modified == None: + modified = curTime + + # If we're given a modified time but no creation time, use current time + elif created == None: + created = curTime + + # Construct new graph to add to database + new_graph = models.Graph(graph_id = graphname, user_id = username, json = json.dumps(graphJson, sort_keys=True, indent=4), created = created, modified = modified, public = public, shared_with_groups = shared_with_groups, default_layout_id = default_layout_id) + + db_session.add(new_graph) + db_session.commit() + + if 'tags' in graphJson['metadata']: + tags = graphJson['metadata']['tags'] + else: + tags = [] + + # Insert all tags for this graph into tags database + insert_data_for_graph(graphJson, graphname, username, tags, nodes, curTime, 0, db_session) + + db_session.close() + + # If everything works, return Nothing + return None + +def insert_data_for_graph(graphJson, graphname, username, tags, nodes, modified, public, db_session): + ''' + Inserts metadata about a graph into its respective tables. + + :param graphJson: JSON of graph + :param graphname: Name of graph + :username: Name of user + :param: Tags of graph + :param nodes: Nodes to insert into nodes table + :param modified: Modification date of tabe + :param public: Nodes to insert into nodes table + :param db_session: Database connection + ''' + # Add all tags for this graph into graph_tag and graph_to_tag tables + for tag in tags: + tag_exists = db_session.query(models.GraphTag).filter(models.GraphTag.tag_id == tag).first() + + # If the tag doesn't already exists in the database, add it + if tag_exists == None: + new_tag = models.GraphTag(tag_id = tag) + db_session.add(new_tag) + db_session.commit() + + # Add to Graph to Tag table so that we can retrieve all graphs with tag + new_graph_to_tag = models.GraphToTag(graph_id = graphname, user_id = username, tag_id = tag) + db_session.add(new_graph_to_tag) + db_session.commit() + + # Go through edges and parse them accordingly + edges = graphJson['graph']['edges'] + + # If there are edges with same source and directed + dupEdges = [] + + # Number to differentiate between two duplicate edges + rand = 0 + + # Stores mapping from node id to node label. This mapping is used to fetch node (head/tail) label while storing the edges. + node_label_dict = {} + + # Go through all nodes in JSON and add to node table + for node in nodes: + # Used for backwards-compatibility since some JSON have label + # but new CytoscapeJS uses the content property + if 'label' in node['data']: + node['data']['content'] = node['data']['label'] + del node['data']['label'] + + # If the node has any content inside of it, display that content, otherwise, just make it an empty string + if 'content' not in node['data']: + node['data']['content'] = "" + + # Add node to table + new_node = models.Node(node_id = node['data']['id'], label = node['data']['content'], user_id = username, graph_id = graphname, modified = modified) + + node_label_dict[str(node['data']['id'])] = str(node['data']['content']) + + db_session.add(new_node) + db_session.commit() + + for edge in edges: + # Is the edge directed? + is_directed = 1 + + # Make edge undirected if it doesn't have target_arrow_shape attribute + if 'target_arrow_shape' not in edge['data']: + edge['data']['target_arrow_shape'] = "none" + is_directed = 0 + + # To make sure int and floats are also accepted as source and target nodes of an edge + source_node_id = str(edge['data']['source']) + target_node_id = str(edge['data']['target']) + + if source_node_id in node_label_dict: + source_node_label = node_label_dict[source_node_id] + else: + source_node_label = "" + + if target_node_id in node_label_dict: + target_node_label = node_label_dict[target_node_id] + else: + target_node_label = "" + + # Keep track of all the duplicate edges + # If there are two duplicate edges, append a counter and store it as an ID + if source_node_id + '-' + target_node_id in dupEdges: + rand += 1 + if 'id' not in edge['data']: + edge['data']['id'] = source_node_id + '-' + target_node_id + rand + + + # If this is first time we've seen an edge, simply get its ID without the counter + else: + if 'id' not in edge['data']: + edge['data']['id'] = source_node_id + '-' + target_node_id + + dupEdges.append(source_node_id + '-' + target_node_id) + + # TRICKY NOTE: An edge's ID is used as the label property + # The reason is because edge uses an 'id' column as the primary key. + # The label was the column I decided to avoid completely reconstructing the database + # POSSIBLE SOLUTION: If edge is bidirectional, we insert two edges with inverse source and target nodes + + # We are storing head_node_label and tail_node_label to speed up the similar terms search query on edges. Looking up in two tables was taking up too much time. + # head_node_label and tail_node_label are part of index `edge_idx_head_label_tail_label`. This helped in reducing the query time. + new_edge = models.Edge(user_id = username, graph_id = graphname, head_node_id = source_node_id, head_node_label = source_node_label, tail_node_id = target_node_id, tail_node_label = target_node_label, edge_id = edge['data']['id'], directed = is_directed, id = None) + + db_session.add(new_edge) + db_session.commit() + +def update_graph(username, graphname, graph_json): + ''' + Updates the JSON for a graph. + + :param username: Email of user in GraphSpace + :param graphname: Name of graph to insert + :param graph_json: JSON of graph + ''' + + # Get graph + graph = get_graph(username, graphname) + + # If graph doesn't exist + if graph == None: + return "Can't update " + graphname + " because it does not exist for " + username + + # Get database connection + db_session = data_connection.new_session() + + # Delete from graph + db_session.delete(graph) + db_session.commit() + + try: + # Get all tags for the graph + gt_to_delete = db_session.query(models.GraphToTag).filter(models.GraphToTag.graph_id == graphname).filter(models.GraphToTag.user_id == username).delete() + + except NoResultFound: + print "No tags for graph" + + try: + # Delete from edge + edges_to_delete = db_session.query(models.Edge).filter(models.Edge.graph_id == graphname).filter(models.Edge.user_id == username).delete() + + except NoResultFound: + print "No edges in graph to delete" + + try: + # Delete from node + nodes_to_delete = db_session.query(models.Node).filter(models.Node.graph_id == graphname).filter(models.Node.user_id == username).delete() + + except NoResultFound: + print "No nodes in graph to delete" + + db_session.commit() + + curTime = datetime.now() + # Re-insert graph + result = insert_graph(username, graphname, graph_json, graph.created, curTime, graph.public, graph.shared_with_groups, graph.default_layout_id) + + # Update modified time for group to graph table + group_to_graph = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).filter(models.Graph.graph_id == graphname).filter(models.Graph.user_id == username).first() + + if group_to_graph != None: + group_to_graph.modified = curTime + db_session.commit() + + return result + +def get_graph_json(username, graphname): + ''' + Get the JSON of the graph to view. + + :param username: Email of user in GraphSpace + :param password: Password of the user + :return JSON: JSON of graph to view + ''' + # Get the graph + graph = get_graph(username, graphname) + + # If graph doesn't exist, return None + if graph == None: + return None + + return graph.json + +def delete_graph(username, graphname): + ''' + Deletes graph from database. + + :param username: Email of user in GraphSpace + :param password: Password of the user + ''' + + # Get graph + graph = get_graph(username, graphname) + + if graph == None: + return + + # Create database connection + db_session = data_connection.new_session() + + try: + # Delete graph + db_session.delete(graph) + + db_session.commit() + # Delete from graph_to_tag + gt = db_session.query(models.GraphToTag).filter(models.GraphToTag.user_id == username).filter(models.GraphToTag.graph_id == graphname).delete() + + # Delete from group_to_graph + gg = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.user_id == username).filter(models.GroupToGraph.graph_id == graphname).delete() + + # Delete from edge + edge = db_session.query(models.Edge).filter(models.Edge.graph_id == graphname).filter(models.Edge.user_id == username).delete() + + # Delete from node + node = db_session.query(models.Node).filter(models.Node.user_id == username).filter(models.Node.graph_id == graphname).delete() + + # Delete from layout + layout = db_session.query(models.Layout).filter(models.Layout.graph_id == graphname).filter(models.Layout.user_id == username).delete() + + db_session.commit() + + task = db_session.query(models.Task).filter(models.Task.graph_id == graphname).filter(models.Task.user_id == username).all() + + for t in task: + db_session.delete(t) + db_session.commit() + + db_session.close() + + except Exception as ex: + print ex + db_session.close() + return + +def get_all_graphs_for_user(username): + ''' + Gets all graphs for username + + :param username: Email of user in GraphSpace + :return Graphs: [graphs] + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Get all graphs user owns + user_graphs = db_session.query(models.Graph).filter(models.Graph.user_id == username).all() + + # Get all names of graphs that user owns + cleaned_user_graph_names = [] + + # Get rid of unicode + for graph in user_graphs: + cleaned_user_graph_names.append(graph.graph_id) + + db_session.close() + return cleaned_user_graph_names + except NoResultFound: + db_session.close() + return [] + +def get_graphs_in_group(group_id, group_owner): + ''' + Gets graphs in a group. + + @param group_id: Id of group + @param group_owner: Owner of group + ''' + + # Create database connection + db_session = data_connection.new_session() + + try: + # Gets all the graphs in the group + graphs_in_group = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == group_id).filter(models.GroupToGraph.group_owner == group_owner).all() + + db_session.close() + return graphs_in_group + except NoResultFound: + db_session.close() + return [] + +def get_groups_of_user(user_id): + ''' + Get all groups that the user owns + + :param user_id: Email of user of GraphSpace + :return Groups: [group information] + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Get all groups that user owns + owned_groups = db_session.query(models.Group).filter(models.Group.owner_id == user_id).all() + + # Get information about graphs in group and format it + complete_group_information = get_cleaned_group_data(owned_groups, db_session) + + db_session.close() + return complete_group_information + except NoResultFound: + db_session.close() + return None + +def get_cleaned_group_data(data, db_session): + ''' + Get all information about group (including number of graphs group has) + + :param data: Information about group + :param cur: Database cursor + :return Groups: [gorup information + graphs in group information] + ''' + + # Get information about how many graphs each group contains + complete_group_information = [] + + # For each group that is provided, append the amount of graphs that belong in the group + # and return it as a tuple + + # Used as the primary method for /groups page + for group in data: + cleaned_group = [] + cleaned_group.append(group.name) + cleaned_group.append(group.description) + cleaned_group.append(group.owner_id) + + graphs = db_session.query(models.Graph.graph_id).filter(models.GroupToGraph.group_id == group.group_id).filter(models.GroupToGraph.group_owner == group.owner_id).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).all() + + cleaned_group.append(len(graphs)) + + # cleaned_group.append(group.public) + # cleaned_group.append(group.shared_with_groups) + cleaned_group.append(group.group_id) + complete_group_information.append(tuple(cleaned_group)) + + return complete_group_information + +def get_all_groups_with_member(user_id, skip = None): + ''' + Get all groups that has the user as a member in that group. + + :param user_id: Member to be searched for in all groups + :return Groups: [Groups that user_id is a member of] + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + cleaned_groups = [] + # Get all groups that the user is a member of + groups_with_member = db_session.query(models.Group).filter(models.Group.group_id == models.GroupToUser.group_id).filter(models.Group.owner_id == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == user_id).all() + + if skip == None: + # Format group information + cleaned_groups = get_cleaned_group_data(groups_with_member, db_session) + else: + # Get all groups that the user is a member of + groups_with_member += db_session.query(models.Group).filter(models.Group.owner_id == user_id).all() + cleaned_groups = groups_with_member + + db_session.close() + return cleaned_groups + except NoResultFound: + db_session.close() + return None + +def change_description(username, groupId, groupOwner, desc): + ''' + Changes description of group. + + :param username: person who is requesting the change + :param groupId: ID of group to change description + :param groupOwner: Owner of the group + :param desc: Description to change to + :return Error: + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + if username != groupOwner: + return "You can only change description of group that you own!" + + # Get the group to change the description of + group = db_session.query(models.Group).filter(models.Group.owner_id == groupOwner).filter(models.Group.group_id == groupId).one() + + group.description = desc + + db_session.commit() + db_session.close() + return None + except Exception as ex: + db_session.close() + return ex + +def get_group_by_id(groupOwner, groupId): + ''' + Gets a group information by group id ( REST API option). + + :param groupOwner: Owner of the group + :param groupId: ID of group to be searched for + :return Group: [Information about group (see REST API in Help section)] + ''' + # Create database connection + db_session = data_connection.new_session() + + # Get the group + group = db_session.query(models.Group).filter(models.Group.owner_id == groupOwner).filter(models.Group.group_id == groupId).first() + + # If no group is found, return + if group == None: + return None + + cleaned_data = [] + + # Remove group owner's name from member's list to display in UI + initial_members = get_group_members(groupOwner, groupId) + members = [] + + # Get all member names + for member in initial_members: + if member.user_id != groupOwner: + members.append(member.user_id) + + # Combine group with members of group + cleaned_tuple = (group.description, members, group.owner_id, group.name, group.group_id) + + db_session.close() + return [cleaned_tuple] + +def get_group(group_owner, groupId): + ''' + Gets all information about a certain group (used for groups page exclusively). + + :param group_owner: Owner of group to get from server + :param groupId: ID of groupId + :return Group: [information of group] + ''' + # Create database connection + db_session = data_connection.new_session() + + # Get the group + group = db_session.query(models.Group).filter(models.Group.group_id == groupId).filter(models.Group.owner_id == group_owner).first() + + # If no group exists, return + if group == None: + db_session.close() + return None + + cleaned_data = {} + + # Set all properties that are used in the /groups page + cleaned_data['members'] = get_group_members(group_owner, groupId) + cleaned_data['owner'] = group.owner_id + cleaned_data['group_id'] = group.group_id + cleaned_data['description'] = group.description + + # Get all graph names for group + graphs = get_graphs_in_group(group.group_id, group.owner_id) + + graph_names = [] + for graph in graphs: + graph_names.append(graph.graph_id) + + cleaned_data['graphs'] = graphs + db_session.close() + return cleaned_data + +def get_group_members(groupOwner, groupId): + ''' + Get all members of a group. + + :param groupOwner: Group Owner + :param groupId: Group ID + :return Members: [Members of group] + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Get all members of the group + group_members = db_session.query(models.User).filter(models.User.user_id == models.GroupToUser.user_id).filter(models.GroupToUser.group_owner == groupOwner).filter(models.GroupToUser.group_id == groupId).all() + + # Also get owns of the group as well since owners are technically members of the group too + # group_members += db_session.query(models.User).filter(models.User.user_id == models.GroupToUser.user_id).filter(models.GroupToUser.group_owner == groupOwner).filter(models.GroupToUser.group_id == groupId).all() + + db_session.close() + return group_members + + except NoResultFound: + db_session.close() + return None + +def can_see_shared_graph(logged_in_user, graph_owner, graphname): + ''' + See if user is allowed to see a graph. + + :param logged_in_user: User that is currently logged in + :param graph_owner: Owner of graph being viewed + :param graphname: Name of graph being viewed + :return boolean: True if can see it, false otherwise + ''' + + # Get all groups that this graph is shared with + groups = get_all_groups_for_this_graph(graph_owner, graphname) + + # If there are any groups that share this graph + # check to see if the logged in user is a member in that group. + # If they are, then they are allowed to see the graph + if len(groups) > 0: + for group in groups: + + # If logged in user owns a group that the graph is shared with + if logged_in_user == group.owner_id: + return True + + # Get all members of the group + members = get_group_members(group.owner_id, group.group_id) + + # If the user is a member of a group that graph is shared with, + # the user may view this graph + for member in members: + if logged_in_user == member.user_id: + return True + + return None + +def remove_group(owner, group): + ''' + Removes a group from server. + + :param owner: Owner of group + :param group: Group ID + :return + ''' + + # Create database connection + db_session = data_connection.new_session() + + # Check to see if group exists + group = db_session.query(models.Group).filter(models.Group.group_id == cleanGroupName(groupId)).filter(models.Group.owner_id == username).first() + + # Group already exists in database + if group != None: + db_session.close() + return None + + # Create new group + new_group = models.Group(group_id = cleanGroupName(groupId), name = groupId, owner_id = username, description = "") + + # Add to database + db_session.add(new_group) + db_session.commit() + db_session.close() + return [groupId, cleanGroupName(groupId)] + +def cleanGroupName(groupName): + ''' + Cleans group name (gets rid of spaces and _ characters) + + :param groupName: Name of group + :return group: cleaned group name + ''' + groupName = groupName.replace(' ', '') + groupName = groupName.replace('-', '') + return groupName + +def groups_for_user(username): + ''' + Get all groups user belongs to or owns. + + :param username: Email of user + :return Groups: [groups] + ''' + # Create database connection + db_session = data_connection.new_session() + cleaned_group_data = [] + try: + # Get all groups that the user is a member of + member_of_groups = db_session.query(models.GroupToUser).filter(models.GroupToUser.user_id == username).all() + + # Appeend tuple that describes ID of group and the owner of the group + for group in member_of_groups: + cleaned_group_data.append({"groupId": group.group_id, "group_owner": group.group_owner}) + + except NoResultFound: + print "User is not a member of any groups" + + try: + # Get all groups that the user owns + owned_groups = db_session.query(models.Group).filter(models.Group.owner_id == username).all() + + # Appeend tuple that describes ID of group and the owner of the group + for group in owned_groups: + cleaned_group_data.append({"groupId": group.group_id, "group_owner": group.owner_id}) + + except NoResultFound: + print "User is not an owner of any groups" + + db_session.close() + return cleaned_group_data + +def search_result_for_graphs_in_group(uid, search_type, search_terms, db_session, groupOwner, groupId): + ''' + Search method for the graphs in group page. + Emulates search functionality in graphs page except for a particular group. + + @param uid: Logged in user + @param search_type: Type of search (partial_search or full_search) + @param search_terms: All terms being searched for + @param db_session: Database connection + @param groupOwner: Owner of group + @param groupId: ID of group + ''' + + # If it is a search type that is not recognized, return empty list + if search_type != 'partial_search' and search_type != 'full_search': + return [] + + # Make into list if it is not a lsit + if not isinstance(search_terms, list): + search_terms = search_terms.split(',') + + # If there are any search terms + if len(search_terms) > 0: + + # List to keep track of all matched graphs + initial_graphs_from_search = [] + + # Get connection to database + data_session = data_connection.new_session() + + # Go through each search term, aggregating + # all graphs that match the specific search term + for search_word in search_terms: + # matched_graphs contains a list of all graphs that match the specific search term + matched_graphs = [] + # First, we check to see if there are any graphs that have a graph name that matches the search term + matched_graphs += find_all_graphs_containing_search_word_in_group(uid, search_type, search_word, data_session, groupOwner, groupId) + + # ":" indicates that search_word may be an edge + if ':' in search_word: + # append all graphs that contain an edge which matches the search_word + matched_graphs += find_all_graphs_containing_edges_in_group(uid, search_type, search_word, data_session, groupOwner, groupId) + # otherwise append all graphs that contain a node which matches the search word + else: + matched_graphs += find_all_graphs_containing_nodes_in_group(uid, search_type, search_word, data_session, groupOwner, groupId) + + # Go through all matched graphs + # If there is a graph that appears multiple times in the list + # combine their result. + # Effectively, a graph may appear at most one time for each search word + matched_graphs = combine_similar_graphs(matched_graphs) + + # Add condensed tuples to list of graphs matched + initial_graphs_from_search += matched_graphs + + # Go through and count the list of occurrences of matched graph + graph_repititions = defaultdict(int) + + # Counting the number of occurences + for graph_tuple in initial_graphs_from_search: + key = graph_tuple[0] + graph_tuple[4] + graph_repititions[key] += 1 + + # Go through and aggregate all graph together + graph_mappings = defaultdict(list) + + # If the number of times a graph appears matches the number of search terms + # it is a graph we want (simulating the and operator for all search terms) + for graph_tuple in initial_graphs_from_search: + key = graph_tuple[0] + graph_tuple[4] + + graph_tuple = list(graph_tuple) + + # Placeholder for tags of the graph + graph_tuple.insert(1, "") + + # Graph matches all search terms + if graph_repititions[key] == len(search_terms): + + # If we haven't seen this graph yet + if key not in graph_mappings: + graph_mappings[key] = tuple(graph_tuple) + else: + # Combine result of previous tuple + old_tuple = list(graph_mappings[key]) + + # If there is already a matching node/edge id + if len(old_tuple[2]) > 0 and len(graph_tuple[2]) > 0: + old_tuple[2] += ", " + graph_tuple[2] + old_tuple[3] += ", " + graph_tuple[3] + # Otherwise, simply insert this graph tuples id + else: + old_tuple[2] += graph_tuple[2] + old_tuple[3] += graph_tuple[3] + + graph_mappings[key] = tuple(old_tuple) + + # Go through all the graphs and insert tags for the graphs that match all search terms + return graph_mappings.values() + else: + return [] + +def find_all_graphs_containing_edges_in_group(uid, search_type, search_word, db_session, groupId, groupOwner): + ''' + Finds all edges that match search terms that are shared with group. + Emulates search functionality in graphs page except for a particular group. + + @param uid: Logged in user + @param search_type: Type of search (partial_search or full_search) + @param search_word: Term to search for in edge + @param db_session: Database connection + @param groupOwner: Owner of group + @param groupId: ID of group + ''' + # List to keep track of all graphs that contain edges that match the search_word + initial_graphs_matching_edges = [] + + # Separate the edge into its two node ID's + # This is done because in the database, an edge ID is comprised of target:source nodes + node_ids = search_word.split(":") + + # Get head and tail node references + head_node = node_ids[0] + tail_node = node_ids[1] + + # List of all head node ids + head_nodes = [] + + # List of all tail node ids + tail_nodes = [] + + # Match all edges that contain the edges that exactly match the search_word + if search_type == "full_search": + + # Get all (head) nodes that contain a label matching search_word + head_nodes += db_session.query(models.Node.node_id).filter(models.Node.label == head_node).all() + + # Get all (tail) nodes that contain a label matching search_word + tail_nodes += db_session.query(models.Node.node_id).filter(models.Node.label == tail_node).all() + + # Get all (head) nodes that contain a node id matching search_word + head_nodes += db_session.query(models.Node.node_id).filter(models.Node.node_id == head_node).all() + + # Get all (tail) nodes that contain a node id matched search_word + tail_nodes += db_session.query(models.Node.node_id).filter(models.Node.node_id == tail_node).all() + + elif search_type == "partial_search": + + # Get all (head) nodes that contain a partially matching label + head_nodes += db_session.query(models.Node.node_id).filter(models.Node.label.like("%" + head_node + "%")).all() + + # Get all (tail) nodes that contain a label partially matching label + tail_nodes += db_session.query(models.Node.node_id).filter(models.Node.label.like("%" + tail_node + "%")).all() + + # Get all (head) nodes that contain a node id partially matching search_word + head_nodes += db_session.query(models.Node.node_id).filter(models.Node.node_id.like("%" + head_node + "%")).all() + + # Get all (head) nodes that contain a node id partially matching search_word + tail_nodes += db_session.query(models.Node.node_id).filter(models.Node.node_id.like("%" + tail_node + "%")).all() + + # Remove all the duplicates + head_nodes = list(set(head_nodes)) + tail_nodes = list(set(tail_nodes)) + + # Go through head and tail nodes to see if there are any graphs + # that match the given view type (my graphs, shared, public). + # In other words, return all graphs that having matching edges + # for the given view type. + + # TODO: ASK MURALI ABOUT BIDIRECTION EDGES + + # If there are both head and tail nodes + if len(head_nodes) > 0 and len(tail_nodes) > 0: + # Go through all permutations of these nodes + # compile graphs that match the given view_type (my graphs, shared, public) + for i in xrange(len(head_nodes)): + for j in xrange(len(tail_nodes)): + h_node = head_nodes[i][0] + t_node = tail_nodes[j][0] + + # We make two queries because we want to have tail:head and head:tail search (to resolve undirected edges searching) + initial_graphs_matching_edges += db_session.query(models.Edge).filter(models.Edge.head_node_id == h_node).filter(models.Edge.tail_node_id == t_node).filter(models.Edge.graph_id == models.GroupToGraph.graph_id).filter(models.Edge.user_id == uid).filter(models.GroupToGraph.user_id == models.Edge.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + initial_graphs_matching_edges += db_session.query(models.Edge).filter(models.Edge.head_node_id == t_node).filter(models.Edge.tail_node_id == h_node).filter(models.Edge.graph_id == models.GroupToGraph.graph_id).filter(models.Edge.user_id == uid).filter(models.GroupToGraph.user_id == models.Edge.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + + graph_dict = dict() + # Remove duplicates for all graphs that match have the same edge matching search term + for graph in initial_graphs_matching_edges: + key = graph.head_node_id + graph.graph_id + graph.user_id + graph.tail_node_id + graph.edge_id + if key in graph_dict: + continue + else: + graph_dict[key] = graph + + return graph_dict.values() + else: + return [] + +def find_all_graphs_containing_nodes_in_group(uid, search_type, search_word, db_session, groupId, groupOwner): + ''' + Finds all nodes that match search terms that are shared with group. + Emulates search functionality in graphs page except for a particular group. + + @param uid: Logged in user + @param search_type: Type of search (partial_search or full_search) + @param search_word: Term to search for in node + @param db_session: Database connection + @param groupOwner: Owner of group + @param groupId: ID of group + ''' + node_data = [] + + # If we only want partially matched nodes + if search_type == 'partial_search': + + # Get all nodes that have a partially matching label + node_data = db_session.query(models.Node).filter(models.Node.label.like("%" + search_word + "%")).filter(models.Node.graph_id == models.GroupToGraph.graph_id).filter(models.GroupToGraph.user_id == models.Node.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + + # Get all nodes that have a partially matching node id + node_data += db_session.query(models.Node).filter(models.Node.node_id.like("%" + search_word + "%")).filter(models.Node.graph_id == models.GroupToGraph.graph_id).filter(models.GroupToGraph.user_id == models.Node.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + else: + # Get all nodes that have an exact matching label + node_data = db_session.query(models.Node).filter(models.Node.label == search_word).filter(models.Node.graph_id == models.GroupToGraph.graph_id).filter(models.GroupToGraph.user_id == models.Node.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + + # Get all nodes that have an exact matching node id + node_data += db_session.query(models.Node).filter(models.Node.node_id == search_word).filter(models.Node.graph_id == models.GroupToGraph.graph_id).filter(models.GroupToGraph.user_id == models.Node.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + + graph_dict = dict() + + # Remove duplicates for all graphs that match have the same node id and label matching search term + for graph in node_data: + key = graph.graph_id + graph.user_id + graph.label + graph.node_id + if key in graph_dict: + continue + else: + graph_dict[key] = graph + + return graph_dict.values() + +def find_all_graphs_containing_search_word_in_group(uid, search_type, search_word, db_session, groupId, groupOwner): + ''' + Finds all graphs with names that match search terms that are shared with group. + Emulates search functionality in graphs page except for a particular group. + + @param uid: Logged in user + @param search_type: Type of search (partial_search or full_search) + @param search_word: Term to search for in graph name + @param db_session: Database connection + @param groupOwner: Owner of group + @param groupId: ID of group + ''' + matched_graphs = [] + # Return all graphs that have a graph name that partially matches the search word + if search_type == 'partial_search': + try: + #Get all graphs that have ID that partially match search term + matched_graphs = db_session.query(models.Graph).filter(models.Graph.graph_id.like("%" + search_word + "%")).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + + except NoResultFound: + print "No shared graphs matching search term" + + elif search_type == 'full_search': + try: + # Return all graphs that have a graph name that exactly matches the search word + matched_graphs = db_session.query(models.Graph).filter(models.Graph.graph_id == search_word).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + + except NoResultFound: + print "No shared graphs matching search term" + + + graph_dict = dict() + + # Remove duplicates for all graphs that match have the same graph matching search term + for graph in matched_graphs: + key = graph.graph_id + graph.user_id + if key in graph_dict: + continue + else: + graph_dict[key] = graph + + return graph_dict.values() + +def tag_result_for_graphs_in_group(groupOwner, groupId, tag_terms, db_session): + ''' + Finds all graphs with graphs that have matching tag. + + @param groupOwner: Owner of group + @param groupId: ID of group + @param tag_terms: Tag terms to search for + @param db_session: Database connection + ''' + intial_graphs_with_tags = [] + + if len(tag_terms) > 0: + for tag in tag_terms: + try: + # Find graphs that have tag being searched for + intial_graphs_with_tags += db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).filter(models.Graph.graph_id == models.GraphToTag.graph_id).filter(models.Graph.user_id == models.GraphToTag.user_id).filter(models.GraphToTag.tag_id == tag).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + + except NoResultFound: + print "No shared graphs with tag" + + # Go through and count the list of occurrences of matched graph + graph_repititions = defaultdict(int) + + # Counting the number of occurences + for graph in intial_graphs_with_tags: + graph_repititions[graph] += 1 + + # Go through and aggregate all graph together + graph_mappings = set() + + # If the number of times a graph appears matches the number of search terms + # it is a graph we want (simulating the and operator for all search terms) + for graph in intial_graphs_with_tags: + + # Graph matches all search terms + if graph_repititions[graph] == len(tag_terms): + graph_mappings.add(graph) + + # Go through all the graphs and insert tags for the graphs that match all search terms + return graph_mappings + else: + return [] + +def get_all_graphs_for_group(uid, groupOwner, groupId, request): + ''' + Get all graphs that belong to this group. + + :param groupOwner: Owner of group + :param groupId: Id of group + :param search_terms: Terms to be searched for + :param tag_terms: Tags to be searched for in graphs + :return Graphs: [graphs] + ''' + + # Get connection to databse + db_session = data_connection.new_session() + + # Set search type + search_type = None + + if 'partial_search' in request.GET: + search_type = 'partial_search' + elif 'full_search' in request.GET: + search_type = 'full_search' + + # Check to see if query has search terms, tag terms, or + # user wants to sort graphs + search_terms = request.GET.get(search_type) + tag_terms = request.GET.get('tags') or request.GET.get('tag') + order_by = request.GET.get('order') + + graph_data = [] + + if tag_terms and len(tag_terms) > 0: + cleaned_tags = tag_terms.split(',') + # Goes through each tag, making it a string + # so the url will contain those tags as a part + # of the query string + for tags in xrange(len(cleaned_tags)): + cleaned_tags[tags] = cleaned_tags[tags].strip() + # If user enters in a blank tag, delete it + if len(cleaned_tags[tags]) == 0: + del cleaned_tags[tags] + + if search_terms and len(search_terms) > 0: + + # Split up search terms by comma + cleaned_search_terms = search_terms.split(',') + + # Goes through each search term, making it a string + # so the url will contain those searches as a part + # of the query string + for i in xrange(len(cleaned_search_terms)): + cleaned_search_terms[i] = cleaned_search_terms[i].strip() + # Deleted no length search terms + if len(cleaned_search_terms[i]) == 0: + del cleaned_search_terms[i] + + # If both a tag term and search term are entered + if search_terms and tag_terms and len(search_terms) > 0 and len(tag_terms) > 0: + actual_graphs = [] + + # Get all graphs that contain all the search terms + search_result_graphs = search_result_for_graphs_in_group(uid, search_type, cleaned_search_terms, db_session, groupId, groupOwner) + + # Get all graphs that contain all the tag terms + tag_result_graphs = tag_result_for_graphs_in_group(groupOwner, groupId, cleaned_tags, db_session) + + tag_graphs = [x[0] for x in tag_result_graphs] + actual = [x[0] for x in actual_graphs] + + # If it is not already part of final graphs returned, add it in + for graph in search_result_graphs: + if graph[0] in tag_graphs and graph[0] not in actual: + actual_graphs.append(graph) + + graph_data = actual_graphs + + # If only search terms are entered + elif search_terms: + graph_data = search_result_for_graphs_in_group(uid, search_type, cleaned_search_terms, db_session, groupId, groupOwner) + + # If only tag terms are entered + elif tag_terms: + graph_data = tag_result_for_graphs_in_group(groupOwner, groupId, cleaned_tags, db_session) + else: + try: + graph_data = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.modified, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() + + except NoResultFound: + print 'no result found' + + # If user wants to sort the data + if order_by: + graph_data = order_information(order_by, search_terms, graph_data) + else: + graph_data = order_information("modified_descending", search_terms, graph_data) + + db_session.close() + return graph_data + +def task_exists(graph_id, user_id): + ''' + Checks to see if task exists for graph. + + @param graph_id: ID of graph + @param user_id: Owner of graph + + ''' + # Create database connection + db_session = data_connection.new_session() + + # Check to see if there is a task currently active for the current graph + exists = db_session.query(models.Task.created).filter(models.Task.graph_id == graph_id).filter(models.Task.user_id == user_id).first() + return exists + +def generateTimeStampAndSignature(secretKey, operation): + ''' + Generates common parameters as defined in (http://docs.aws.amazon.com/AWSMechTurk/latest/AWSMturkAPI/ApiReference_CommonParametersArticle.html) + + @param secretKey: Secret Key given by AWS when creating account + @param operation: Operation of call (http://docs.aws.amazon.com/AWSMechTurk/latest/AWSMturkAPI/ApiReference_OperationsArticle.html) + @return (Timestamp, Signature) following AWS semantics + ''' + + # Get current timestamp + cur_date = datetime.utcnow() + timestamp = datetime.strftime(cur_date, "%Y-%m-%dT%H:%M:%S") + "Z" + + # Create signature based on service, operation and timestamp + signature = "AWSMechanicalTurkRequester" + operation + timestamp + + # Encrypt with HMAC-SHA1 in base64, then URL encode + # (http://docs.aws.amazon.com/AWSMechTurk/latest/AWSMechanicalTurkRequester/MakingRequests_RequestAuthenticationArticle.html#CalcReqSig) + signature = hmac.new(secretKey, signature, sha1).digest().encode("base64").rstrip("\n") + signature = urllib.urlencode({"code": signature})[5:] + + return (timestamp, signature) + + +def launchTask(graph_id, user_id, layout_array, single=None, submitted=0): + ''' + Launches a task on Amazon Mechanical Turk. + + :param graph_id: ID of graph + :param user_id: Owner of graph + :param layout_array: Array of layouts to be saved when task is launched + :param single: If only single layout is being produced instead of an array of layouts + :param submitted: Number of times the layout has been submitted by other workers + :return Error or None if no error + ''' + + # Create database connection + db_session = data_connection.new_session() + + #TODO: ASK IF GROUP OWNER CAN ALSO LAUNCH TASK OR ONLY GRAPH OWNER CAN + + # Get the current time + curtime = datetime.now() + + # If only one layout is provided, nest it in list so rest of code still works + if single != None: + layout_array = [json.loads(layout_array[0])] + else: + layout_array = json.loads(layout_array[0]) + + # If the proper environment variables are set in gs-setup + if AWSACCESSKEYID != None and SECRETKEY != None: + + #Go through each layout and save it + for layout in layout_array: + # Create randomly named layout + new_layout = models.Layout(layout_id = None, layout_name = "Worker_layout_" + str(random.randint(0, 100000)), owner_id = "MTURK_Worker", graph_id = graph_id, user_id = user_id, json = json.dumps(layout), public = 0, shared_with_groups = 0, times_modified=0, original_json=None) + db_session.add(new_layout) + db_session.commit() + + # Get the common parameters for MTurk + timestamp, signature = generateTimeStampAndSignature(SECRETKEY, "CreateHIT") + + # Current as of 12/14/2016 + version = "2014-08-15" + operation = "CreateHIT" + + # Duration of both task and how long it is to be alive (for now same value) + duration = "3000" + + # Title of task and description of task + title = urllib.urlencode({"title": "Lay out a network"})[6:].replace("+", "%20") + description = urllib.urlencode({"description": "Move nodes and edges in a graph following guidelines"})[12:] + + # Generate link back to GS that worker will follow + link_to_graphspace = URL_PATH + "task/" + user_id + "/" + graph_id + "?layout=" + new_layout.layout_name + "&layout_owner=" + new_layout.owner_id + + # Follows Amazon Schematics (http://docs.aws.amazon.com/AWSMechTurk/latest/AWSMturkAPI/ApiReference_CreateHITOperation.html) + question_form_as_xml = '''GraphSpacetruePlease follow the link to lay this graph out to be visually pleasing. Afterwards, you will be presented a survey code to enter below in order to submit this HIT. Thank you for your participation.There are 3 guidelines to follow when laying out a graph. 1) Arrange nodes of the same color together. 2) Arrange rectangles at the bottom of the graph. 3) Arrange diamonds on top of the graph. There is a short tutorial to introduce the tools to aid you provided with the link. The following screenshots shows how a user may layout a graph according to the guidelines.This task is part of a study by Virginia Tech researchers investigating how people can make graph visualizations easier to understand, and the results may be published in scientific journals, conferences, and graduate student theses. You are invited to participate by accepting this task and completing the online consent form. If you participate, you will use our software to look at graphs and create new layouts using some guidelines. You can do as many tasks as you like. Participation is voluntary and confidential. You must be 18 or older to participate. You will be paid $0.50 for each task.imagepng''' + URL_PATH + '''image?name=originalThe game board, with "X" to move.imagepng''' + URL_PATH + '''image?name=midwayThe game board, with "X" to move.imagepng''' + URL_PATH + '''image?name=finalThe game board, with "X" to move.Link to task]]>Replace this with code obtained from GraphSpace.''' + + # must encode from XML to urlencoded format.. some of the letters didn't match up correctly so manually replacement was necessary + xml_encoded = urllib.urlencode({"xml": question_form_as_xml})[4:].replace("+", "%20").replace("%21", "!") + + # Generate MechTurkRequest + request = AWS_URL + '/?Service=AWSMechanicalTurkRequester&Operation=CreateHIT&AWSAccessKeyId=' + AWSACCESSKEYID + '&Version=' + version + '&Timestamp=' + timestamp + "&Title=" + title + "&Description=" + description + "&Reward.1.Amount=0.50&Reward.1.CurrencyCode=USD&AssignmentDurationInSeconds=" + duration + "&LifetimeInSeconds=259200" + "&Question=" + xml_encoded + '&Signature=' + signature + '&Keywords=network,layout,money,science,graph,nodes,edges,task,work,easy' + + response = requests.get(request, allow_redirects=False) + + print response.text + + # Parse XML + root = ET.fromstring(response.text) + + # Depending on XML response, handle task creation + try: + isValid = root[1][0][0].text + if isValid == "True": + hit_id=root[1][1].text + new_task = models.Task(task_id=None, submitted=submitted, graph_id=graph_id, user_id=user_id, task_owner=user_id, created=curtime, hit_id=hit_id, layout_id = new_layout.layout_id, task_type="LAYOUT_TASK", worker_id="MTURK_Worker") + db_session.add(new_task) + createTaskCode(db_session, hit_id) + db_session.commit() + db_session.close() + + except Exception as e: + print "Error is", e + return root[0][1][0][1].text + + db_session.close() + + else: + print "No AWS KEY Specified!" + +# def launchPrepaidTasks(): + +# crowd_layout_prepaid_tasks = [ +# ("dsingh5270@gmail.com", "Etoxazole_crowd", 185), +# ("dsingh5270@gmail.com", "Etoxazole_crowd", 186), +# ("dsingh5270@gmail.com", "Etoxazole_crowd", 187), +# ("dsingh5270@gmail.com", "Etoxazole_crowd", 188), +# ("dsingh5270@gmail.com", "Etoxazole_crowd", 189), +# ("dsingh5270@gmail.com", "Etoxazole_crowd", 201), +# ("dsingh5270@gmail.com", "Bisphenol_crowd", 190), +# ("dsingh5270@gmail.com", "Bisphenol_crowd", 191), +# ("dsingh5270@gmail.com", "Bisphenol_crowd", 192), +# ("dsingh5270@gmail.com", "Bisphenol_crowd", 193), +# ("dsingh5270@gmail.com", "Bisphenol_crowd", 194), +# ("dsingh5270@gmail.com", "Bisphenol_crowd", 200), +# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 180), +# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 181), +# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 182), +# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 183), +# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 184), +# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 199), +# ("dsingh5270@gmail.com", "Flusilazole_crowd", 175), +# ("dsingh5270@gmail.com", "Flusilazole_crowd", 176), +# ("dsingh5270@gmail.com", "Flusilazole_crowd", 177), +# ("dsingh5270@gmail.com", "Flusilazole_crowd", 178), +# ("dsingh5270@gmail.com", "Flusilazole_crowd", 179), +# ("dsingh5270@gmail.com", "Flusilazole_crowd", 198), +# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 170), +# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 171), +# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 172), +# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 173), +# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 174), +# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 197), +# ("dsingh5270@gmail.com", "Triclosan_crowd", 165), +# ("dsingh5270@gmail.com", "Triclosan_crowd", 166), +# ("dsingh5270@gmail.com", "Triclosan_crowd", 167), +# ("dsingh5270@gmail.com", "Triclosan_crowd", 168), +# ("dsingh5270@gmail.com", "Triclosan_crowd", 169), +# ("dsingh5270@gmail.com", "Triclosan_crowd", 195) +# ] + +# researcher_layout_prepaid_tasks = [ +# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 43), +# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 44), +# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 45), +# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 46), +# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 47), +# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 18), +# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 19), +# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 20), +# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 21), +# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 22), +# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 33), +# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 34), +# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 35), +# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 36), +# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 37), +# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 23), +# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 24), +# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 25), +# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 26), +# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 27), +# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 8), +# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 9), +# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 10), +# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 11), +# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 12), +# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 53), +# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 54), +# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 55), +# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 56), +# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 57) +# ] + +# for task in crowd_layout_prepaid_tasks: +# launchApprovalTask(task[0], task[1], task[2]) + +# for task in researcher_layout_prepaid_tasks: +# launchApprovalTask(task[0], task[1], task[2]) + +# db_session = data_connection.new_session() +# for task in crowd_layout_prepaid_tasks: +# new_task = models.Task(task_id=None, task_owner=task[0], graph_id=task[1], user_id=task[0], created=datetime.now(), hit_id="EXPERT_WORKER", worker_id="EXPERT_WORKER", layout_id=task[2], submitted=0, task_type="APPROVE_TASK") +# db_session.add(new_task) + +# for task in researcher_layout_prepaid_tasks: +# new_task = models.Task(task_id=None, task_owner=task[0], graph_id=task[1], user_id=task[0], created=datetime.now(), hit_id="EXPERT_WORKER", worker_id="EXPERT_WORKER", layout_id=task[2], submitted=0, task_type="APPROVE_TASK") +# db_session.add(new_task) + +# db_session.commit() +# db_session.close() + +def getAllApproveTasks(): + db_session = data_connection.new_session() + approve_tasks = db_session.query(models.Task).filter(models.Task.worker_id == "EXPERT_WORKER").all() + db_session.close() + return approve_tasks + +def launchApprovalTask(uid, gid, layout_id, submitted=0): + ''' + Launches approval task for a layout. + + @param uid: Owner of graph + @param gid: Name of graph + @param layout_id: ID of layout + ''' + + # If the proper environment variables are set in gs-setup + if AWSACCESSKEYID != None and SECRETKEY != None: + + db_session = data_connection.new_session() + + # Get the layout + layout = db_session.query(models.Layout).filter(models.Layout.layout_id == layout_id).first() + + # If it doesn't exist, exit + if layout == None: + print "LAYOUT DOESNT EXIST ANYMORE" + return None + + # Current as of 12/14/2016 + version = "2014-08-15" + operation = "CreateHIT" + + # Duration of both task and how long it is to be alive (for now same value) + duration = "3000" + + # Get the common parameters + timestamp, signature = generateTimeStampAndSignature(SECRETKEY, "CreateHIT") + + # Title of task and description of task + title = urllib.urlencode({"title": "Tell us how well this network follows guidelines"})[6:].replace("+", "%20") + description = urllib.urlencode({"description": "Examine a network and rate how well it meets the specified guidelines."})[12:] + + link_to_graphspace = URL_PATH + "approve/" + uid + "/" + gid + "?layout=" + layout.layout_name + "&layout_owner=" + "MTURK_Worker" + + question_form_as_xml = ''' + + + GraphSpace + true + + This task should not take more than 1 minute to complete. Please click the following link and rate the networks. + + + image + png + + ''' + URL_PATH + '''image?name=approve_layout + Image of interface. + This task is part of a study by Virginia Tech researchers investigating how people can make graph visualizations easier to understand, and the results may be published in scientific journals, conferences, and graduate student theses. You are invited to participate by accepting this task and completing the online consent form. If you participate, you will use our software to look at graphs and give feedback on and review graph layouts submitted by crowd workers. You can do as many tasks as you like. Participation is voluntary and confidential. You must be 18 or older to participate. You will be paid $0.20 for each task. + + Link to task]]> + + + + + + + + Replace this with code obtained from GraphSpace. + + + + ''' + + # must encode from XML. gs to urlencoded format.. some of the letters didn't match up correctly so manually replacement was necessary + xml_encoded = urllib.urlencode({"xml": question_form_as_xml})[4:].replace("+", "%20").replace("%21", "!") + + # Generate MechTurkRequest + request = AWS_URL + '/?Service=AWSMechanicalTurkRequester&Operation=CreateHIT&AWSAccessKeyId=' + AWSACCESSKEYID + '&Version=' + version + '&Timestamp=' + timestamp + "&Title=" + title + "&Description=" + description + "&Reward.1.Amount=0.20&Reward.1.CurrencyCode=USD&AssignmentDurationInSeconds=" + duration + "&LifetimeInSeconds=259200" + "&Question=" + xml_encoded + '&Signature=' + signature + '&Keywords=network,layout,easy,money,graphs,quick,science,visual' + '&MaxAssignments=5' + + response = requests.get(request, allow_redirects=False) + + print response.text + + # Parse XML + root = ET.fromstring(response.text) + + # Depending on XML response, handle task creation + try: + isValid = root[1][0][0].text + if isValid == "True": + hit_id=root[1][1].text + new_task = models.Task(task_id=None, task_owner=uid, graph_id=gid, user_id=uid, created=datetime.now(), hit_id=hit_id, layout_id=layout_id, submitted=submitted, task_type="APPROVE_TASK", worker_id="MTURK_Worker") + db_session.add(new_task) + db_session.commit() + createTaskCode(db_session, hit_id) + db_session.close() + + except Exception as e: + print "Error is", e + return root[0][1][0][1].text + + db_session.close() + +def get_all_groups_for_user_with_sharing_info(graphowner, graphname): + ''' + Gets all groups that a user owns or is a member of, + and indicates whether the specified graph is shared with that group + + :param owner: Owner of graph + :param grpahname: Name of graph + :return group_info: [{group_name: , "graph_shared": boolean}] + ''' + group_info = [] + + # Get all groups that the user is a member of or owns + groups = get_groups_of_user(graphowner) + get_all_groups_with_member(graphowner) + + # Get connection to database + db_session = data_connection.new_session() + # Determine if a graph is shared with a specific group + for group in groups: + group_name = group[0] + group_id = group[4] + group_owner = group[2] + + # Check if graph is shared with this group + is_shared_with_group = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.graph_id == graphname).filter(models.GroupToGraph.user_id == graphowner).filter(models.GraphToTag.user_id == graphowner).filter(models.GroupToGraph.group_id == group_id).filter(models.GroupToGraph.group_owner == group_owner).first() + + # If it is not shared, then set "graph_shared" to False + if is_shared_with_group == None: + group_info.append({"group_name": group_name, "group_owner": group_owner, "group_id": group_id, "graph_shared": False}) + else: + group_info.append({"group_name": group_name, "group_owner": group_owner, "group_id": group_id, "graph_shared": True}) + + db_session.close() + return group_info + +def updateSharingInformationForGraph(owner, gid, groups_to_share_with, groups_not_to_share_with): + ''' + Shares specified graph with all groups to share with. Unshares specified graph with all groups to unshare with. + :param owner: Owner of graph + :param grpahname: Name of graph + :param groups_to_share_with: Groups to share with ** have form of [groupName_groupOwner,....] + :param groups_not_to_share_with: Groups not to share with ** have form of [groupName_groupOwner,....] + ''' + for group in groups_to_share_with: + groupInfo = group.split("12345__43121__") + + for group in groups_not_to_share_with: + groupInfo = group.split("12345__43121__") + unshare_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) + +def add_user_to_group(username, owner, group): + ''' + Adds a user to a group. + + :param username: Username to add to group + :param owner: Owner of the group + :param group: Group ID + :return + ''' + # Create database connection + db_session = data_connection.new_session() + + user = db_session.query(models.User).filter(models.User.user_id == username).first() + + if user == None: + db_session.close() + return "User does not exist!" + + # Is user a member of the group + isMember = db_session.query(models.GroupToUser.user_id).filter(models.GroupToUser.user_id == owner).filter(models.GroupToUser.group_id == group).first() + + # Is user an owner of the group + isOwner = db_session.query(models.Group.owner_id).filter(models.Group.owner_id == owner).filter(models.Group.group_id == group).first() + + message = "" + + # User must be an owner of a member of a group to add members to it + if isMember != None or isOwner != None: + new_group_member = models.GroupToUser(group_id = group, group_owner = owner, user_id = username) + db_session.add(new_group_member) + db_session.commit() + message = "Successfully added user " + username + " to " + group + "." + else: + message = "Become the owner or a member of this group first!" + + db_session.close() + return message + +def remove_user_from_group(username, owner, groupId): + ''' + Removes user from group. + + :param username: User to remove + :param owner: Owner of group + :param groupId: Group ID + :return + ''' + + # Create database connection + db_session = data_connection.new_session() + + # Check to see if user exists + user = db_session.query(models.User).filter(models.User.user_id == username).first() + + if user == None: + db_session.close() + return "User does not exist!" + + # Check to see if group exists + group = db_session.query(models.Group).filter(models.Group.group_id == groupId).filter(models.Group.owner_id == owner).first() + + if group == None: + db_session.close() + return "Group does not exist!" + + # Check to see if member in that group actually exists + group_member = db_session.query(models.GroupToUser).filter(models.GroupToUser.group_id == groupId).filter(models.GroupToUser.group_owner == owner).first() + + if group_member == None: + db_session.close() + return "Group member does not exist" + + db_session.delete(group_member) + db_session.commit() + db_session.close() + return "Successfully removed user " + username + " from " + groupId + "." + +def remove_user_through_ui(username, owner, group): + ''' + Removes user from group through UI. + + :param username: User to remove + :param owner: Owner of group + :param group: Group ID + :return + ''' + return remove_user_from_group(username, owner, group) + +def share_graph_with_group(owner, graph, groupId, groupOwner): + ''' + Shares a graph with group. + + :param owner: Owner of group + :param graph: Graph to share + :param groupId: Group ID + :param groupOwner: Group Owner + :return + ''' + + # Get graph + graph_exists = get_graph(owner, graph) + + if graph_exists == None: + return "Graph does not exist" + + # Check to see if the group exists + group_exists = get_group(groupOwner, groupId) + + if group_exists == None: + return "Group does not exist" + + # Create database connection + db_session = data_connection.new_session() + + group_name_exists = db_session.query(models.Group).filter(models.Group.group_id == groupId).first() + + if group_name_exists != None and group_name_exists.owner_id != groupOwner: + return "Group exists but the group owner provided doesn't own this group" + + # Is graph already shared + shared_graph = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).filter(models.GroupToGraph.graph_id == graph).filter(models.GroupToGraph.user_id == owner).first() + + # Graph is already shared + if shared_graph != None: + return None + + # Is a user a member of the group trying to share graph with + group_member = db_session.query(models.GroupToUser).filter(models.GroupToUser.user_id == owner).filter(models.GroupToUser.group_id == groupId).filter(models.GroupToUser.group_owner == groupOwner).first() + + # Is a user the owner of a group + group_owner = db_session.query(models.Group.owner_id).filter(models.Group.owner_id == groupOwner).filter(models.Group.group_id == groupId).first() + + # If they're an owner or a group member, they can add graph to the group + if group_owner != None or group_member != None: + new_shared_graph = models.GroupToGraph(group_id = groupId, group_owner = groupOwner, user_id = owner, graph_id = graph, modified = graph_exists.modified) + + db_session.add(new_shared_graph) + db_session.commit() + else: + return "You must be the owner or a member of this group in order to share graphs with it." + + db_session.close() + return None + +def unshare_graph_with_group(owner, graph, groupId, groupOwner): + ''' + Graph to unshare with group. + + :param owner: Owner of group + :param graph: Graph to unshare + :param groupId: Group ID + :param groupOwner: Group Owner + :return + ''' + + # Get graph + graph_exists = get_graph(owner, graph) + + if graph_exists == None: + return "Graph does not exist!" + + # Check to see if the group exists + group_exists = get_group(groupOwner, groupId) + + if group_exists == None: + return "Group does not exist" + + # Create database connection + db_session = data_connection.new_session() + + group_name_exists = db_session.query(models.Group).filter(models.Group.group_id == groupId).first() + + if group_name_exists != None and group_name_exists.owner_id != groupOwner: + return "Group exists but the group owner provided doesn't own this group" + + # Is this graph already shared with the group? + is_shared_with_group = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.graph_id == graph).filter(models.GroupToGraph.user_id == owner).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).first() + + # If graph is not shared with group + if is_shared_with_group == None: + db_session.close() + return "Can't unshare a graph that is not currently shared with the group" + + # Is a user a member of the group trying to share graph with + group_member = db_session.query(models.GroupToUser).filter(models.GroupToUser.user_id == owner).filter(models.GroupToUser.group_id == groupId).filter(models.GroupToUser.group_owner == groupOwner).first() + + # Is a user the owner of a group + group_owner = db_session.query(models.Group.owner_id).filter(models.Group.owner_id == groupOwner).filter(models.Group.group_id == groupId).first() + + if group_owner != None or group_member != None: + + # Unshare the graph + db_session.delete(is_shared_with_group) + db_session.commit() + else: + return "You must be the owner or a member of this group in order to unshare graphs with it." + + db_session.close() + return None + +# ---------------- END REST API ------------------------------ + +def view_graphs_of_type(view_type, username): + ''' + View graphs of this type e.g. shared, my graphs, public. + + :param view_type: Type of view (shared, public) + :param username: Name of user + :return Graphs: [graphs] + ''' + + # Create database connection + db_session = data_connection.new_session() + + graphs = [] + + # Select graphs depending on view_type + if view_type == "public": + # Get all public graphs + try: + graphs = db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).distinct(models.Graph.graph_id).filter(models.Graph.public == 1).all() + except NoResultFound: + print "No public graphs" + + elif view_type == "shared": + try: + graphs = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.modified, models.GroupToGraph.user_id).distinct(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.group_id == models.GroupToUser.group_id).filter(models.GroupToGraph.group_owner == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == username).all() + except NoResultFound: + print "No shared graphs" + else: + try: + # Get all my graphs + graphs = db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).filter(models.Graph.user_id == username).all() + except NoResultFound: + print "No owned graphs" + + return graphs + + db_session.close() + return cleaned_graphs + +def is_public_graph(username, graph): + ''' + Checks to see if a given graph for a user is public. + + :param username: Email of user + :param graph: Graph of user + :return boolean: True if public graph + ''' + + # Get the graph + graph = get_graph(username, graph) + + # If no graph is found, return None + if graph == None: + return None + + # Return true if public, false otherwise + if graph.public == 1: + return True + else: + return False + +def get_all_groups_for_this_graph(uid, graph): + ''' + Gets all the groups that the graph is shared with. + + :param uid: User of the graph + :param graph: Name of graph + :return Groups: [groups] + ''' + + # Get database connection + db_session = data_connection.new_session() + + try: + # Get all groups that this graph is shared with + shared_groups = db_session.query(models.Group).filter(models.GroupToGraph.graph_id == graph).filter(models.GroupToGraph.user_id == uid).filter(models.GroupToGraph.group_id == models.Group.group_id).filter(models.GroupToGraph.group_owner == models.Group.owner_id).all() + db_session.close() + return shared_groups + except NoResultFound: + db_session.close() + return None + +def change_graph_visibility(isPublic, user_id, graphName): + ''' + Makes specified graph and all associated layouts public or private + + :param isPublic boolean that decides if graph is made public or private (0 if private, 1 if public) + :param user_id ID of owner of graph + :param graphName name of graph to make public + ''' + + # Create database connection + db_session = data_connection.new_session() + + # Get the graph + graph = db_session.query(models.Graph).filter(models.Graph.graph_id == graphName).filter(models.Graph.user_id == user_id).first() + + # If it doesn't exist + if graph == None: + db_session.close() + return "Graph with name " + graphName + " doesn't exist under " + user_id + '.' + + # Update property + graph.public = isPublic + db_session.commit() + + try: + # Change all layouts visibility for a graph + layouts = db_session.query(models.Layout).filter(models.Layout.graph_id == graphName).filter(models.Layout.user_id == user_id).filter(models.Layout.shared_with_groups == 1).all() + for layout in layouts: + layout.public = isPublic + db_session.commit() + + except NoResultFound: + print 'No shared layouts for this graph' + + db_session.close() + + +# Changes the name of a layout +def changeLayoutName(uid, gid, old_layout_name, new_layout_name, loggedIn): + ''' + Changes the name of the layout. + + :param uid: Owner of graph + :param gid: Name of graph + :param old_layout_name: Old name of layout + :param new_layout_name: New name of layout + :param loggedIn: User making those changes + ''' + # Create database connection + db_session = data_connection.new_session() + + # Get the layout + new_layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.owner_id == loggedIn).filter(models.Layout.layout_name == old_layout_name).first() + + # Check to see if there already is a layout with that name for this user + check_layout_name = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.owner_id == loggedIn).filter(models.Layout.layout_name == new_layout_name).first() + + if check_layout_name != None: + db_session.close() + return "Can't change layout name to " + new_layout_name + " because you already have a layout with that name for this graph." + + # Change the name + if new_layout != None: + new_layout.layout_name = new_layout_name + db_session.commit() + + db_session.close() + return None + +def makeLayoutPublic(uid, gid, public_layout, layout_owner): + ''' + Makes a layout public. + + :param uid: Owner of graph + :param gid: Name of graph + :param public_layout: Name of layout + :param layout_owner: Owner of layout + ''' + # Create database connection + db_session = data_connection.new_session() + + # Get layouts to make public + layout = db_session.query(models.Layout).filter(models.Layout.layout_name == public_layout).filter(models.Layout.user_id == uid).filter(models.Layout.graph_id == gid).filter(models.Layout.owner_id == layout_owner).first() + + # If layout exists, make it public + if layout != None: + if layout.public == 1: + layout.public = 0 + + # Get graph + graph = db_session.query(models.Graph).filter(models.Graph.graph_id == gid).filter(models.Graph.user_id == uid).first() + + # If layout isn't public, remove it as default id + if graph != None: + if graph.default_layout_id == layout.layout_id: + graph.default_layout_id = None + else: + layout.public = 1 + + db_session.commit() + + db_session.close() + +def update_layout(graph_id, graph_owner, layout_name, layout_owner, json, public, shared_with_groups, originalLayout): + ''' + Update layout of specific graph. + + :param graph_id: Name of the graph + :param graph_owner: Owner of the graph + :param layout_name: Name of layout to save + :param layout_owner: Owner of layout + :param json: JSON of the graph + :param public: Is layout public or not + :param shared_with_groups: Is layout shared with groups + ''' + # Create database connection + db_session = data_connection.new_session() + + # Checks to see if there is a layout for this specific graph and the same layout name which the person saving the layout already owns + layout = db_session.query(models.Layout).filter(models.Layout.graph_id == graph_id).filter(models.Layout.user_id == graph_owner).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).first() + + # If no such layout exists, add it + if layout != None: + layout.graph_id = graph_id + layout.user_id = graph_owner + layout.layout_name = layout_name + layout.layout_owner = layout_owner + layout.json = json + layout.public = public + layout.shared_with_groups = shared_with_groups + layout.times_modified += 1 + layout.original_json = originalLayout + db_session.commit() + + computeFeatures(graph_owner, graph_id, layout_name, layout_owner) + else: + return "Layout not found!" + + db_session.close() + +def save_layout(graph_id, graph_owner, layout_name, layout_owner, json, public, shared_with_groups, default=False): + ''' + Saves layout of specific graph. + + :param graph_id: Name of the graph + :param graph_owner: Owner of the graph + :param layout_name: Name of layout to save + :param layout_owner: Owner of layout + :param json: JSON of the graph + :param public: Is layout public or not + :param shared_with_groups: Is layout shared with groups + :param default: Is layout default for given graph_id + ''' + # Create database connection + db_session = data_connection.new_session() + + # Checks to see if there is a layout for this specific graph and the same layout name which the person saving the layout already owns + layout = db_session.query(models.Layout).filter(models.Layout.graph_id == graph_id).filter(models.Layout.user_id == graph_owner).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).first() + + # If no such layout exists, add it + if layout != None: + return "Layout with this name already exists for this graph! Please choose another name." + + # Add the new layout + new_layout = models.Layout(layout_id = None, layout_name = layout_name, owner_id = layout_owner, graph_id = graph_id, user_id = graph_owner, json = json, public = public, shared_with_groups = shared_with_groups, times_modified=0, original_json=None) + db_session.add(new_layout) + db_session.commit() + + if default: + db_session.refresh(new_layout) + a = db_session.query(models.Graph).filter(models.Graph.graph_id == graph_id).update({ + "default_layout_id": new_layout.layout_id + }) + db_session.commit() + + db_session.close() + +def deleteLayout(uid, gid, layoutToDelete, layout_owner): + ''' + Deletes layout from graph. + + :param uid: Owner of graph + :param gid: Name of graph + :param layoutToDelete: name of layout to delete + :param layout_owner: User that is deleting the graph + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Get the specific layout + layout = db_session.query(models.Layout).filter(models.Layout.layout_name == layoutToDelete).filter(models.Layout.user_id == uid).filter(models.Layout.graph_id == gid).filter(models.Layout.owner_id == layout_owner).first() + + if layout == None: + return "Layout does not exist!" + + # Get graph which may contain a layout + graph = db_session.query(models.Graph).filter(models.Graph.graph_id == gid).filter(models.Graph.user_id == uid).first() + + if graph == None: + return "Graph does not exist!" + + # If layout being deleted is graphs default layout, remove both + if graph.default_layout_id == layout.layout_id: + graph.default_layout_id = None + db_session.commit() + + db_session.delete(layout) + db_session.commit() + + db_session.close() + return None + except Exception as ex: + db_session.close() + return ex + +def get_layout_for_graph(layout_name, layout_owner, graph_id, graph_owner, loggedIn): + ''' + Retrieves specific layout for a certain graph. + + :param layout_name: Name of layout + :param layout_owner: Owner of layout + :param graph_id: Name of graph + :param graph_owner: Owner of graph + :param loggedIn: Logged in user + ''' + # Create database connection + db_session = data_connection.new_session() + + # If the person viewing the layout is not the graph owner or the graph is not public + if loggedIn != graph_owner and is_public_graph(graph_owner, graph_id) != True: + # Check to see if user is a member of any groups that graph is shared with + user_is_member = can_see_shared_graph(loggedIn, graph_owner, graph_id) + + # If user is not a member, don't display layout + if user_is_member == None: + return None + + # Get layout for graph if it exists + layout = db_session.query(models.Layout).filter(models.Layout.layout_name == layout_name).filter(models.Layout.graph_id == graph_id).filter(models.Layout.user_id == graph_owner).filter(models.Layout.owner_id == layout_owner).first() + + if layout == None: + db_session.close() + return None + else: + db_session.close() + return cytoscapePresetLayout(json.loads(layout.json)) + +def getLayoutById(layout_id): + # Create database connection + db_session = data_connection.new_session() + layout = db_session.query(models.Layout).filter(models.Layout.layout_id == layout_id).first() + db_session.close() + return layout + +def cytoscapePresetLayout(csWebJson): + ''' + Converts CytoscapeWeb preset layouts to be + the standards of CytoscapeJS JSON. See http://js.cytoscape.org/#layouts/preset + for more details. + + :param csWebJson: A CytoscapeWeb compatible layout json containing coordinates of the nodes + :return csJson: A CytoscapeJS compatible layout json containing coordinates of the nodes + ''' + csJson = {} + + # csWebJSON format: [{x: x coordinate of node, y: y coordinate of node, id: id of node},...] + # csJson format: [id of node: {x: x coordinate of node, y: y coordinate of node},...] + + for node_position in csWebJson: + + csJson[str(node_position['id'])] = { + 'x': node_position['x'], + 'y': node_position['y'] + }; + + if 'background_color' in node_position: + csJson[str(node_position['id'])]["background_color"] = node_position['background_color'] + + if 'shape' in node_position: + csJson[str(node_position['id'])]['shape'] = node_position['shape'] + + return json.dumps(csJson) + +def get_all_layouts_for_graph(uid, gid): + ''' + Get all layouts for graph. + + :param uid: Owner of graph + :param gid: Name of graph + :return Layouts: [layouts of graph] + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Get layouts for graph + layouts = db_session.query(models.Layout).filter(models.Layout.owner_id == owner).filter(models.Layout.graph_id == gid).all() + + # Get rid of unicode + cleaned_layouts = [] + for layout in layouts: + cleaned_layouts.append(layout.layout_name) + + db_session.close() + return layout + + except NoResultFound: + db_session.close() + return None + +def share_layout_with_all_groups_of_user(owner, gid, layoutId, layout_owner): + ''' + Shares a layout with all the groups that owner of a graph is a part of. + + :param owner: Owner of graph + :param gid: Name of graph + :param layoutId: Layout of the graph + :param layout_owner: layout_owner of Layout + ''' + # Create database connection + db_session = data_connection.new_session() + + # If layout was the default graph layout, then we have to clear that entry + graph = db_session.query(models.Graph).filter(models.Graph.user_id == owner).filter(models.Graph.graph_id == gid).first() + + if graph == None: + return None + + # Get layout if it exists + layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.layout_name == layoutId).filter(models.Layout.user_id == owner).filter(models.Layout.owner_id == layout_owner).first() + + if layout == None: + return None + + # If the current layout is not shared with the group, share it + if layout.shared_with_groups == 0: + layout.shared_with_groups = 1 + else: + # If it is shared, then unshare it + layout.shared_with_groups = 0 + layout.public = 0 + + # If layout to be unshared is a default layout + # remove it as a default layout + if graph.default_layout_id == layout.layout_id: + graph.default_layout_id = None + db_session.commit() + + print graph.default_layout_id + db_session.commit() + db_session.close() + return None + +# Gets my layouts for a graph +def get_my_layouts_for_graph(uid, gid, loggedIn): + ''' + Get my layouts for this graph. + + :param uid: Owner of graph + :param gid: Name of graph + :param loggedIn: Current user of graphspace + :return Layouts: [my layouts of graph] + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Get all layouts for graph that user created + layouts = db_session.query(models.Layout).filter(models.Layout.owner_id == loggedIn).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.shared_with_groups == 0).filter(models.Layout.public == 0).all() + + db_session.close() + return layouts + + except NoResultFound: + db_session.close() + return None + +def get_shared_layouts_for_graph(uid, gid, loggedIn): + ''' + Get shared layouts for this graph. + + :param uid: Owner of graph + :param gid: Name of graph + :param loggedIn: Current user of graphspace + :return Layouts: [shared layouts of graph] + ''' + + # Create database connection + db_session = data_connection.new_session() + + try: + layout_names = [] + # Get all groups this graph is shared with + all_groups_for_graph = get_all_groups_for_this_graph(uid, gid) + + # Get all groups that the user is a member of + all_groups_for_user = get_all_groups_with_member(loggedIn, skip = True) + + group_dict = dict() + + # Get all groups shared with this graph, removing all duplicates + for group in all_groups_for_graph: + key = group.group_id + group.owner_id + + if key not in group_dict: + group_dict[key] = group + + # Get all groups that the user can see + for group in all_groups_for_user: + key = group.group_id + group.owner_id + + if key in group_dict: + + # If the current user is a member of any groups that have current graph shared in + # for group in all_groups_for_graph: + layout_names = db_session.query(models.Layout).filter(models.Layout.user_id == uid).filter(models.Layout.graph_id == gid).filter(models.Layout.shared_with_groups == 1).all() + + db_session.close() + return layout_names + except NoResultFound: + db_session.close() + return [] + +def get_my_shared_layouts_for_graph(uid, gid, loggedIn): + ''' + Get shared layouts of the graph owner for this graph. + + :param uid: Owner of graph + :param gid: Name of graph + :param loggedIn: Current user of graphspace + :return Layouts: [shared layouts of graph] + ''' + + # Create database connection + db_session = data_connection.new_session() + + try: + # In the database, we define unlisted as the parameter to determine if a certain + # layout is shared within all groups that the graph is shared with and + # public to determine whether everyone is allowed access to the layout. + # If the graph is public, all shared layouts should be public as well, therefore + # we collect all shared and public layouts. + # Note: This is done as a second-measure step and it shouldn't ever matter + # because all layouts are set to public when the graph is set to public + shared_layouts = db_session.query(models.Layout).distinct(models.Layout.layout_name).filter(models.Layout.user_id == uid).filter(models.Layout.owner_id == loggedIn).filter(models.Layout.graph_id == gid).filter(or_(models.Layout.shared_with_groups == 1, models.Layout.public == 1)).all() + + db_session.close() + return shared_layouts + except NoResultFound: + db_session.close() + return None + +def get_public_layouts_for_graph(uid, gid): + ''' + Get public layouts for this graph. + + :param uid: Owner of graph + :param gid: Name of graph + :return Layouts: [public layouts of graph] + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Get all the public layouts for a specific graph + public_layouts = db_session.query(models.Layout).filter(models.Layout.user_id == uid).filter(models.Layout.graph_id == gid).filter(models.Layout.public == 1).all() + + db_session.close() + return public_layouts + except NoResultFound: + db_session.close() + return [] + +def get_all_graphs_for_tags(tags): + ''' + Get all graphs that match the tags. + + :param tags: [List of tags to match] + :return Graphs: [graphs that contain these tags] + ''' + + # Split tags into a list + if tags: + tag_terms = tags.split(',') + for i in xrange(len(tag_terms)): + tag_terms[i] = tag_terms[i].strip() + + # Create database connection + db_session = data_connection.new_session() + + try: + graph_list = [] + actual_graphs_for_tags_list = [] + + # Go through each individual tag + for tag in tag_terms: + # Get all graphs that contain the specific tag we are searching for + graph_list += db_session.query(models.Graph.graph_id).distinct(models.Graph.graph_id).filter(models.GraphToTag.tag_id == tag).filter(models.GraphToTag.graph_id == models.Graph.graph_id).all() + + # Get number of times the graph names appear. + # If they appear the same number of times as the lenght of the tag terms + # it implicitly means that the graph has all of the tags that are being searched for. + accurate_tags = Counter(graphs_for_tag_list) + for graph in graphs_for_tag_list: + if accurate_tags[graph] == len(tag_terms): + actual_graphs_for_tags.append(graph[0]) + + db_session.close() + return actual_graphs_for_tags + + except NoResultFound: + db_session.close() + return None + else: + return None + +def get_all_tags_for_user(username): + ''' + Return all tags that a user has for their graphs. + + :param username: Email of user in GraphSpace + :return Tags: [tags] + ''' + # Get database connection + db_session = data_connection.new_session() + + try: + # Get tags that the user has and return them + tag_list = db_session.query(models.GraphToTag.tag_id).distinct(models.GraphToTag.tag_id).filter(models.GraphToTag.user_id == username).all() + + cleaned_tag_list = [] + + # Get string from unicode so that I can parse it easier + for tag in tag_list: + cleaned_tag_list.append(str(tag[0])) + + db_session.close() + return cleaned_tag_list + except NoResultFound: + db_session.close() + return None + +def get_all_tags_for_graph(graphname, username): + ''' + Returns all of the tags for a specific graph. + + :param graphname: Name of graph to search for + :param username: Email of user in GraphSpace + :return Tags: [tags of graph] + ''' + # Get database connection + db_session = data_connection.new_session() + + try: + # Get graph to see if it exists + graph_exists = db_session.query(models.Graph).filter(models.Graph.graph_id == graphname).filter(models.Graph.user_id == username).first() + + if graph_exists == None: + return [] + + # Retrieves all tags that match a given graph + tag_list = db_session.query(models.GraphToTag.tag_id).distinct(models.GraphToTag.tag_id).filter(models.GraphToTag.user_id == username).filter(models.GraphToTag.graph_id == graphname).all() + + cleaned_tag_list = [] + + # Get string from unicode so that I can parse it easier + for tag in tag_list: + cleaned_tag_list.append(str(tag[0])) + + db_session.close() + return cleaned_tag_list + except NoResultFound: + db_session.close() + return [] + +def change_graph_visibility_for_tag(isPublic, tagname, username): + ''' + Makes all graphs under a tag owned by username public. + + :param isPublic: If graphs are to be made public (0 for private, 1 for public) + :param tagname: Name of tag to search for + :param username: Email of user in GraphSpace + :return + ''' + # Get database connection + db_session = data_connection.new_session() + + try: + # Get all the graphs that user OWNS which contain the matched tags + # Note: Two people using same tag don't have to worry about their + # graphs changing visiblity because we only change the visibility + # of the graph the person is making the request owns + + # Go through all these graphs and change their public column. + # This means that they are visible or private depending on the boolean bit + # associated in their public column (See Graph table) + graph_list = db_session.query(models.Graph).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).filter(models.Graph.user_id == username).filter(models.Graph.graph_id == models.GraphToTag.graph_id).all() + + for graph in graph_list: + graph.public = isPublic + + # Go through all these nodes for graphs and change their public column. + # This means that they are visible or private depending on the boolean bit + # associated in their public column (See Graph table) + # NOTE: I had this originally, but is this even necessary? + node_list = db_session.query(models.Node).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).filter(models.Node.user_id == username).filter(models.Node.graph_id == models.GraphToTag.graph_id).all() + + # Change the visibility of all the layouts that are associated with a graph + layout_list = db_session.query(models.Layout).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).filter(models.Layout.user_id == username).filter(models.Layout.graph_id == models.GraphToTag.graph_id).all() + + for layout_graph in layout_list: + layout_graph.public = isPublic + + db_session.commit() + db_session.close() + except NoResultFound: + print "No graphs that match those tags for the user" + db_session.close() + return None + +def get_visibility_of_graph(user_id, graph_id): + ''' + Gets the publicity of a graph. + + @param user_id: Owner of graph + @param graph_id: Name of graph + ''' + db_session = data_connection.new_session() + public = db_session.query(models.Graph.public).filter(models.Graph.user_id == user_id).filter(models.Graph.graph_id == graph_id).first() + db_session.close() + + # If the graph we query for doesn't exist, return empty bracket + if public == None: + return [] + return public[0] + +def delete_all_graphs_for_tag(tagname, username): + ''' + Deletes all graphs under a tag owned by username. + + :param tagname: Name of tag to search for + :param username: Email of user in GraphSpace + :return + ''' + # Create connection to database + db_session = data_connection.new_session() + + try: + # Get all the graphs that the user owns which match the tag + graph_list = db_session.query(models.Graph).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).filter(models.Graph.graph_id == models.GraphToTag.graph_id).filter(models.Graph.user_id == models.GraphToTag.user_id).all() + + # Delete all these graphs from the graphs table + for graph in graph_list: + db_session.delete(graph) + + # Get all the rows in graph_to_tag that the user owns + delete_tags = db_session.query(models.GraphToTag).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).all() + + # Delete all those rows from graph_to_tag database + for tag_to_delete in delete_tags: + db_session.delete(tag_to_delete) + + db_session.commit() + db_session.close() + return "Done" + except Exception as ex: + print ex + db_session.close() + return None + +def getGraphInfo(uid, gid): + ''' + Returns the json, visibility, and Id of the graph. + + :param uid: Owner of graph + :param gid: Graph Id + + :return json, visibility, graph_id + ''' + + # Create connection with database + db_session = data_connection.new_session() + + try: + # Retrieves json, public (visibility), and graph id of graph + data = db_session.query(models.Graph.json, models.Graph.public, models.Graph.graph_id).filter(models.Graph.graph_id == gid).filter(models.Graph.user_id == uid).one() + data = list(data) + data[0] = verify_json(data[0]) + + db_session.close() + return data + except Exception as ex: + print uid, gid + print "Error in Graph Info: " + str(ex) + db_session.close() + return None + +def retrieveJSON(uid, gid): + ''' + Retrieves JSON of graph. + + :param uid: Graph owner + :param gid: Graph Id + + :return JSON + ''' + # Create connection with database + db_session = data_connection.new_session() + + try: + # Returns json if it exists, otherwise nothing + data = db_session.query(models.Graph.json).filter(models.Graph.user_id == uid).filter(models.Graph.graph_id == gid).one() + db_session.close() + return data + except Exception as ex: + print "No JSON found for " + gid + print "Error " + ex + db_session.close() + return None + +def insert_user(user_id, password, admin): + ''' + Inserts user into database if they do not exist. + + :param user_id: Email of user + :param password: Password of user + :param admin: is user an admin? + + :return None if user already exists + ''' + # Create database connection + db_session = data_connection.new_session() + + try: + # Creates a new user with given information + new_user = models.User(user_id=user_id, password = password, admin = admin) + db_session.add(new_user) + db_session.commit() + db_session.close() + except Exception as ex: + # If user already exists, don't create another user with same email + print ex + db_session.close() + return None + +def evalQuality(numChanges, timeSpent, numEvents): + ''' + Evaluates the quality of the work given 3 features. + + @param numChanges: Number of movements of graph elements + @param timeSpent: Amount of time spent on the task + @param numEvents: Number of UI element interactions + + @return True iff quality is sufficient + ''' + nb = NB() + + nb.train_classifier() + + return nb.classify(numChanges, timeSpent, numEvents) + +def computeFeatures(uid, gid, layout_name, layout_owner): + ''' + Computes all features for a layout worked on by worker. + + @param uid: Owner of graph + @param gid: Name of graph + @param layout_name: Name of layout + @param layout_owner: Owner of layout + ''' + + db_session = data_connection.new_session() + + layout = db_session.query(models.Layout).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).first() + + if layout == None: + return None + + + # Compute distance moved for each node + distance_vector = [] + + # Pairwise distance between each node + pairwise_vector = [] + + if layout.original_json != None: + origJson = json.loads(layout.original_json) + newJson = json.loads(layout.json) + + for orig_key in origJson: + orig_x = origJson[orig_key]["x"] + orig_y = origJson[orig_key]["y"] + + for obj in newJson: + new_x = obj["x"] + new_y = obj["y"] + + if orig_key == obj["id"]: + distance_vector.append(distance(orig_x, orig_y, new_x, new_y)) + + else: + pairwise_vector.append(distance(orig_x, orig_y, new_x, new_y)) + + db_session.close() + + return [distance_vector, pairwise_vector] + +def distance(x1, y1, x2, y2): + return math.sqrt((x2 - x1)**2 + (y2 - y1)**2) + +def retrieveTaskCode(uid, gid, worked_layout, numChanges, timeSpent, events, hit_id): + ''' + Retrieves task code. + ''' + + db_session = data_connection.new_session() + + # Get the layout from the database + layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.layout_name == worked_layout).filter(models.Layout.owner_id == "MTURK_Worker").first() + + # If layout doesn't exist, exit out + if layout == None: + return None + + # Get features of the task + features = computeFeatures(uid, gid, worked_layout, "MTURK_Worker") + + # Store them in database + new_feature_vector = models.Feature(id=None, user_id = uid, graph_id = gid, layout_id = layout.layout_id, created=datetime.now(), distance_vector=json.dumps(features[0]), pairwise_vector=json.dumps(features[1]), num_changes=numChanges, time_spent=timeSpent, events=events) + db_session.add(new_feature_vector) + db_session.commit() + + # Basic error checking to see if worker deserves pay + if numChanges < 50 and timeSpent < 100 and len(events) < 50: + return "Not enough work done to complete task!" + + # Create database connection + db_session = data_connection.new_session() + + # Get the task associated for this graph + task = db_session.query(models.Task).filter(models.Task.hit_id == hit_id).filter(models.Task.task_type == "LAYOUT_TASK").first() + submit = task.submitted + + # If task doesn't exist, exit out + if task == None: + return None + + # Once task is complete, delete it from the database + db_session.delete(task) + db_session.commit() + + # Update the modified count for the layout + layout.times_modified += 1 + db_session.commit() + + # Launch another task on MTURK if the layout hasn't been modified at least 5 times + # if layout.times_modified < 5: + # launchTask(gid, uid, [layout.json], single=True, submitted=submit + 1) + + task_code = db_session.query(models.TaskCode.code).filter(models.TaskCode.hit_id == hit_id).first() + db_session.close() + + # launch approval tasks + launchApprovalTask(uid, gid, layout.layout_id) + + return task_code + +def createTaskCode(db_session, hitId): + + # Generate task code + taskCode = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(20)) + new_code = models.TaskCode(code=taskCode, created=datetime.now(), hit_id = hitId) + db_session.add(new_code) + db_session.commit() + +def getCrowdEnabledGroup(): + ''' + All users in this special group may launch tasks to GraphCrowd. + ''' + + db_session = data_connection.new_session() + + try: + #THIS IS A HARD-CODED GROUP TO GAIN ACCESS TO MTURK + allowed_users = db_session.query(models.User.user_id).filter(models.GroupToUser.user_id == models.User.user_id).filter(models.GroupToUser.group_id == "Crowd_Group").filter(models.GroupToUser.group_owner == "tmmurali@acm.org").all() + allowed_users_clean = [] + + # Only members of this group or owner of the group have access to Ask the Crowd as well as can see the Crowd option + for user in allowed_users: + allowed_users_clean.append(str(user[0])) + + # Let group owner also view the buttons + group_owner = db_session.query(models.User.user_id).filter(models.Group.group_id == "Crowd_Group").filter(models.Group.owner_id == "tmmurali@acm.org").filter(models.Group.owner_id == models.User.user_id).first() + if group_owner != None: + allowed_users_clean.append(group_owner[0]) + + return allowed_users_clean + except NoResultFound: + db_session.close() + return [] + db_session.close() + +def usernameMismatchError(): + ''' + Returns response telling user that their usernames (from the URL and username field in POST request) + do not match + ''' + return throwError(400, "Usernames do not match!") + +def userNotFoundError(): + ''' + Returns response telling user that their username and password combination is not found. + ''' + return throwError(401, "Username/Password is not recognized!") + +def throwError(statusCode, error): + ''' + Returns response to any error. + ''' + return constructResponse(statusCode, None, error) + +def sendMessage(statusCode, message): + ''' + Returns response to sucessful request. + ''' + return constructResponse(statusCode, message, None) + +def constructResponse(statusCode, message, error): + ''' + Constructs a response to send to the user. + + :param statusCode: Status coe of the request + :param message: Message to send to the user + :param error: Error to display to the user + :return + ''' + response = {"StatusCode": statusCode} + + if message != None: + response['Message'] = message + else: + response['Error'] = error + + return response diff --git a/build/lib/graphs/util/db_conn.py b/build/lib/graphs/util/db_conn.py new file mode 100644 index 00000000..ce5c2617 --- /dev/null +++ b/build/lib/graphs/util/db_conn.py @@ -0,0 +1,53 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import sqlalchemy +from django.conf import settings + +# database locations +_originaldb = settings.DATABASE_LOCATION + +class Database(object): + ''' + Create a database object to query from. + ''' + def __init__(self, db_type): + self.db = db_type + self.connection = None + + if self.db == 'prod': + self.engine = create_engine(_originaldb, echo=False) + else: + self.engine = create_engine('sqlite:///:memory:', echo=False) + + Session = sessionmaker(bind=self.engine) + self.session = Session() + + if self.db == 'prod': + self.meta = sqlalchemy.schema.MetaData() + self.meta.reflect(bind=self.engine) + else: + self.meta = None + + def new_session(self): + ''' + Create a new session in this database. This is needed to avoid 1 seesion per thread + error raised if you don't create a new session for every new page load or query request. + ''' + self.session.close() + Session = sessionmaker(bind=self.engine) + self.session = Session() + return self.session + + def connect(self): + ''' + Establish connection to the database engine. + ''' + self.connection = self.engine.connect() + return self.connection + + def close(self): + ''' + Close the connection to the database engine. + ''' + if self.connection is not None: + self.connection.close() \ No newline at end of file diff --git a/build/lib/graphs/util/db_init.py b/build/lib/graphs/util/db_init.py new file mode 100644 index 00000000..0bb87560 --- /dev/null +++ b/build/lib/graphs/util/db_init.py @@ -0,0 +1,19 @@ +''' + Module to initialize database access. +''' + +from graphs.util.db_conn import Database + +#connect to database +db = Database('prod') + +#get tables from database +graph = db.meta.tables['graph'] +node = db.meta.tables['node'] +edge = db.meta.tables['edge'] +user = db.meta.tables['user'] +group = db.meta.tables['group'] +group_to_graph = db.meta.tables['group_to_graph'] +group_to_user = db.meta.tables['group_to_user'] +graph_to_tag = db.meta.tables['graph_to_tag'] +password_reset = db.meta.tables['password_reset'] \ No newline at end of file diff --git a/build/lib/graphs/util/json_validator.py b/build/lib/graphs/util/json_validator.py new file mode 100644 index 00000000..b6034c92 --- /dev/null +++ b/build/lib/graphs/util/json_validator.py @@ -0,0 +1,390 @@ +import json +import re + +# See http://js.cytoscape.org/#style/node-body +ALLOWED_NODE_SHAPES = ['rectangle', 'roundrectangle', 'ellipse', 'triangle', + 'pentagon', 'hexagon', 'heptagon', 'octagon', 'star', + 'diamond', 'vee', 'rhomboid'] + +ALLOWED_NODE_BORDER_STYLES = ['solid', 'dotted', 'dashed', 'double'] + +ALLOWED_NODE_BACKGROUND_REPEAT = ['no-repeat', 'repeat-x', 'repeat-y', 'repeat'] + +ALLOWED_NODE_TEXT_TRANSFORM = ['none', 'uppercase', 'lowercase'] + +ALLOWED_NODE_TEXT_WRAP = ['none', 'wrap'] + +ALLOWED_TEXT_BACKROUND_SHAPE = ['rectangle', 'roundrectangle'] + +ALLOWED_TEXT_HALIGN = ['left', 'center', 'right'] + +ALLOWED_TEXT_VALIGN = ['top', 'center', 'bottom'] + +## See http://js.cytoscape.org/#style/labels +ALLOWED_TEXT_WRAP = ['wrap','none'] + +## See http://js.cytoscape.org/#style/edge-arrow +ALLOWED_ARROW_SHAPES = ['tee', 'triangle', 'triangle-tee', 'triangle-backcurve', + 'square', 'circle', 'diamond', 'none'] + +## See http://js.cytoscape.org/#style/edge-line +ALLOWED_EDGE_STYLES = ['solid', 'dotted','dashed'] + +ALLOWED_ARROW_FILL = ['filled', 'hollow'] + +NODE_COLOR_ATTRIBUTES = ['background_color', 'border_color', 'color', + 'text_outline_color', 'text_shadow_color', + 'text_border_color'] + +EDGE_COLOR_ATTRIBUTES = ['line_color', 'source_arrow_color', + 'mid_source_arrow_color', 'target_arrow_color', + 'mid_target_arrow_color'] + + +def verify_json(graph_json): + graph_json = json.loads(graph_json) + + for node in graph_json["graph"]["nodes"]: + node = node["data"] + + if "shape" in node: + shape = node["shape"].lower() + else: + shape = "ellipse" + + if shape not in ALLOWED_NODE_SHAPES: + shape = "ellipse" + + node["shape"] = shape + + return json.dumps(graph_json) + +def validate_json(graphJson): + """ + Validates JSON to see if all properties are consistent with API. + + @param graphJson: JSON of graph + """ + + cleaned_json = json.loads(graphJson) + + if 'data' in cleaned_json: + cleaned_json = convert_json(cleaned_json) + + if "graph" not in cleaned_json: + return "JSON of graph must have 'graph' property" + + if "nodes" not in cleaned_json["graph"]: + return "JSON of graph must have 'nodes' property" + + if not isinstance(cleaned_json["graph"]["nodes"], list): + return "Nodes property must contain an array" + + if "edges" not in cleaned_json["graph"]: + return "JSON of graph must have 'edges' property" + + if not isinstance(cleaned_json["graph"]["edges"], list): + return "Edges property must contain an array" + + # Validate all node properties + nodes = cleaned_json["graph"]["nodes"] + error = validate_node_properties(nodes) + + if error != None: + return error + + # Validate all edge properties + error = validate_edge_properties(cleaned_json["graph"]["edges"], nodes) + + if error != None: + return error + +def validate_edge_properties(edges, nodes): + """ + Validates all edge properties. + + @param edges: Array of edge objects (http://js.cytoscape.org) + """ + + error = "" + edge_id = None + node_list = [node["data"]["id"] for node in nodes] + # Go through all edges to verify if edges contain valid properties + # recognized by CytoscapeJS + for edge in edges: + edge = edge["data"] + + # Check if source and target node of an edge exist in JSON node list + if edge["source"] not in node_list or edge["target"] not in node_list: + return "For all edges source and target nodes should exist in node list" + + # If edge has no source and target nodes, throw error since they are required + if "source" not in edge or "target" not in edge: + return "All edges must have at least a source and target property. Please verify that all edges meet this requirement." + + # Check if source and target nodes are strings, integers or floats + if not (isinstance(edge["source"], (basestring, int, float)) and isinstance(edge["target"], (basestring, int, float))): + return "Source and target nodes of the edge must be strings, integers or floats" + + edge_id = "with source: " + str(edge["source"]) + "and target: " + str(edge["target"]) + + # If edge is directed, it must have a target_arrow_shape + if "directed" in edge and edge["directed"] == "true": + if "target_arrow_shape" not in edge: + return "Edge", edge_id, "must have a target_arrow_shape property if directed is set to true" + + if "source_arrow_shape" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["source_arrow_shape"], ALLOWED_ARROW_SHAPES) + + if "mid_source_arrow_shape" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["source_arrow_shape"], ALLOWED_ARROW_SHAPES) + + if "target_arrow_shape" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["target_arrow_shape"], ALLOWED_ARROW_SHAPES) + + if "mid_target_arrow_shape" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["mid_target_arrow_shape"], ALLOWED_ARROW_SHAPES) + + if "line_style" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["line_style"], ALLOWED_EDGE_STYLES) + + if "source_arrow_fill" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["source_arrow_fill"], ALLOWED_ARROW_FILL) + + if "mid_source_arrow_fill" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["mid_source_arrow_fill"], ALLOWED_ARROW_FILL) + + if "target_arrow_fill" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["target_arrow_fill"], ALLOWED_ARROW_FILL) + + if "mid_target_arrow_fill" in edge: + error += find_property_in_array("Edge", edge_id, edge, edge["mid_target_arrow_fill"], ALLOWED_ARROW_FILL) + + for attr in EDGE_COLOR_ATTRIBUTES: + if attr in edge: + error += check_color_hex(edge[attr]) + + if len(error) > 0: + return error + else: + return None + +def validate_node_properties(nodes): + """ + Validates all node properties. + + :param G: NetworkX object. + """ + + unique_ids = set() + + error = "" + + # Go through all nodes to verify if the nodes contain valid properties + # recognized by CytoscapeJS + for node in nodes: + node = node["data"] + # Check the data type of node, should be int, float or string + if not isinstance(node["id"], (basestring, int, float)): + return "All nodes must be strings, integers or floats" + + # Check to see if ID is in node + if "id" not in node: + return "All nodes must have a unique ID. Please verify that all nodes meet this requirement." + + if node["id"] not in unique_ids: + unique_ids.add(node["id"]) + else: + return "There are multiple nodes with ID: " + str(node["id"]) + ". Please make sure all node IDs are unique." + + # Checks shape of nodes to make sure it contains only legal shapes + if "shape" in node: + error += find_property_in_array("Node", node["id"], "shape", node["shape"], ALLOWED_NODE_SHAPES) + + # If node contains a border-style property, check to make sure it is + # a legal value + if "border_style" in node: + error += find_property_in_array("Node", node["id"], "border_style", node["border_style"], ALLOWED_NODE_BORDER_STYLES) + + # If node contains a background_black property, check to make sure + # they have values [-1, 1] + if "border_blacken" in node: + if node["border_blacken"] >= -1 and node["border_blacken"] <= -1: + error += "Node: " + str(node["id"]) + " contains illegal border_blacken value. Must be between [-1, 1]." + + if "background_repeat" in node: + error += find_property_in_array("Node", node["id"], "background_repeat", node["background_repeat"], ALLOWED_NODE_BACKGROUND_REPEAT) + + if "text_transform" in node: + error += find_property_in_array("Node", node["id"], "text_transform", node["text_transform"], ALLOWED_NODE_TEXT_TRANSFORM) + + if "text_wrap" in node: + error += find_property_in_array("Node", node["id"], "text_wrap", node["text_wrap"], ALLOWED_NODE_TEXT_WRAP) + + if "text_background_shape" in node: + error += find_property_in_array("Node", node["id"], "text_background_shape", node["text_background_shape"], ALLOWED_NODE_SHAPES) + + if "text_halign" in node: + error += find_property_in_array("Node", node["id"], "text_halign", node["text_halign"], ALLOWED_TEXT_HALIGN) + + if "text_valign" in node: + error += find_property_in_array("Node", node["id"], "text_valign", node["text_valign"], ALLOWED_TEXT_VALIGN) + + for attr in NODE_COLOR_ATTRIBUTES: + if attr in node: + error += check_color_hex(node[attr]) + + if len(error) > 0: + return error + else: + return None + + +def check_color_hex(color_code): + """ + Check the validity of the hexadecimal code of various node and edge color + related attributes. + + This function returns an error if the hexadecimal code is not of the format + '#XXX' or '#XXXXXX', i.e. hexadecimal color code is not valid. + + :param color_code: color code + """ + # if color name is given instead of hex code, no need to check its validity + if not color_code.startswith('#'): + return "" + valid = re.search(r'^#(?:[0-9a-fA-F]{3}){1,2}$', color_code) + if valid is None: + return color_code + ' is not a valid hex color code.' + else: + return "" + + +def find_property_in_array(elementType, key, prop, value, array): + """ + Goes through array to see if property is contained in the array. + + :param elementType: Node or an Edge + :param key: Key to search for in network + :param value: Value of key + :param prop: Name to search for in array + :param array: Array to search for property in + """ + if value not in array: + array_list = ",".join(array) + return elementType + " " + key + " contains illegal value for property: " + prop + ". Value given for this property was: " + value + ". Accepted values for property: " + prop + " are: [" + array_list + "]" + else: + return "" + +def assign_edge_ids(json_string): + ''' + Modifies all ID's of edges to be the names of the nodes that they are attached to. + + :param json_string: JSON of graph + :return json_string: JSON of graph having unique ID's for all edges + ''' + + ids = [] + # Creates ID's for all of the edges by creating utilizing the source and target nodes + # The edge ID would have the form: source-target + for edge in json_string['graph']['edges']: + # To make sure int and floats are also accepted as source and target nodes of an edge + source_node = str(edge['data']['source']) + target_node = str(edge['data']['target']) + edge['data']['id'] = source_node + '-' + target_node + + # If the ID has not yet been seen (is unique), simply store the ID + # of that edge as source-target + if edge['data']['id'] not in ids: + ids.append(edge['data']['id']) + else: + # Otherwise if there are multiple edges with the same ID, + # append a number to the end of the ID so we can distinguish + # multiple edges having the same source and target. + # This needs to be done because HTML DOM needs unique IDs. + counter = 0 + while edge['data']['id'] in ids: + counter += 1 + edge['data']['id'] = edge['data']['id'] + str(counter) + ids.append(edge['data']['id']) + + # Return JSON having all edges containing unique ID's + return json_string + +# This file is a wrapper to communicate with sqlite3 database +# that does not need authentication for connection. + +# It may be viewed as the controller to the database + +def convert_json(original_json): + ''' + Converts original_json that's used in Cytoscape Web + such that it is compatible with the new Cytoscape.js + + See: http://cytoscape.github.io/cytoscape.js/ + + Original json structure used for Cytoscape Web: + { + "metadata": { + + }, + + "graph": { + "data": { + "nodes": [ + { "id": "node1", "label": "n1", ... }, + { "id": "node2", "label": "n2", ... }, + ... + ], + "edges": [ + { "id": "edge1", "label": "e1", ... }, + { "id": "edge2", "label": "e2", ... }, + ... + ] + } + } + } + + New json structure: + { + "metadata": { + + }, + + "graph": { + "nodes": [ + {"data": {"id": "node1", "label": "n1", ...}}, + {"data": {"id": "node2", "label": "n2", ...}}, + ... + ], + "edges": [ + {"data": {"id": "edge1", "label": "e1", ...}}, + {"data": {"id": "edge2", "label": "e2", ...}}, + ... + ] + } + } + ''' + + #parse old json data + old_json = json.loads(original_json) + old_nodes = old_json['graph']['data']['nodes'] + old_edges = old_json['graph']['data']['edges'] + + new_nodes, new_edges = [], [] + + #format node and edge data + for node in old_nodes: + new_nodes.append({"data": node}) + + for edge in old_edges: + new_edges.append({"data": edge}) + + #build the new json + new_json = {} + new_json['metadata'] = old_json['metadata'] + new_json['graph'] = {} + new_json['graph']['nodes'] = new_nodes + new_json['graph']['edges'] = new_edges + + return json.dumps(new_json, indent=4) diff --git a/build/lib/graphs/util/paginator.py b/build/lib/graphs/util/paginator.py new file mode 100644 index 00000000..56affbc8 --- /dev/null +++ b/build/lib/graphs/util/paginator.py @@ -0,0 +1,72 @@ +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from graphs.util import db + +def pager(request, content, page_size=25, adjacent_pages=3): + ''' + Adds pagination context variables for use in displaying first, adjacent and + last page links in addition to those created by the object_list generic + view. + + context - context to be displayed on the page + content - content to be paginated + + based on the following article: + www.tummy.com/articles/django-pagination/ + ''' + + context = {} + + paginator = Paginator(content, page_size) + + page = request.GET.get('page') + + try: + current_page = paginator.page(page) + except PageNotAnInteger: + #If page is not an integer, deliver first page. + current_page = paginator.page(1) + except EmptyPage: + #If page is out of range, deliver lastpage of results. + current_page = paginator.page(paginator.num_pages) + + startPage = max(current_page.number, 1) + + endPage = current_page.number + adjacent_pages + 1 + + if endPage > paginator.num_pages: + endPage = paginator.num_pages + 1 + + page_numbers = [n for n in range(startPage, endPage)] + + # context of the paginator to be displayed on the webpage + context['paginator'] = paginator + if page_numbers == 1: + context['has_next'] = None + context['has_previous'] = None + else: + context['has_next'] = current_page.has_next() + context['has_previous'] = current_page.has_previous() + + context['page_numbers'] = page_numbers + context['show_last'] = paginator.num_pages not in page_numbers + context['current_page'] = current_page + if endPage == paginator.num_pages + 1: + if paginator.num_pages < 4: + context['page_numbers'] = range(1, endPage) + else: + context['page_numbers'] = range(paginator.num_pages - adjacent_pages, endPage) + context['pages_range'] = range(paginator.num_pages - adjacent_pages, endPage) + else: + context['pages_range'] = range(current_page.number, paginator.num_pages + 1) + + try: + context['next_page_number'] = current_page.next_page_number() + except EmptyPage: + pass + + try: + context['previous_page_number'] = current_page.previous_page_number() + except EmptyPage: + pass + + return context \ No newline at end of file diff --git a/build/lib/graphs/views.py b/build/lib/graphs/views.py new file mode 100644 index 00000000..01ff87ea --- /dev/null +++ b/build/lib/graphs/views.py @@ -0,0 +1,2462 @@ +from django.shortcuts import render, redirect +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.views import generic +from django.templatetags.static import static + +from django.shortcuts import render_to_response + +from graphs.util.paginator import pager +from graphs.util import db +from graphs.auth.login import login +from forms import LoginForm, SearchForm, RegisterForm +from django.conf import settings + +import json +import bcrypt +import os +import operator + +from operator import itemgetter +from itertools import groupby +from graphs.forms import LoginForm, RegisterForm + +URL_PATH = settings.URL_PATH + +##### VIEWS ##### + +def image(request): + name = request.GET.get('name', '') + + if len(name) > 0: + return HttpResponseRedirect(URL_PATH + 'static/images/' + name + '.png') + else: + return HttpResponse(json.dumps(db.throwError(404, "Image not found!")), content_type="application/json") + +def saveFeedback(request): + + if request.POST: + feedback = request.POST["feedback"] + graph_id = request.POST["graph_id"] + user_id = request.POST["user_id"] + layout_owner = request.POST["layout_owner"] + layout_name = request.POST["layout_name"] + + error = db.saveFeedback(feedback, graph_id, user_id, layout_owner, layout_name) + + if error != None: + return HttpResponse(json.dumps(db.throwError(500, error)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Feedback saved!")), content_type="application/json") + +def getFeedback(request): + + if request.POST: + graph_id = request.POST["graph_id"] + user_id = request.POST["user_id"] + layout_owner = request.POST["layout_owner"] + layout_name = request.POST["layout_name"] + + results = db.getFeedback(graph_id, user_id, layout_owner, layout_name) + + if len(results) > 0: + return HttpResponse(json.dumps(db.sendMessage(200, results)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(500, "No feedback entered for this task!")), content_type="application/json") + +def index(request): + ''' + Render the main page + + :param request: HTTP GET Request + ''' + # If there is a POST request made to the main page (graphspace.org/index or graphspace.org/), + # that means that the user is trying to log on to GraphSpace. + # If they try to log on, we first check to see if their password needs to be reset (for whatever reason). + # The password_reset table contains all the users whose passwords need to be updated. + # Once the user has updated their password, their name is removed from the password_reset table + + if request.method == 'POST' and db.need_to_reset_password(request.POST['user_id']) != None: + context = {} + + # Forcibly clearing an existing user session (essentially logging user out) + request.session['uid'] = None + + # Email the user the link to reset their password + result = db.sendForgotEmail(request.POST['user_id']) + + # Any and all errors are thrown via "Error" key in context. This will + # be displayed to the user on the front end through a message. + context['Error'] = "Need to reset your password! An email has been sent to " + request.POST['user_id'] + ' with instructions to reset your password!' + return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); + + # Action to login the user to GraphSpace + context = login(request) + + if context['Error'] == None: + return render(request, 'graphs/index.html', context) + elif db.need_to_reset_password(request.POST['user_id']) != None: + context = {} + context['Error'] = "Invalid password. Perhaps you need to reset your password!" + # Any and all errors are thrown via "Error" key in context. This will + # be displayed to the user on the front end through a message. + return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); + else: + # If there is a problem, throw error and the reason why there was a problem + return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); + +def logout(request): + ''' + Log the user out and display logout page. + + :param request: HTTP GET Request + + ''' + + # Clears all context + context = {} + + # Deletes the "Uid" key from the session + # currently being tracked by Django. + try: + del request.session['uid'] + except KeyError: + # TODO: should something be done here? + pass + + # redirect to the main page after logout. + return HttpResponseRedirect('/index/') + +def download(request): + ''' + Download the graph as an image. + Used for when user requests to download PNG of graph. + + :param HTTP GET Request + + ''' + + # Only respond if it is a POST request. + # It will contain the image to be downloaded by the user + if request.POST: + if request.POST['image']: + response = HttpResponse(request.POST['image'], content_type='application/octet-stream') + response['Content-Disposition'] = 'attachment; filename="foo.png"' + return response + + else: + # redirect to the main page + return HttpResponseRedirect('/index/') + +def graphs(request): + ''' + Render the My Graphs page + + :param request: HTTP GET Request + ''' + + return _graphs_page(request, 'my graphs') + +def shared_graphs(request): + ''' + Render the graphs/shared/ page showing all graphs that are shared with a user + + :param request: HTTP GET Request + ''' + + return _graphs_page(request, 'shared') + +def public_graphs(request): + ''' + Render the graphs/public/ page showing all graphs that are public + + :param request: HTTP GET Request + ''' + + return _graphs_page(request, 'public') + +def _graphs_page(request, view_type): + ''' + wrapper view for the following pages: + graphs/ + graphs/shared/ + graphs/public/ + + :param request: HTTP GET Request + :param view_type: Type of view for graph (Ex: my graphs, shared, public) + ''' + # context of the view to be passed in for rendering + context = {} + + # List of graphs that will be returned by the request + graph_list = None + + # handle login + context = login(request) + + # Send view_type to front end to tell the user (through button color) where they are + # The view_type refers to which category of graphs are being viewed (public, shared, my graphs) + context['view_type'] = view_type + + # If there is an error, display the error + if context['Error']: + return render(request, 'graphs/error.html', context) + + # Checks to see if a user is currently logged on + uid = request.session['uid'] + + # Placeholder to keep track of + # whether we are partially searching or + # exact searching + search_type = None + + # Partial search may be thought of as "contains" matching + # Exact search may be though of as "identical" matching + if 'partial_search' in request.GET: + search_type = 'partial_search' + elif 'full_search' in request.GET: + search_type = 'full_search' + + # Set all information abouut graphs to the front-end + # Information of graphs consists of all data for an individual graph + # as well as any search queries and tag queries being performed + context = db.get_graphs_for_view_type(context, view_type, uid, request) + + # Holds the amount of times a tag appears for a graph + all_tags = {} + + # Goes through all the graphs that are currently on a page + if context['graph_list'] != None: + pager_context = pager(request, context['graph_list']) + if type(pager_context) is dict: + context.update(pager_context) + for i in xrange(len(context['current_page'].object_list)): + graph = list(context['current_page'][i]) + # Get all the tags associated with current graphs and populate the + # tags accordion + graph_tags = [] + + if request.GET.get(search_type): + user_id = graph[5] + graph_id = graph[0] + graph_tags = db.get_all_tags_for_graph(graph_id, user_id) + graph[1] = graph_tags + graph.append(db.get_visibility_of_graph(user_id, graph_id)) + else: + user_id = graph[2] + graph_id = graph[0] + graph_tags = db.get_all_tags_for_graph(graph_id, user_id) + graph.insert(1, graph_tags) + graph.append(db.get_visibility_of_graph(user_id, graph_id)) + + context['current_page'].object_list[i] = graph + + # reset the search form + context['search_form'] = SearchForm(placeholder='Search...') + + # Checks to see if there are any tags that the user wants to search for + request_tags = request.GET.get('tags') or request.GET.get('tag') or None + + # If there are no graphs returned by the query, then display message on + # how to add graphs + if len(context['graph_list']) == 0: + context = constructGraphMessage(context, view_type, request.GET.get(search_type), request_tags) + + recent_graphs = context['graph_list'] + + recent_graphs.sort(key=lambda r: r[2], reverse=True) + + if len(recent_graphs) > 250: + recent_graphs = recent_graphs[:250] + + graph_tags = [] + + for graph in recent_graphs: + + if request.GET.get(search_type): + graph_tags = db.get_all_tags_for_graph(graph[0], graph[5]) + else: + graph_tags = db.get_all_tags_for_graph(graph[0], graph[2]) + + for tag in graph_tags: + if len(tag) > 0: + if tag in all_tags: + all_tags[tag] += 1 + else: + all_tags[tag] = 1 + + sorted_tags = sorted(all_tags.items(), key=operator.itemgetter(1), reverse = True)[:10] + + all_tags_refined = [i[0] for i in sorted_tags] + + # Populates tags search bar with most used tags of last 250 graphs + context['all_tags'] = all_tags_refined #list(set(all_tags))[:10] + + # indicator to include css/js footer for side menu support etc. + context['footer'] = True + + return render(request, 'graphs/graphs.html', context) + +def upload_graph_through_ui(request): + + if request.method == 'POST': + login_form = LoginForm() + register_form = RegisterForm() + + upload_json = True + + title_of_graph = None + + if 'title' in request.POST: + title_of_graph = request.POST['title'] + + if str(request.FILES['graphname'])[-4:] != "json": + upload_json = None + + if request.POST['email'] == 'Public User': + # assign random id generator + if upload_json: + result = db.uploadJSONFile(None, request.FILES['graphname'].read(), title_of_graph) + else: + result = db.uploadCyjsFile(None, request.FILES['graphname'].read(), title_of_graph) + + if 'Error' not in result: + context = {'login_form': login_form, 'register_form': register_form, 'Success': result['Success']} + else: + context = {'login_form': login_form, 'register_form': register_form, 'Error': result['Error']} + return render(request, 'graphs/upload_graph.html', context) + else: + + if upload_json: + result = db.uploadJSONFile(request.POST['email'], request.FILES['graphname'].read(), title_of_graph) + else: + result = db.uploadCyjsFile(request.POST['email'], request.FILES['graphname'].read(), title_of_graph) + + if 'Error' not in result: + context = {'login_form': login_form, 'uid': request.POST['email'], 'register_form': register_form, 'Success': result['Success']} + else: + context = {'login_form': login_form, 'uid': request.POST['email'], 'register_form': register_form, 'Error': result['Error']} + + return render(request, 'graphs/upload_graph.html', context) + else: + context = login(request) + return render(request, 'graphs/upload_graph.html', context) + +def save_layout(request, uid, gid): + ''' + Saves a layout for a graph. + + :param HTTP POST Request + + ''' + graph_owner = uid + if request.POST: + if uid == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to save a layout!")), content_type="application/json") + + result = db.save_layout(gid, graph_owner, request.POST['layout_name'], request.POST['loggedIn'], request.POST['points'], request.POST['public'], request.POST['unlisted']) + if result == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Layout saved!")), content_type="application/json") + + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def update_layout(request, uid, gid): + ''' + Updates a layout for a graph. + + :param HTTP POST Request + + ''' + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + error = db.update_layout(gid, uid, request.POST['layout_name'], request.POST['loggedIn'], request.POST['points'], request.POST['public'], request.POST['unlisted'], request.POST['originalLayout']) + if error == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Layout updated!")), content_type="application/json") + + return HttpResponse(json.dumps(db.throwError(400, error)), content_type="application/json"); + +def design_graph(request, uid, gid): + ''' + View a graph with CytoscapeJS along with tool pallete + to help researcher layout of a graph. + + :param request: HTTP GET Request + :param uid: Owner of the graph to view + :param gid: Graph id of the graph to view + ''' + + # Context contains all the elements we want to render on the web + # page. We fill in the various elements of context before calling + # the render() function. + #handle login + # context = login(request) + context = { + "uid": request.session['uid'], + "Error": None + } + + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + #TODO: Create trigger to delete older tasks (3 days) + + # if the graph is public, or if a user is a member + # of the group where this graph is shared + # or if he owns this graph, then allow him to view it + # otherwise do not allow it + if db.is_public_graph(uid, gid) or 'Public_User_' in uid: + graph_to_view = db.get_all_info_for_graph(uid, gid) + elif request.session['uid'] == None: + context['Error'] = "You are not authorized to view this graph, create an account and contact graph's owner for permission to see this graph." + return render(request, 'graphs/error.html', context) + else: + # If the user is member of group where this graph is shared + user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) + + # if user is owner of graph or a member of group that shares graph + if request.session['uid'] == uid or user_is_member == True: + graph_info = db.getGraphInfo(uid, gid) + if graph_info != None: + graph_to_view = graph_info + else: + context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it." + return render(request, 'graphs/error.html', context) + else: + context['Error'] = "You are not authorized to view this graph, please contact graph's owner for permission." + return render(request, 'graphs/error.html', context) + + # Get correct layout for the graph to view + context = db.set_layout_context(request, context, uid, gid) + + if context['Error']: + return render(request, 'graphs/error.html', context) + + # Convert JSON for CytoscapeJS, if needed + context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) + context['draw_graph'] = True + + # TODO: This will eventually get deleted + json_data = json.loads(context['graph']) + + # id of the owner of this graph + context['owner'] = uid + + # graph id + context['graph_id'] = gid + + # Don't display the task_view + context["task_view"] = False + context["approve_view"] = False + context["researcher_view"] = False + context["designer_view"] = True + + return render(request, 'graphs/view_graph.html', context) + +def view_graph(request, uid, gid): + ''' + View a graph with CytoscapeJS. + + :param request: HTTP GET Request + :param uid: Owner of the graph to view + :param gid: Graph id of the graph to view + ''' + # Context contains all the elements we want to render on the web + # page. We fill in the various elements of context before calling + # the render() function. + #handle login + context = login(request) + + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + #TODO: Create trigger to delete older tasks (3 days) + + # if the graph is public, or if a user is a member + # of the group where this graph is shared + # or if he owns this graph, then allow him to view it + # otherwise do not allow it + if db.is_public_graph(uid, gid) or 'Public_User_' in uid: + graph_to_view = db.get_all_info_for_graph(uid, gid) + elif request.session['uid'] == None: + context['Error'] = "You are not authorized to view this graph, create an account and contact graph's owner for permission to see this graph." + return render(request, 'graphs/error.html', context) + else: + # If the user is member of group where this graph is shared + user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) + + # if user is owner of graph or a member of group that shares graph + if request.session['uid'] == uid or user_is_member == True: + graph_info = db.getGraphInfo(uid, gid) + if graph_info != None: + graph_to_view = graph_info + else: + context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it." + return render(request, 'graphs/error.html', context) + else: + context['Error'] = "You are not authorized to view this graph, please contact graph's owner for permission." + return render(request, 'graphs/error.html', context) + + # Get correct layout for the graph to view + context = db.set_layout_context(request, context, uid, gid) + + if context['Error']: + return render(request, 'graphs/error.html', context) + + # Convert JSON for CytoscapeJS, if needed + context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) + context['draw_graph'] = True + + # Get all the groups that are shared for this graph + shared_groups = db.get_all_groups_for_this_graph(uid, graph_to_view[2]) + + format_shared_groups = [] + for shared_group in shared_groups: + format_shared_groups.append((shared_group.group_id, shared_group.owner_id)) + + context['shared_groups'] = format_shared_groups + + if graph_to_view[1] == 1: + context['shared'] = 'Publicly Shared' + else: + context['shared'] = 'Privately Shared' + + # TODO: This will eventually get deleted + json_data = json.loads(context['graph']) + #add sidebar information to the context for display + if 'description' in json_data['metadata']: + context['description'] = json_data['metadata']['description'] + "" + else: + context['description'] = "" + + # id of the owner of this graph + context['owner'] = uid + + # If the metadata has either a name or a title (backward-compatible) + # display it on the top of the graph + if 'name' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['name'] + elif 'title' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['title'] + else: + context['graph_name'] = '' + + # graph id + context['graph_id'] = gid + + # Don't display the task_view + context["task_view"] = False + context["approve_view"] = False + context["researcher_view"] = True + + # HARDCODED GROUP.. IF USER IS IN THIS GROUP, THEN ONLY THEN CAN THEY LAUNCH TASKS ON MTURK + context["crowd_group"] = db.getCrowdEnabledGroup() + + if len(json_data['graph']['edges']) > 0 and 'k' in json_data['graph']['edges'][0]['data']: + context['filters'] = True + + # redirect if the user wishes to view the json data + if request.method == "GET" and 'view_json' in request.GET: + return HttpResponseRedirect("/json/%s/%s" % (uid, gid)) + + return render(request, 'graphs/view_graph.html', context) + +def view_task(request, uid, gid): + ''' + View that workers will see for a launched task. + + :param request: HTTP GET Request + :param uid: email of the user that owns this graph + :param gid: name of graph that the user owns + ''' + + # db.getAssignmentsForGraph(uid, gid) + if 'uid' in request.session: + context = login(request) + context["task_view"] = True + + else: + login_form = LoginForm() + register_form = RegisterForm() + context = {'login_form': login_form, 'register_form': register_form, "Error": None, "task_view": True} + + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + graph_info = db.getGraphInfo(uid, gid) + + + if graph_info != None: + graph_to_view = graph_info + else: + context['Error'] = "Task does not exist anymore!." + return render(request, 'graphs/error.html', context) + + layout_name = request.GET.get('layout', '') + layout_owner = request.GET.get('layout_owner', '') + + # Get correct layout for the graph to view + context = db.set_task_layout_context(request, context, uid, gid, layout_name, layout_owner) + + if context['Error']: + return render(request, 'graphs/error.html', context) + + # Convert JSON for CytoscapeJS, if needed + context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) + + context['draw_graph'] = True + + # TODO: This will eventually get deleted + + json_data = json.loads(context['graph']) + + #add sidebar information to the context for display + if 'description' in json_data['metadata']: + context['description'] = json_data['metadata']['description'] + "" + else: + context['description'] = "" + + # id of the owner of this graph + context['owner'] = uid + + if 'name' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['name'] + else: + context['graph_name'] = '' + + # graph id + context['graph_id'] = gid + + # owner + context["owner"] = uid + + context["researcher_view"] = False + context["approve_view"] = False + + return render(request, 'graphs/view_graph.html', context) + +def approve_task_expert(request): + if 'uid' in request.session: + context = login(request) + else: + context = {} + + tasks = db.getAllApproveTasks() + all_tasks = len(tasks) + for task in tasks: + if task.submitted == 0: + + uid = task.user_id + gid = task.graph_id + + graph_info = db.getGraphInfo(uid, gid) + + layout = db.getLayoutById(task.layout_id) + + context = db.set_task_layout_context(request, context, uid, gid, layout.layout_name, layout.owner_id, approve=True, expert=True) + + context['graph'] = db.retrieve_cytoscape_json(graph_info[0]) + context['remaining'] = all_tasks + context['draw_graph'] = True + + context["researcher_view"] = False + context["approve_view"] = True + + json_data = json.loads(context['graph']) + + #add sidebar information to the context for display + if 'description' in json_data['metadata']: + context['description'] = json_data['metadata']['description'] + "" + else: + context['description'] = "" + + # id of the owner of this graph + context['owner'] = uid + + if 'name' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['name'] + else: + context['graph_name'] = '' + + # graph id + context['graph_id'] = gid + + # owner + context["owner"] = uid + + return render(request, 'graphs/view_graph_expert.html', context) + + context['Error'] = "It appears as if there are no more graphs to lay out. Thank you for your time!" + return render(request, 'graphs/error.html', context) + +def approve_task(request, uid, gid): + ''' + Approve or reject a task. + + :param request: HTTP GET Request + :param uid: email of the user that owns this graph + :param gid: name of graph that the user owns + ''' + + if 'uid' in request.session: + context = login(request) + + else: + login_form = LoginForm() + register_form = RegisterForm() + context = {'login_form': login_form, 'register_form': register_form, "Error": None, "task_view": True} + + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + graph_info = db.getGraphInfo(uid, gid) + + if graph_info != None: + graph_to_view = graph_info + else: + context['Error'] = "Task does not exist anymore!." + return render(request, 'graphs/error.html', context) + + layout_name = request.GET.get('layout', '') + layout_owner = request.GET.get('layout_owner', '') + + context = db.set_task_layout_context(request, context, uid, gid, layout_name, layout_owner, approve=True) + + if context['Error']: + return render(request, 'graphs/error.html', context) + + # Convert JSON for CytoscapeJS, if needed + context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) + + context['draw_graph'] = True + + context["researcher_view"] = False + context["approve_view"] = True + + # TODO: This will eventually get deleted + + json_data = json.loads(context['graph']) + + #add sidebar information to the context for display + if 'description' in json_data['metadata']: + context['description'] = json_data['metadata']['description'] + "" + else: + context['description'] = "" + + # id of the owner of this graph + context['owner'] = uid + + if 'name' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['name'] + else: + context['graph_name'] = '' + + # graph id + context['graph_id'] = gid + + # owner + context["owner"] = uid + + return render(request, 'graphs/view_graph.html', context) + +def submitEvaluation(request): + ''' + Submits Evaluation for a task. + ''' + + if request.POST: + + gid = request.POST["graph_id"] + uid = request.POST["user_id"] + layout_name = request.POST["layout_name"] + layout_owner = request.POST["layout_owner"] + triangle_rating = request.POST["triangle_rating"] + rectangle_rating = request.POST["rectangle_rating"] + shape_rating = request.POST["shape_rating"] + color_rating = request.POST["color_rating"] + hit_id = request.POST["hit_id"] + + task_code = db.submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, shape_rating, color_rating, hit_id) + + if task_code != None: + return HttpResponse(json.dumps(db.sendMessage(201, task_code)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(500, "Evaluation Submission Unsucessful!")), content_type="application/json") + else: + return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) + +def submitExpertEvaluation(request): + ''' + Submits Expert Evaluation for a task. + ''' + + if request.POST: + + gid = request.POST["graph_id"] + uid = request.POST["user_id"] + layout_name = request.POST["layout_name"] + layout_owner = request.POST["layout_owner"] + triangle_rating = request.POST["triangle_rating"] + rectangle_rating = request.POST["rectangle_rating"] + shape_rating = request.POST["shape_rating"] + color_rating = request.POST["color_rating"] + hit_id = request.POST["hit_id"] + + task_code = db.submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, shape_rating, color_rating, hit_id, expert=True) + + if task_code != None: + return HttpResponse(json.dumps(db.sendMessage(201, task_code)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(500, "Evaluation Submission Unsucessful!")), content_type="application/json") + else: + return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) + +def retrieveTaskCode(request): + ''' + Retrieves code for a task when worker has completed task. + + ''' + + if request.POST: + + gid = request.POST["graph_id"] + uid = request.POST["user_id"] + worked_layout = request.POST["layout_name"] + numChanges = request.POST["numChanges"] + timeSpent = request.POST["timeSpent"] + events = request.POST["events"] + hit_id = request.POST["hit_id"] + + if not gid or not uid: + return HttpResponse(json.dumps(db.throwError(201, "Must include both graph_id and user_id in POST request.")), content_type="application/json") + + surveyCode = db.retrieveTaskCode(uid, gid, worked_layout, numChanges, timeSpent, events, hit_id) + + if surveyCode == None: + surveyCode = "Task does not exist anymore!" + return HttpResponse(json.dumps(db.sendMessage(201, surveyCode)), content_type="application/json") + + else: + return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) + +def view_json(request, uid, gid): + ''' + View json structure of a graph. + + :param request: HTTP GET Request + :param uid: email of the user that owns this graph + :param gid: name of graph that the user owns + ''' + #handle login + context = login(request) + + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + # if the graph is public, or if a user is a member + # of the group where this graph is shared + # or if he owns this graph, then allow him to view it's JSON + # otherwise do not allow it + if db.is_public_graph(uid, gid) or 'Public_User_' in uid: + graph_to_view = db.get_all_info_for_graph(uid, gid) + elif request.session['uid'] == None: + context['Error'] = "You are not authorized to view JSON for this graph, create an account and contact graph's owner for permission to see this." + return render(request, 'graphs/error.html', context) + else: + # If the user is member of group where this graph is shared + user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) + + # if user is owner of graph or a member of group that shares graph + if request.session['uid'] == uid or user_is_member == True: + graph_info = db.getGraphInfo(uid, gid) + if graph_info != None: + graph_to_view = graph_info + else: + context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it's JSON." + return render(request, 'graphs/error.html', context) + else: + context['Error'] = "You are not authorized to view JSON for this graph, please contact graph's owner for permission." + return render(request, 'graphs/error.html', context) + + + graph_to_view = db.get_graph_json(uid, gid) + + if graph_to_view == None: + context['Error'] = "Graph not found, please make sure you have the correct URL." + return render(request, 'graphs/error.html', context) + + # Get correct json for CytoscapeJS + context['json'] = db.retrieve_cytoscape_json(graph_to_view) + + # id of the owner of this graph + context['owner'] = uid + + # graph id + context['graph_id'] = gid + + # If it is http request, render it in the specific page, otherwise just return the JSON + if request: + return render(request, 'graphs/view_json.html', context) + else: + return HttpResponse(context['json']) + +def groups(request): + ''' + Render the Owner Of page, showing groups that are owned by the user. + + :param request: HTTP GET Request + + ''' + return _groups_page(request, 'owner of') + +def groups_member(request): + ''' + Render the Member Of page, showing the groups that the user belong to . + + :param request: HTTP GET Request + + ''' + return _groups_page(request, 'member') + +def all_groups(request): + ''' + Render the All Groups page, showing all groups in the database.Admin feature [NOT CURRENTLY SUPPORTED]. + + :param request: HTTP GET Request + + ''' + return _groups_page(request, 'all') + +def _groups_page(request, view_type): + ''' + Wrapper view for the following pages: + groups/ + groups/member/ + groups/public/ + groups/all/ + + :param request: HTTP GET Request + :param view_type: Type of view for the group (Example: owner of, member, public, all) + + ''' + #context of the view to be passed in for rendering + context = {} + group_list = None + + #handle login + context = login(request) + + #Send view_type to front end to tell the user (through button color) where they are + context['view_type'] = view_type + + #check for authentication + uid = request.session['uid'] + if uid is not None: + # Get groups that the user is a member of + if view_type == 'member': + group_list = db.get_all_groups_with_member(context['uid']) + db.get_groups_of_user(context['uid']) + + # if admin, then they can view this + elif view_type == 'all': + if db.is_admin(uid) == 1: + group_list = db.get_all_groups_in_server() + else: + context['Error'] = "You are not authorized to see this group's contents! Please contact group's owner to add you to the group!" + return render(request, 'graphs/error.html', context) + + #groups of logged in user(my groups) + else: + # List all groups that uid either owns. + group_list = db.get_groups_of_user(context['uid']) + + #Order all tuples if user wants to order their results + order_term = request.GET.get('order') + + if order_term: + if request.GET.get('order') == 'group_ascending': + group_list = sorted(group_list, key=lambda graph: graph[0]) + elif order_term == 'group_descending': + group_list = sorted(group_list, key=lambda graph: graph[0], reverse=True) + elif order_term == 'owner_ascending': + group_list = sorted(group_list, key=lambda graph: graph[2]) + elif order_term == 'owner_descending': + group_list = sorted(group_list, key=lambda graph: graph[2], reverse=True) + + else: + group_list = sorted(group_list, key=lambda graph: graph[0]) + + #add the group list to context to display on the page. + if len(group_list) != 0: + context['group_list'] = group_list + else: + context['group_list'] = None + + pager_context = pager(request, group_list) + + if type(pager_context) is dict: + context.update(pager_context) + + context['my_groups'] = len(db.get_groups_of_user(context['uid'])) + context['member_groups'] = len(db.get_all_groups_with_member(context['uid'])) + context['my_groups'] + + if view_type == 'owner of' and context['my_groups'] == 0: + context['message'] = "It appears that you are not an owner of any group. Please create a group in order to own a group." + elif view_type == 'member' and context['member_groups'] == 0 : + context['message'] = "It appears as if you are not a member of any group. Please join a group in order for them to appear here." + else: + context['message'] = "It appears as if there are currently no groups on GraphSpace." + return render(request, 'graphs/groups.html', context) + + #No public groups anymore + else: + context['Error'] = "You need to be logged in and also be a member of this group in order to see this group's contents!" + return render(request, 'graphs/error.html', context) + +def graphs_in_group(request, group_owner, group_id): + ''' + Groups/group_name page, where group_name is the name of the + group to view the graphs that belong to the group. + + This is the view displayed when the user clicks a group listed + on the /groups page. + + Group names that are not allowed: 'all', 'member' and 'public'. + they are preoccupied. + + :param request: HTTP GET Request + :param group_id: Name of group to get + + ''' + + #handle login + context = login(request) + + # add search form + search_form = SearchForm() + context['search_form'] = search_form + + # if the group name is not one of the designated names, display graphs + # that belong to the group + if "uid" in context: + if group_id != 'all' or group_id != 'member': + + group_dict = db.groups_for_user(context['uid']) + + if not any(g_dict['groupId'] == group_id for g_dict in group_dict): + context['Error'] = "You need to be a member of a group to see its contents! Please contact group's owner to add you to the group!" + return render(request, 'graphs/error.html', context) + + # Get all graph information that belong to this group + graph_data = db.get_all_graphs_for_group(context['uid'], group_owner, group_id, request) + + search_type = None + context['search_result'] = False + + if 'partial_search' in request.GET: + search_type = 'partial_search' + elif 'full_search' in request.GET: + search_type = 'full_search' + + if search_type != None: + context['search_result'] = True + context['search_type'] = search_type + context['search_word'] = "" + + cleaned_search_terms = request.GET.get(search_type).split(',') + for i in xrange(len(cleaned_search_terms)): + cleaned_search_terms[i] = cleaned_search_terms[i].strip() + # Deleted no length search terms + if len(cleaned_search_terms[i]) == 0: + del cleaned_search_terms[i] + + for i in xrange(len(cleaned_search_terms)): + context['search_word'] += cleaned_search_terms[i] + ',' + + if len(context['search_word']) > 0: + context['search_word'] = context['search_word'][:len(context['search_word']) - 1] + + # include the graph data to the context + if len(graph_data) != 0: + context['graph_list'] = graph_data + else: + context['graph_list'] = None + if context['search_result'] == True: + context['message'] = "It appears as if there are no graphs in this group that match your search query!" + else: + context['message'] = "It appears as if there are no graphs in this group yet." + + group_information = db.get_group_by_id(group_owner, group_id) + + # pass the group_id to the context for display + context['group_id'] = group_information[0][4] + + context['group_name'] = group_information[0][3] + + context['group_owner'] = group_information[0][2] + + context['group_description'] = group_information[0][0] + + context['group_members'] = group_information[0][1] + + all_tags = [] + + # Goes through all the graphs that are currently on a page + pager_context = pager(request, graph_data) + if type(pager_context) is dict: + context.update(pager_context) + for i in xrange(len(context['current_page'].object_list)): + graph = list(context['current_page'][i]) + + graph_tags = [] + if request.GET.get(search_type): + user_id = graph[5] + graph_id = graph[0] + graph_tags = db.get_all_tags_for_graph(graph_id, user_id) + graph[1] = graph_tags + graph.append(db.get_visibility_of_graph(user_id, graph_id)) + else: + user_id = graph[2] + graph_id = graph[0] + graph_tags = db.get_all_tags_for_graph(graph_id, user_id) + graph.insert(1, graph_tags) + graph.append(db.get_visibility_of_graph(user_id, graph_id)) + all_tags += graph_tags + + context['current_page'].object_list[i] = graph + + context['all_tags'] = list(set(all_tags)) + # indicator to include css/js footer for side menu support etc. + context['footer'] = True + + return render(request, 'graphs/graphs_in_group.html', context) + # if the group name is one of the designated names, display + # appropriate vies for each + else: + if group_id == 'member': + return groups_member(request) + else: + return public_groups(request) + else: + context['Error'] = "Please log in to view groups page" + return render(request, 'graphs/error.html', context) + + +def features(request): + ''' + View features page. + + :param request: HTTP GET Request + + ''' + #handle login + context = login(request) + + return render(request, 'graphs/features.html', context) + +def help(request): + ''' + Render the following pages: + + help/ + help/getting_started + + :param request: HTTP GET Request + + ''' + + #handle login + context = login(request) + + return render(request, 'graphs/help_users.html', context) + +def help_programmers(request): + ''' + Render the help/tutorials page. + + :param request: HTTP GET Request + + ''' + + #handle login + context = login(request) + + return render(request, 'graphs/help_programmers.html', context) + +def help_graphs(request): + ''' + Render the help/graphs page. + + :param request: HTTP GET Request + + ''' + + #handle login + context = login(request) + + return render(request, 'graphs/help_graphs.html', context) + +def help_restapi(request): + ''' + Render the help/restapi page. + + :param request: HTTP GET Request + + ''' + + #handle login + context = login(request) + + return render(request, 'graphs/help_restapi.html', context) + +def help_jsonref(request): + ''' + Render the help/jsonref page. + + :param request: HTTP GET Request + + ''' + + #handle login + context = login(request) + + return render(request, 'graphs/help_jsonref.html', context) + +def help_about(request): + ''' + Render the help/about page. + + :param request: HTTP GET Request + + ''' + + #handle login + context = login(request) + + return render(request, 'graphs/help_about.html', context) + +def help_tutorial(request): + ''' + Render the help/tutorial page. + + :param request: HTTP GET Request + + ''' + + #handle login + context = login(request) + + return render(request, 'graphs/help_tutorial.html', context) + +def register(request): + ''' + Register a new user. + + :param request: HTTP POST Request containing: + + {"user_id": , "password": } + + ''' + + #if the form has been submitted + if request.method == 'POST' and 'user_id' in request.POST and 'password' in request.POST: + # RegisterForm is bound to POST data + register_form = RegisterForm(request.POST) + # form validation + if register_form.is_valid(): + # create new account by inserting the new account + # record to the database + user_id = register_form.cleaned_data['user_id'] + + if user_id == None: + return HttpResponse(json.dumps(db.throwError(400, "Email already exists!")), content_type="application/json"); + + # hash the password using bcrypt library + hashed_pw = bcrypt.hashpw( + register_form.cleaned_data['password'], + bcrypt.gensalt()) + admin = 0 + + db.insert_user(user_id, hashed_pw, admin) + + # should display success message. not there yet. + return HttpResponseRedirect('/index/') + else: + register_form = RegisterForm() # An unbound form + + context['register_form'] = register_form + context['footer'] = True + + return render(request, 'graphs/register.html', context) + +def retrieveIDs(request): + ''' + Retrieves ID's of the nodes. + Used when highlighting elements of the graph. + + :param request: HTTP POST Request containing + + {uid: , gid: , values: [labels/id's of edges/nodes return id for]} + + :return JSON: {"IDS": [ids of nodes/edges in database]} + + ''' + + #Grab id's of the nodes to highlight given the label of the nodes + if request.POST: + element_values = request.POST['values'].split(',') + elementDictionary = {} + for element in element_values: + elementDictionary[element] = [] + # Find an edge + if ':' in element: + elementDictionary[element] += db.find_edge(request.POST['uid'], request.POST['gid'], element.strip(), request.POST['search_type']) + else: + elementDictionary[element] += db.find_node(request.POST['uid'], request.POST['gid'], element.strip(), request.POST['search_type']) + + return HttpResponse(json.dumps(elementDictionary)) + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def sendResetEmail(request): + ''' + Sends an email to the requester. + + :param request: HTTP POST Request containing: + + {"forgot_email": } + + :returns JSON: {"Error|Success": "Email does not exist! | "Email has been sent!"} + + ''' + if request.POST: + db.add_user_to_password_reset(request.POST['forgot_email']) + emailId = db.sendForgotEmail(request.POST['forgot_email']) + + # If email is not found, throw an error + if emailId == None: + return HttpResponse(json.dumps(db.throwError(404, "Email does not exist!")), content_type="application/json") + + return HttpResponse(json.dumps(db.sendMessage(200, "Email has been sent!")), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def resetLink(request): + ''' + Directs the user to a link that + allows them to change their password. + + :param HTTP GET Request + :return JSON: {"email": | "Error": "Unrecognized ID"} + + ''' + if request.method == 'GET': + login_form = LoginForm() + register_form = RegisterForm() + code = request.GET.get('id') + email = db.retrieveResetInfo(code) + context = {'login_form': login_form, 'register_form': register_form} + + if email == None: + context['Error'] = "This password reset link is outdated. Please try resetting your password again." + return render(request, 'graphs/error.html', context) + + context['email'] = email + context['url'] = URL_PATH + return render(request, 'graphs/reset.html', context) + else: + return HttpResponse(json.dumps(db.throwError(500, "This route only accepts GET requests.")), content_type="application/json") + +def resetPassword(request): + ''' + Resets the password of the user. + + :param request: HTTP POST Request containing + + {"email": , "password": "password"} + + :return JSON: {"Error|Success": "Password Update not successful! | Password updated for !"} + + ''' + if request.method == "POST": + resetInfo = db.resetPassword(request.POST['email'], request.POST['password'], request.POST['code']) + + if resetInfo == None: + return HttpResponse(json.dumps(db.throwError(500, "Password Update not successful!")), content_type="application/json"); + + return HttpResponse(json.dumps(db.sendMessage(200, "Password updated for " + request.POST['email'])), content_type="application/json"); + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def launchTask(request): + ''' + Launches a task on Amazon Mechanical Turk. + + :param request: HTTP POST Request containing + + {"graph_id": , "user_id": } + + :return JSON: {"Error|Success": Error | Task Launched on Amazon Mechanical Turk!"} + ''' + # Only 1 task per graph as long as there is a HIT active (3 days) + error = db.launchTask(request.POST["graph_id"], request.POST["user_id"], request.POST.getlist('layout_array')) + + if error != None: + return HttpResponse(json.dumps(db.throwError(500, error)), content_type="application/json") + + return HttpResponse(json.dumps(db.sendMessage(201, "Task Launched on Amazon Mechanical Turk!")), content_type="application/json"); + +def changeLayoutName(request): + ''' + Changes the name of the layout + + :param request: Incoming HTTP POST Request containing: + + {"uid": ,"gid": , "old_layout_name": , "new_layout_name": "} + + :return JSON: {"Success": } + ''' + if request.method == 'POST': + loggedIn = request.session.get('uid') + uid = request.POST['uid'] + gid = request.POST['gid'] + old_layout_name = request.POST['old_layout_name'] + new_layout_name = request.POST['new_layout_name'] + + if loggedIn == None: + return HttpResponse(json.dumps({"StatusCode": 500, "Message": "Must be logged in to make those requests", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name + "&layout_owner=" + loggedIn}), content_type="application/json") + + error = db.changeLayoutName(uid, gid, old_layout_name, new_layout_name, loggedIn) + if error == None: + return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout name changed!", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name + "&layout_owner=" + loggedIn}), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, error)), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def deleteLayout(request): + ''' + Deletes layout of a graph + + :param request: Incoming HTTP POST Request containing: + + {"owner": ,"gid": , "layout": , "user_id": "} + + :return JSON: {"Success": } + ''' + if request.method == 'POST': + uid = request.session.get('uid') + gid = request.POST['gid'] + + if uid == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to delete a layout!")), content_type="application/json") + + layoutToDelete = request.POST['layout'] + layout_owner = request.POST['layout_owner'] + + result = db.deleteLayout(uid, gid, layoutToDelete, layout_owner) + + if result == None: + return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout deleted!", "url": URL_PATH + 'graphs/' + uid + '/' + gid}), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def makeLayoutPublic(request): + ''' + Makes a layout of graph public + + :param request: Incoming HTTP POST Request containing: + + {"owner": ,"gid": , "layout": , "user_id": "} + + :return JSON: {"Success": } + ''' + if request.method == 'POST': + uid = request.POST['uid'] + gid = request.POST['gid'] + layoutToMakePpublic = request.POST['layout'] + loggedIn = request.POST['user_id'] + + current_user = request.session.get('uid') + + # If user is not logged on, they can't do anything + if current_user == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make share layouts!")), content_type="application/json") + + # If user is the owner of the graph or if they are the layout owner, can they share a layout + if current_user != uid and db.get_layout_for_graph(layoutId, layout_owner, gid, uid, current_user) == None: + return HttpResponse(json.dumps(db.throwError(500, "Not authorized to share layouts!")), content_type="application/json") + + if uid == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make a layout public!")), content_type="application/json") + + db.makeLayoutPublic(uid, gid, layoutToMakePpublic, loggedIn) + return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout made public!", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name}), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def getGroupsForGraph(request): + ''' + Returns all the groups that are associated with request. + + :param request:Incoming HTTP POST Request containing: + + {"gid": } + + :return JSON: {"Groups": [list of groups]} + ''' + if request.method == 'POST': + owner = request.session.get('uid') + gid = request.POST['gid'] + + if owner == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to see groups for this graph!")), content_type="application/json") + + return HttpResponse(json.dumps({"StatusCode": 200, "Group_Information": db.get_all_groups_for_user_with_sharing_info(owner, gid)}), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def shareGraphWithGroups(request): + ''' + Shares graph with specified groups. + Unshares graph with specified groups. + + :param request:Incoming HTTP POST Request containing: + {"gid": , "owner": , "groups_to_share_with": [group_ids], "groups_not_to_share_with": [group_ids]} + :return TBD + ''' + if request.method == 'POST': + owner = request.POST['owner'] + gid = request.POST['gid'] + groups_to_share_with = request.POST.getlist('groups_to_share_with[]') + groups_not_to_share_with = request.POST.getlist('groups_not_to_share_with[]') + + for group in groups_to_share_with: + groupInfo = group.split("12345__43121__") + db.share_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) + + for group in groups_not_to_share_with: + groupInfo = group.split("12345__43121__") + db.unshare_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) + + return HttpResponse("Done") + + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def create_group(request, groupname): + ''' + Allows group creation from the GUI. + + :param request: Incoming HTTP POST Request containing: + + {"owner": , "groupname": < name of group>, "username": User who submitted the request} + + :return JSON: {"Upload": , "Group Name | Error": } + ''' + + # If request is a POST request, add it to the server + if request.method == 'POST': + group_created = db.create_group(request.POST['username'], groupname) + + # If there isn't already a group name that exists with the same name under account + # add it to account + if group_created != None: + return HttpResponse(json.dumps({"StatusCode": 201, "Message": "Group created!", "Group Name": group_created[0], "Group Id": group_created[1]}, indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, "Group name already exists for this account"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def deleteGraph(request): + ''' + Allows deletion of graph. + + :param request: Incoming HTTP POST Request containing: + + {"uid": , "gid": < name of graph>} + + :return JSON: {"Delete": } + ''' + if request.method == 'POST': + gid = request.POST['gid'] + uid = request.session.get('uid') + + # Check if the user is authenticated + if uid == None: + return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to delete this graph"), indent=4, separators=(',', ': ')), content_type="application/json") + + # if the user owns the graph only then allow him to delete it + graph_info = db.getGraphInfo(uid, gid) + if graph_info == None: + return HttpResponse(json.dumps(db.throwError(404, "You do not own any such Graph."), indent=4, separators=(',', ': ')), content_type="application/json") + else: + + jsonData = db.get_graph_json(uid, gid) + if jsonData != None: + db.delete_graph(uid, gid) + return HttpResponse(json.dumps(db.sendMessage(200, "Successfully deleted " + gid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(404, "You do not own any such Graph."), indent=4, separators=(',', ': ')), content_type="application/json") + +def delete_group_through_ui(request): + ''' + Allows group creation from the GUI. + + :param request: Incoming HTTP POST Request containing: + + {"groupOwner": , "groupName": < name of group>, "username": User who submitted the request} + + :return JSON: {"Delete": } + ''' + + # If request is a POST request, add it to the server + if request.method == 'POST': + if request.POST['username'] == request.POST['groupOwner']: + db.remove_group(request.POST['groupOwner'], request.POST['groupName']) + return HttpResponse(json.dumps(db.sendMessage(200, request.POST['groupName'] + " deleted for " + request.POST['groupOwner'])), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def unsubscribe_from_group(request): + ''' + Allows group creation from the GUI. + + :param request: Incoming HTTP POST Request containing: + + {"groupOwner": , "groupName": < name of group>, "username": User who submitted the request} + + :return JSON: {"Unsubscribe | Error": } + ''' + + # If request is a POST request, add it to the server + if request.method == 'POST': + result = db.remove_user_through_ui(request.POST['username'], request.POST['groupOwner'], request.POST['groupName']) + if result != None: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "You are no longer following " + request.POST['groupName'] + " owned by " + request.POST['groupOwner'])), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def change_description_through_ui(request): + ''' + Allows user to change description of group through UI. + + :param request: Incoming HTTP POST Request containing: + + {"groupOwner": , "groupId": < ID of group>, "username": User who submitted the request, "description": } + + :return JSON: {"Changed | Error": } + ''' + + # If request is a POST request, add it to the server + if request.method == 'POST': + result = db.change_description(request.POST['username'], request.POST['groupId'], request.POST['groupOwner'], request.POST['description']) + if result != None: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Changed description")), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def add_member_through_ui(request): + ''' + Allows user to add members to a group through UI. + + :param request: Incoming HTTP POST Request containing: + + {"groupOwner": , "groupId": < ID of group>, "member": "member to add"} + + :return JSON: {"Message": } + ''' + + # If request is a POST request, add it to the server + if request.method == 'POST': + result = db.add_user_to_group(request.POST['member'], request.POST['groupOwner'], request.POST['groupId']) + return HttpResponse(json.dumps(db.sendMessage(200, result)), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def remove_member_through_ui(request): + ''' + Allows user to remove members from a group through UI. + + :param request: Incoming HTTP POST Request containing: + + {"groupOwner": , "groupId": < ID of group>, "member": "member to remove"} + + :return JSON: {"Message": } + ''' + + # If request is a POST request, add it to the server + if request.method == 'POST': + result = db.remove_user_from_group(request.POST['member'], request.POST['groupOwner'], request.POST['groupId']) + return HttpResponse(json.dumps(db.sendMessage(200, result)), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def getGroupsWithLayout(request): + ''' + Gets all groups that have the particular graph shared in the group. + + :param request: Incoming HTTP POST Request containing: + + {"loggedIn": [current user], "owner": < Owner of graph >, "gid": "Id of graph"} + + :return JSON: {"Groups":[Groups]} + ''' + if request.method == 'POST': + result = db.is_layout_shared(request.POST['layout'], request.POST['loggedIn'], request.POST['owner'], request.POST['gid']) + return HttpResponse(json.dumps({"StatusCode": 200, "Group_Information": result}), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def setDefaultLayout(request): + if request.method == 'POST': + result = db.setDefaultLayout(request.POST['layoutId'], request.POST['gid'], request.POST['uid']) + if result != None: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Set " + request.POST['layoutId'] + " as default")), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def removeDefaultLayout(request): + if request.method == 'POST': + result = db.removeDefaultLayout(request.POST['layoutId'], request.POST['gid'], request.POST['uid']) + if result != None: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Removed " + request.POST['layoutId'] + " as default")), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def renderImage(request): + # This is a temporary route so Allison's graphs show up + return HttpResponseRedirect(URL_PATH + 'static/images/legend.png'); + +def shareLayoutWithGroups(request): + ''' + Toggles shares/unshare graph with specified groups. + + :param request:Incoming HTTP POST Request containing: + {"gid": , "owner": , "groups_to_share_with": [group_ids], "groups_not_to_share_with": [group_ids]} + :return TBD + ''' + if request.method == 'POST': + layout_owner = request.POST['loggedIn'] + gid = request.POST['gid'] + uid = request.POST['uid'] + layoutId = request.POST['layoutId'] + current_user = request.session.get('uid') + + # If user is not logged on, they can't do anything + if current_user == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make share layouts!")), content_type="application/json") + + # If user is the owner of the graph or if they are the layout owner, can they share a layout + if current_user != uid and db.get_layout_for_graph(layoutId, layout_owner, gid, uid, current_user) == None: + return HttpResponse(json.dumps(db.throwError(500, "Not authorized to share layouts!")), content_type="application/json") + + if db.can_see_shared_graph(current_user, uid, gid) == None: + return HttpResponse(json.dumps(db.throwError(500, "Not allowed to do this operation!")), content_type="application/json") + + if len(db.get_all_groups_for_this_graph(uid, gid)) == 0: + return HttpResponse(json.dumps(db.throwError(400, "No groups to share with. Either share this graph with a group first or make this graph public!")), content_type="application/json") + else: + if db.is_public_graph(uid, gid): + db.makeLayoutPublic(uid, gid, layoutId, layout_owner) + else: + db.share_layout_with_all_groups_of_user(uid, gid, layoutId, layout_owner) + + return HttpResponse(json.dumps(db.sendMessage(200, "Okay")), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +##### END VIEWS ##### + +##### REST API ##### + +def graph_exists(request, user_id, graphname): + ''' + Checks to see if a graph exists + + @param request: HTTP POST Request + @param user_id: Owner of graph + @param: graph_name: Name of graph + ''' + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + graph_exists = db.graph_exists(user_id, graphname) + + if graph_exists == False: + return HttpResponse(json.dumps(db.throwError(404, "User " + user_id + " owns no graph with id " + graphname + "!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "User " + user_id + " owns a graph with id " + graphname + "!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def upload_graph(request, user_id, graphname): + ''' + Uploads a graph for a user + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :param user_id: Id of the user + :param graphname: Name of the graph + + :return response: JSON Response: {"Success|Error": } + + ''' + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + graph_errors = db.insert_graph(user_id, graphname, request.FILES['graphname'].read()) + + if graph_errors != None: + return HttpResponse(json.dumps(db.throwError(400, graph_errors), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(201, "Added " + graphname + " for " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def update_graph(request, user_id, graphname): + ''' + Updates an already existing graph. + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :param user_id: Id of the user + :param graphname: Name of the graph + + :return response: JSON Response: {"Success|Error": } + ''' + + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + graph_errors = db.update_graph(user_id, graphname, request.FILES['graphname'].read()) + if graph_errors != None: + return HttpResponse(json.dumps(db.throwError(404, graph_errors), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(201, "Updated " + graphname + " for " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def retrieve_graph(request, user_id, graphname): + ''' + Retrieves the json of a specified graph + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :param user_id: Id of the user + :param graphname: Name of the graph + + :return response: JSON Response: {"Graph|Error": } + ''' + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + jsonData = db.get_graph_json(user_id, graphname) + if jsonData != None: + return HttpResponse(jsonData) + else: + return HttpResponse(json.dumps(db.throwError(404, "No Such Graph Exists!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def remove_graph(request, user_id, graphname): + ''' + Removes a graph from the server + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :param user_id: Id of the user + :param graphname: Name of the graph + + :return response: JSON Response: {"Success|Error": } + + ''' + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + jsonData = db.get_graph_json(user_id, graphname) + if jsonData != None: + db.delete_graph(user_id, graphname) + return HttpResponse(json.dumps(db.sendMessage(200, "Successfully deleted " + graphname + " owned by " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(404, "No Such Graph Exists."), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def view_all_graphs_for_user(request, user_id): + ''' + View all graphs for a user + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :return response: JSON Response: {"Graphs|Error": } + ''' + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + data = db.get_all_graphs_for_user(user_id) + return HttpResponse(json.dumps({"StatusCode": 200, "Graphs": data}, indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def make_graph_public(request, user_id, graphname): + ''' + Makes specified graph and all of its layouts public + + :param request: Incoming HTTP POST Request containing: + {"username": ,"password": } + :param graphname: name of graph to make public + :return response: JSON Response: {"Success|Error": } + ''' + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + data = db.change_graph_visibility(1, request.POST['username'], graphname) + + if data == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Successfully made " + graphname + " owned by " + user_id + " public."), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(404, data), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def make_graph_private(request, user_id, graphname): + ''' + Makes specified graph and all of its layouts public + + :param request: Incoming HTTP POST Request containing: + {"username": ,"password": } + :param graphname: name of graph to make public + :return response: JSON Response: {"Success|Error": } + ''' + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + data = db.change_graph_visibility(0, request.POST['username'], graphname) + if data == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Successfully made " + graphname + " owned by " + user_id + " private."), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(404, data), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def get_groups(request): + ''' + Get all groups that are on this server + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :return response: JSON Response: {"Groups|Error": } + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + data = db.get_all_groups_with_member(request.POST['username']) + return HttpResponse(json.dumps({"StatusCode": 200, "Groups": data}, indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def get_group(request, group_owner, groupname): + ''' + Get information about this group + + :param request: Incoming HTTP POST Request containing: {"username": ,"password": } + :param group_owner: Owner of group to get from server + :param groupname: ID of group to get from server + :return response: JSON Response: {"Groups|Error": } + + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + data = db.get_group(group_owner, groupname) + if data == None: + return HttpResponse(json.dumps(db.throwError(404, "Group does not exist for this user!"), indent=4, separators=(',', ': ')), content_type="application/json") + + return HttpResponse(json.dumps({"StatusCode": 200, "Groups": data}, indent=4, separators=(',', ': ')), content_type="application/json"); + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def delete_group(request, group_owner, groupname): + ''' + Deletes a group from the server. + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + :param groupname: Name of group to delete from server + + :return response: JSON Response: {"Success|Failure": } + + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if group_owner == request.POST['username']: + data = db.remove_group(request.POST['username'], groupname) + if data != None: + return HttpResponse(json.dumps(db.throwError(404, "Group not found!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, data), indent=4, separators=(',', ': ')), content_type="application/json"); + else: + return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def add_group(request, group_owner, groupname): + ''' + Adds a group to the server. If groupname already exists under a user account, then it will fail, otherwise a group name is created under the user's account. + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :param group: Name of group to add to server + :return response: JSON Response: {Upload: "Success|Failure", "Group Name|Error": group | error} + ''' + + # If request is a POST request, add it to the server + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if group_owner == request.POST['username']: + data = create_group(request, groupname) + return HttpResponse(data) + else: + return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def get_group_for_user(request, user_id): + ''' + Gets all groups that a user is a part of. + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :param user_id: Email of the user to get the groups for + :return JSON Response: {"User": , "Groups": } + + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + group = db.groups_for_user(user_id) + return HttpResponse(json.dumps({"StatusCode": 200, "Groups": group}, indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def add_user_to_group(request, group_owner, groupname, user_id): + ''' + Adds specified user to a group. + + :param request: Incoming HTTP POST Request containing: + + {"username": ,"password": } + + :param groupname: Name of group to add user to + :param user_id: Email of user to add to the group + :return JSON Response: {"Response": } + ''' + + if request.method == 'POST': + + # Check to see if the user/password is acceptable + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if group_owner == request.POST['username']: + # Adds user to group + data = db.add_user_to_group(user_id, request.POST['username'], groupname) + + # If nothing is returned, that means that something went wrong + if data == None: + return HttpResponse(json.dumps(db.throwError(400, "Group doesn't exist or user has already been added!"), indent=4, separators=(',', ': ')), content_type="application/json") + + return HttpResponse(json.dumps(db.sendMessage(200, data), indent=4, separators=(',', ': ')), content_type="application/json") + + else: + return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def remove_user_from_group(request, group_owner, groupname, user_id): + ''' + Removes user from group + + :param HTTP POST Request containing + {"username": , "password": } + :param groupname: Name of group to remove user from + :param user_id: Email of user to remove + + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if group_owner == request.POST['username']: + group = db.remove_user_from_group(user_id, request.POST['username'], groupname) + return HttpResponse(json.dumps(db.sendMessage(200, group), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def share_graph(request, graphname, group_owner, groupname): + ''' + Share a graph with group. + + :param HTTP POST Request containing + {"username": , "password": } + :param graphname: Name of graph to unshare_graph + :param groupname: Name of group to unshare graph with + + :return JSON: {"Response": } + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + result = db.share_graph_with_group(request.POST['username'], graphname, groupname, group_owner) + if result == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Graph successfully shared with group!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(400, result), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def unshare_graph(request, graphname, group_owner, groupname): + ''' + Unshare a graph from a group. + + :param HTTP POST Request containing + {"username": , "password": } + :param graphname: Name of graph to unshare_graph + :param groupname: Name of group to unshare graph with + + :return JSON: {"Response": } + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + result = db.unshare_graph_with_group(request.POST['username'], graphname, groupname, group_owner) + if result == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Graph successfully unshared with group!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(400, result), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def get_tags_for_user(request, username): + ''' + Get all tags that a user has under their name + :param HTTP POST Request containing + {"username": , "password": } + :param username: Name of user to get tags from + + :return JSON: {"Response": } + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + result = db.get_all_tags_for_user(username) + return HttpResponse(json.dumps({"StatusCode": 200, "Tags": result}, indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def get_all_tags_for_graph(request, username, graphname): + ''' + Get all tags that a user has under their graph + :param HTTP POST Request containing + {"username": , "password": } + :param graphname: Name of graph to get tags from + :param username: Name of user to get graph of + + :return JSON: {"Response": } + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + result = db.get_all_tags_for_graph(graphname, username) + if result == None: + return HttpResponse(json.dumps(db.sendMessage(404, "Graph does not exist!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps({"StatusCode": 200, "Tags": result}, indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def get_all_graphs_for_tags(request, tag): + ''' + Get all graphs associated with these tags + :param HTTP POST Request containing + {"username": , "password": } + :param tag: Name of tag to get graphs of + + :return JSON: {"Response": } + ''' + + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + result = db.get_all_graphs_for_tags(tag) + return HttpResponse(json.dumps({"StatusCode": 200, "Graphs": result}, indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def make_all_graphs_for_tag_public(request, username, tagname): + ''' + Makes all graphs with this tag public + :param HTTP POST Request containing + {"username": , "password": } + :param username: Owner of graphs to change + :param tag: Name of tag to get graphs of + + :return JSON: {"Response": } + ''' + + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if username == request.POST['username']: + error = db.change_graph_visibility_for_tag(1, tagname, username) + if error == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been made public"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, error), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def make_all_graphs_for_tag_private(request, username, tagname): + ''' + Makes all graphs with this tag private + :param HTTP POST Request containing + {"username": , "password": } + :param username: Owner of graphs to change + :param tag: Name of tag to get graphs of + + :return JSON: {"Response": } + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if username == request.POST['username']: + error = db.change_graph_visibility_for_tag(0, tagname, username) + if error == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been made private"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, error), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +def delete_all_graphs_for_tag(request, username, tagname): + ''' + Makes all graphs with this tag private + :param HTTP POST Request containing + {"username": , "password": } + :param username: Owner of graphs to change + :param tag: Name of tag to get graphs of + + :return JSON: {"Response": } + ''' + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + + if username == request.POST['username']: + db.delete_all_graphs_for_tag(tagname, username) + return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been deleted"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + +# Private Utility methods used throughout views.py + +def handler_404(request): + if request.method == 'POST': + return HttpResponse(json.dumps(db.throwError(404, "REST API endpoint does not exist!")), content_type="application/json") + else: + return render(request,'404.html') + +def handler_500(): + if request.method == 'POST': + return HttpResponse(json.dumps(db.throwError(500, "An error was encountered during this request. REST API call not successful.")), content_type="application/json") + else: + return render(request,'500.html') + +def constructGraphMessage(context, view_type, search, tags): + if view_type == 'shared': + if search == None and tags == None: + context['message'] = "It appears that there are no groups that have shared their graphs." + elif search != None and tags == None: + context['message'] = "It appears that there are no groups that have shared their graphs with the given search criteria." + elif tags != None and search == None: + context['message'] = "It appears that there are no groups that have shared their graphs with the given tag criteria." + else: + context['message'] = "It appears that there are no groups that have shared their graphs with the given search and tag criteria." + + elif view_type == 'public': + if search == None and tags == None: + context['message'] = "It appears that there are no public graphs available. Please create an account and join a group or upload your own graphs through the REST API or web interface." + elif search != None and tags == None: + context['message'] = "It appears that there are no public graphs available that match the search criteria. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search criteria." + elif tags != None and search == None: + context['message'] = "It appears that there are no public graphs available that match the tag criteria. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given tag criteria." + else: + context['message'] = "It appears that there are no public graphs available that match the search and tag criteria. Please create an account and join a group or upload your own graphs with the given search and tag criteria." + + elif view_type == 'all': + if search == None and tags == None: + context['message'] = "It appears that there are no graphs available." + elif search != None and tags == None: + context['message'] = "It appears that there are no graphs available that match the search criteria." + elif tags != None and search == None: + context['message'] = "It appears that there are no graphs available that match the tag criteria." + else: + context['message'] = "It appears that there are no graphs available that match the search and tag criteria." + else: + if search == None and tags == None: + context['message'] = "It appears that you currently have no graphs uploaded. Please create an account and join a group or upload your own graphs through the REST API or web interface." + elif search != None and tags == None: + context['message'] = "It appears that you currently have no graphs uploaded that match the search terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search criteria in order to see them here." + elif tags != None and search == None: + context['message'] = "It appears that you currently have no graphs uploaded that match the tag terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given tag criteria in order to see them here." + else: + context['message'] = "It appears that you currently have no graphs uploaded that match the serach and tag terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search and tag criteria in order to see them here." + + return context diff --git a/build/lib/graphspace/__init__.py b/build/lib/graphspace/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/graphspace/settings/__init__.py b/build/lib/graphspace/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/build/lib/graphspace/settings/base.py b/build/lib/graphspace/settings/base.py new file mode 100644 index 00000000..be28b343 --- /dev/null +++ b/build/lib/graphspace/settings/base.py @@ -0,0 +1,105 @@ +""" +Django settings for graphspace project. + +For more information on this file, see +https://docs.djangoproject.com/en/1.6/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.6/ref/settings/ +""" + +import os +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +ALLOWED_HOSTS = ['*'] + + +# GLOBAL VALUES FOR DATABASE +DB_FULL_PATH = os.path.join(BASE_DIR, 'graphspace.db') +DATABASE_LOCATION = 'sqlite:///' + DB_FULL_PATH + +# Application definition + +INSTALLED_APPS = ( + 'analytical', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'graphs' +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware' +) + +ROOT_URLCONF = 'graphspace.urls' + +WSGI_APPLICATION = 'graphspace.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/1.6/ref/settings/#databases + +# DATABASES = { +# 'default': { +# 'ENGINE': 'django.db.backends.postgresql_psycopg2', +# 'NAME': 'graphspace', +# 'USER': 'adb', +# 'PASSWORD': '', +# 'HOST': 'localhost', +# 'PORT': '5432' +# } +# } + +## Old Sqlite Implementation ### +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'graphspace.db') + } +} + +# Internationalization +# https://docs.djangoproject.com/en/1.6/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +# Changed from 'UTC'. +TIME_ZONE = 'EST' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Email setup +EMAIL_USE_TLS = True +EMAIL_PORT = 587 + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.6/howto/static-files/ + +STATIC_URL = '/static/' + +STATICFILES_DIRS = ( + os.path.join(BASE_DIR, "static"), +) + +TEMPLATE_DIRS = [os.path.join(BASE_DIR, 'templates')] + +# for authentication. Since we need to use SQL Alchemy for the ORM, we cannot use the authentication backend automatically provided by Django when using the Django ORM. +AUTHENTICATION_BACKENDS = ('graphs.auth.AuthBackend.AuthBackend',) + +# Following the recommendation of the Django tutorial at +PASSWORD_HASHERS = ( + 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + 'django.contrib.auth.hashers.BCryptPasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.SHA1PasswordHasher', + 'django.contrib.auth.hashers.MD5PasswordHasher', + 'django.contrib.auth.hashers.CryptPasswordHasher', +) diff --git a/build/lib/graphspace/settings/local.py b/build/lib/graphspace/settings/local.py new file mode 100644 index 00000000..11a6fb74 --- /dev/null +++ b/build/lib/graphspace/settings/local.py @@ -0,0 +1,32 @@ +from graphspace.settings.base import * + +# variables for setting up account through which GraphSpace emails +EMAIL_HOST = 'NONE' +EMAIL_HOST_USER = 'NONE' +EMAIL_HOST_PASSWORD = 'NONE' + +# If error is thrown, display the error in the browser (ONLY FOR LOCAL MACHINES) +DEBUG = True +TEMPLATE_DEBUG = True + +# URL through which to access graphspace +URL_PATH = "http://localhost:8000/" + +# If tracking is enabled for GraphSpace in Google Analytics +GOOGLE_ANALYTICS_PROPERTY_ID = 'UA-00000000-0' + +# Keys given by creating a requestor account on Amazon Mechanical Turk (https://www.mturk.com/mturk/welcome) +AWSACCESSKEYID = 'None' +SECRETKEY = 'None' + +# Path to GraphSPace +PATH = "/Path_to_GraphSpace" + +# SHOULD NEVER CHANGE THIS VALUE +SECRET_KEY = 'this-is-a-secret-key-for-local-settings-only' + +# If needing to test on production mturk account (real money) +# AWS_URL = 'https://mechanicalturk.amazonaws.com' + +# Sandbox (development) MTURK (fake money used) +AWS_URL = 'https://mechanicalturk.sandbox.amazonaws.com' diff --git a/build/lib/graphspace/settings/testing.py b/build/lib/graphspace/settings/testing.py new file mode 100644 index 00000000..11a6fb74 --- /dev/null +++ b/build/lib/graphspace/settings/testing.py @@ -0,0 +1,32 @@ +from graphspace.settings.base import * + +# variables for setting up account through which GraphSpace emails +EMAIL_HOST = 'NONE' +EMAIL_HOST_USER = 'NONE' +EMAIL_HOST_PASSWORD = 'NONE' + +# If error is thrown, display the error in the browser (ONLY FOR LOCAL MACHINES) +DEBUG = True +TEMPLATE_DEBUG = True + +# URL through which to access graphspace +URL_PATH = "http://localhost:8000/" + +# If tracking is enabled for GraphSpace in Google Analytics +GOOGLE_ANALYTICS_PROPERTY_ID = 'UA-00000000-0' + +# Keys given by creating a requestor account on Amazon Mechanical Turk (https://www.mturk.com/mturk/welcome) +AWSACCESSKEYID = 'None' +SECRETKEY = 'None' + +# Path to GraphSPace +PATH = "/Path_to_GraphSpace" + +# SHOULD NEVER CHANGE THIS VALUE +SECRET_KEY = 'this-is-a-secret-key-for-local-settings-only' + +# If needing to test on production mturk account (real money) +# AWS_URL = 'https://mechanicalturk.amazonaws.com' + +# Sandbox (development) MTURK (fake money used) +AWS_URL = 'https://mechanicalturk.sandbox.amazonaws.com' diff --git a/build/lib/graphspace/urls.py b/build/lib/graphspace/urls.py new file mode 100644 index 00000000..eb933e9c --- /dev/null +++ b/build/lib/graphspace/urls.py @@ -0,0 +1,16 @@ +from django.conf.urls import patterns, include, url +from django.contrib import admin +admin.autodiscover() + +urlpatterns = patterns('', + # Examples: + # url(r'^$', 'graphspace.views.home', name='home'), + # url(r'^blog/', include('blog.urls')), + + #url(r'^admin/', include(admin.site.urls)), + url(r'^', include('graphs.urls'), name='base'), + #url(r'^graphs/$', include('graphs.urls')), +) + +handler404 = 'graphs.views.handler_404' +handler500 = 'graphs.views.handler_500' \ No newline at end of file diff --git a/build/lib/graphspace/wsgi.py b/build/lib/graphspace/wsgi.py new file mode 100644 index 00000000..ce82ea93 --- /dev/null +++ b/build/lib/graphspace/wsgi.py @@ -0,0 +1,14 @@ +""" +WSGI config for graphspace project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ +""" + +import os +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "graphspace.settings.production") + +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() diff --git a/dist/GraphSpace-1.1.0-py2.7.egg b/dist/GraphSpace-1.1.0-py2.7.egg new file mode 100644 index 0000000000000000000000000000000000000000..03c939df23a2c9bc1bdfef9d8fc1feb39b7c2df4 GIT binary patch literal 150522 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

    _2&ct~v|x5&JltwJF3 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�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 NLvVngQ-NpA`hUgvMR))J literal 0 HcmV?d00001 diff --git a/get-pip.py b/get-pip.py new file mode 100644 index 00000000..e3484167 --- /dev/null +++ b/get-pip.py @@ -0,0 +1,2565 @@ +#!/usr/bin/env python +# +# Hi There! +# You may be wondering what this giant blob of binary data here is, you might +# even be worried that we're up to something nefarious (good for you for being +# paranoid!). This is a base85 encoding of a zip file, this zip file contains +# an entire copy of pip. +# +# Pip is a thing that installs packages, pip itself is a package that someone +# might want to install, especially if they're looking to run this get-pip.py +# script. Pip has a lot of code to deal with the security of installing +# packages, various edge cases on various platforms, and other such sort of +# "tribal knowledge" that has been encoded in its code base. Because of this +# we basically include an entire copy of pip inside this blob. We do this +# because the alternatives are attempt to implement a "minipip" that probably +# doesn't do things correctly and has weird edge cases, or compress pip itself +# down into a single file. +# +# If you're wondering how this is created, it is using an invoke task located +# in tasks/generate.py called "installer". It can be invoked by using +# ``invoke generate.installer``. + +import os.path +import pkgutil +import shutil +import sys +import struct +import tempfile + +# Useful for very coarse version differentiation. +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 + +if PY3: + iterbytes = iter +else: + def iterbytes(buf): + return (ord(byte) for byte in buf) + +try: + from base64 import b85decode +except ImportError: + _b85alphabet = (b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + b"abcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~") + + def b85decode(b): + _b85dec = [None] * 256 + for i, c in enumerate(iterbytes(_b85alphabet)): + _b85dec[c] = i + + padding = (-len(b)) % 5 + b = b + b'~' * padding + out = [] + packI = struct.Struct('!I').pack + for i in range(0, len(b), 5): + chunk = b[i:i + 5] + acc = 0 + try: + for c in iterbytes(chunk): + acc = acc * 85 + _b85dec[c] + except TypeError: + for j, c in enumerate(iterbytes(chunk)): + if _b85dec[c] is None: + raise ValueError( + 'bad base85 character at position %d' % (i + j) + ) + raise + try: + out.append(packI(acc)) + except struct.error: + raise ValueError('base85 overflow in hunk starting at byte %d' + % i) + + result = b''.join(out) + if padding: + result = result[:-padding] + return result + + +def bootstrap(tmpdir=None): + # Import pip so we can use it to install pip and maybe setuptools too + import pip + from pip.commands.install import InstallCommand + from pip.req import InstallRequirement + + # Wrapper to provide default certificate with the lowest priority + class CertInstallCommand(InstallCommand): + def parse_args(self, args): + # If cert isn't specified in config or environment, we provide our + # own certificate through defaults. + # This allows user to specify custom cert anywhere one likes: + # config, environment variable or argv. + if not self.parser.get_default_values().cert: + self.parser.defaults["cert"] = cert_path # calculated below + return super(CertInstallCommand, self).parse_args(args) + + pip.commands_dict["install"] = CertInstallCommand + + implicit_pip = True + implicit_setuptools = True + implicit_wheel = True + + # Check if the user has requested us not to install setuptools + if "--no-setuptools" in sys.argv or os.environ.get("PIP_NO_SETUPTOOLS"): + args = [x for x in sys.argv[1:] if x != "--no-setuptools"] + implicit_setuptools = False + else: + args = sys.argv[1:] + + # Check if the user has requested us not to install wheel + if "--no-wheel" in args or os.environ.get("PIP_NO_WHEEL"): + args = [x for x in args if x != "--no-wheel"] + implicit_wheel = False + + # We only want to implicitly install setuptools and wheel if they don't + # already exist on the target platform. + if implicit_setuptools: + try: + import setuptools # noqa + implicit_setuptools = False + except ImportError: + pass + if implicit_wheel: + try: + import wheel # noqa + implicit_wheel = False + except ImportError: + pass + + # We want to support people passing things like 'pip<8' to get-pip.py which + # will let them install a specific version. However because of the dreaded + # DoubleRequirement error if any of the args look like they might be a + # specific for one of our packages, then we'll turn off the implicit + # install of them. + for arg in args: + try: + req = InstallRequirement.from_line(arg) + except: + continue + + if implicit_pip and req.name == "pip": + implicit_pip = False + elif implicit_setuptools and req.name == "setuptools": + implicit_setuptools = False + elif implicit_wheel and req.name == "wheel": + implicit_wheel = False + + # Add any implicit installations to the end of our args + if implicit_pip: + args += ["pip"] + if implicit_setuptools: + args += ["setuptools"] + if implicit_wheel: + args += ["wheel"] + + delete_tmpdir = False + try: + # Create a temporary directory to act as a working directory if we were + # not given one. + if tmpdir is None: + tmpdir = tempfile.mkdtemp() + delete_tmpdir = True + + # We need to extract the SSL certificates from requests so that they + # can be passed to --cert + cert_path = os.path.join(tmpdir, "cacert.pem") + with open(cert_path, "wb") as cert: + cert.write(pkgutil.get_data("pip._vendor.requests", "cacert.pem")) + + # Execute the included pip and use it to install the latest pip and + # setuptools from PyPI + sys.exit(pip.main(["install", "--upgrade"] + args)) + finally: + # Remove our temporary directory + if delete_tmpdir and tmpdir: + shutil.rmtree(tmpdir, ignore_errors=True) + + +def main(): + tmpdir = None + try: + # Create a temporary working directory + tmpdir = tempfile.mkdtemp() + + # Unpack the zipfile into the temporary directory + pip_zip = os.path.join(tmpdir, "pip.zip") + with open(pip_zip, "wb") as fp: + fp.write(b85decode(DATA.replace(b"\n", b""))) + + # Add the zipfile to sys.path so that we can import it + sys.path.insert(0, pip_zip) + + # Run the bootstrap + bootstrap(tmpdir=tmpdir) + finally: + # Clean up our temporary working directory + if tmpdir: + shutil.rmtree(tmpdir, ignore_errors=True) + + +DATA = b""" +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 +)xSsFUoLR;R%DaVr#m(89VPc%8ES +>!Qx{G2E2p@c|HTc+LJuVfB3x!?NEGt-RX6?JK2PSI!~&hT8T+-RsBTU>vm5XFm%B|XCqI*cVSUM +KE8D7PlsZAfzBc|%NE!LiZ{;*{LCQ7Kep(w2j+w7Dz2Q{pCjJ;LVRdkyaF~<(8{ox2c!%YzT_`OU|Wh +}=}vH|m!ZT$zdtBZ!LfN-j6p_wh#?w`a|hS0CG`p?wAI8THbcFd>}wTq%ybF_&ZLQx_?75IuP623n~4v954u +6AHoALD`rAOrUvyo*~`=-$QCu&d?O=YQy7{kptSEMvTZiqE8x#VV#s~bs+GN<++bm%FD{Kp~Tv<>hXV +P-#5~LZ@)fm$9~dc7;i;yE>16wiR1^YZZ}Ldaj=pK0zbMdo*}f`28rHi`^Lf7!p>k~=&Lvrz%=3ku~I +~=*k>GAhhN(=Pc`kx+xggDudBtD#@*y{aY>gKbg{g=y_wwOZrz)Smy{}f1%-wWem+g_?B~Vx%{6^@a< +jNtYNBM)-7e}-^LK6LSO5)vf7}aIi#7BN_yNt#0H#Fkl_QBvsseVS +Ej;Sl<*Au@7y1HCMT*>LfUIcm=JXdE^@E-_gnfTv|LZ4$!7!tbW)I+41K2?j{LyndYc;R7{#{Bu-vfI +M^Af#JzxC%!`U$bxmmMp_J2(q4+}y@4B;L+<`~Ev1!@2^ld2q@33awrB+PE^6pW<|Wpmg;0O;$bg%9; +4K%$Tyh1iXSE+_i%&3`0DVu9a#ZE#PoP(p?P)Z4%`P^xhaCujDAk6_5eA_=`>GDGSriq5gBaunn6J9Z +4!UqSep=Y*s^Tb%x1EYQ0e_rUVAxSS|5$9Q<7X8lXK{hl3GWz5q>0ktNtEBOqs7Zu}rOD;jf&2ydVu! +Nlam5e_pQajyePSH79Z2%y!VPNeC0pc39Gy1@!p>>DJsY->=@J{I)kd6;j*extM1u7NK1yj-#W=SCsk{80psbb4eBv +VnCbQalM2hJIyG_>hxbnG`wuY}+c?MLIcQLzXHnb4kT`IA(P0ZH%g9Rst3umPW5L}Vk-#6xV<08E3P1 +#P!=dopWbhk}rSF$OJCpKBE3Ubbn&oKzq}dtC%^L|ivYY$mXrG?;)XAhXhT3&(%2__jy0;hm45`7~!} +}o-R>%}}>6ayto9Q;Zy+B}fW(Vl5Oh<%0B9>>0G5`9TvNp{mhO0kI7?%FHK)fGPeSd-OllI8d8S^9kO +)`Z_9AyL1nxeCGuAOKIzn8kc_b0pQGhR9Hz~3V(k5~F+g5lwRb6*Rrny6Q=%=cH@SU?*I4ZeVIsp2QP +wM$Ycsm!2V&vS>0W24s$@B67kT&ED}b7lKFGvj>F@aYeoo)9Ynb-;%^n*(RV!GVx^f;87WezW&)@ZYC +?dJ~B;VwR`)D8#Pu^7 +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 +Ha;c#$xhJ&kV=X#?>T_r +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 +(QOb2LHXTWh#HfR2duysG{y +69oQrdYd?8Lw#Q518f;v97PgMI50s&A+5EO;HmIZZOqUbc^P?QodeHCw-dAm#4YH({@@*^pGMk$3s?1lcvfMDM +v6ayG*$lx%Y9l`Wslqbhww5xfL6}5g56y=>Lzbi(FkN+dpnvqY!8-HjfPs5l5ZkCSw~jD*E}0tOgltr*o4N)XDx4FX9rIv_A6+%`QL{PKyZ=q(U{GVTDt}Z%3%|KXE22 +b?wrVvU)(O-Am~KP$Zx0Uat7(7_va6Z~Jd5wPV6{x8nj^+rYXRwxOsXxdb@I*CS8*?=7^aPw9zIEdv|$n{ +KS8Ni#@5?#7HEjszG+n?w$WBwi2(zHkRS(}MvLqFT^b|pB-0(3Ee +{DEN25jAVcQ)^NQy1CcdC)qg+(2+Z((a50>{hr_ ++@>c9=(}4U1|7BHGSIEUHm>u|%UmZOL`0P{rtXs<^XgctRutzj9jYd&oY1|K1YJWb3)E +&RzWCzmCasrh;u>i`!hFgCwo?jgkXS)5xs$7+q +AI2Tn52sXcjcBN-)?Yesh8Y9|y~zC#N|pC$`}-R;fd?oE!K21PrC4yO>3+Jd4PRCT^Y&k`Gya*ozH;tuMvx)D`d;}ktkc63iITMb3 +g97-8Gn-3OR59&Xx(;H*MVUWjqEiA&STtyaBf+x-d%|#LmYQ;4%9a +^QJ4d4Qy2T(_fq}Mhy4i4ZwdqSO)4CH~eI>&ZOmnmsQvg!}H6Op~YccKO51SuH7hZdWc1+MrPamFxvu +O}(fX4)INM@Lptj@!^cZ%MclrJl=;J!Q4LCnaB1-tenk>A>QxM))hk;!y@!Z4eBEkTa!UtlQbi>>}Km +$iQGPY;COZ7vnP@~p;YrDcy3iH!8Hfr!)GV#3AI#NQFFtUnP?j*k4-Y_>U&JHR$`Wj+WBt=~!!)X1RO +ji|F%ug@r2i#a2B5(@SnQePc?)o%hj8D2s6J)xy_mF0_^G=h`+(;f*p88RZ2VYpUGRIC%lQS$=t28WwHpvL-kjLjoWvGJ*xOUQm;l7d08h1KE~ISYbaO!V0meiF +5_Js|!@hQ40ckLan2Fi8vy$d*U86>qw{qT+UyMO|ak>zBLop1Z4P{FmP-vv)MZx(=0tR{o!E|V~pk4} +WxX4ipmKt{gN#J4!9uJrEV0|P#o7o1m5 +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 +d!}YK@cD99H1vIT3T@5F +NdLve=gCihW{K9Dk>JN0*bYXN1T(;Z-hrB-gDsGM*>c!fmuVj-o4TfrQ{;adlIb|jp_dp|M~;PD`sAqhJWLE7fB$XIT7fgv^vo;G~yTRImdbx58yK-sj;Xvq#KBv8$M1|9CB10n- +f6t!M|Y}PGKF>|QHdOd6fDKr@tBqaa#&eEz)EHwt&B=lJExNgqCQ-Az+FxW)aSxlGgFc2T_-q?M+5gn +cW)6PmlwQv0J6UxuWp_|SIg0&5389fWbu^@&l-|mFFYl$3dmCd2!y4=~U!GoA~q{B@!-U0hwll8?m?7s{0v0&ZB3Cg_-?z^L*PASG&IS54 +1Cn@f%bvZE|ar%8I9eBWEXMdf6T6k$-n7FfGKYXm|?Ju4tA5nw;4ODwSPZTkSXss2*Rm0P{A%zM+^QP +bICa=1l1T!dy^R=roM7)sIhk5=GA7)v6(JM)+N6jR~Z|+9)R`wOlv3XM8P8;N^HQ}$(qJ|ty=7mTmH87*Fp)*Wcej?poqW)q$3KFq#DzWm2)q(19VoC2t?hfqC>a-1 +v_G3g-oj@{qzUIKf`CyVmE94hHqft|Aru@U@NcFr}v5{4F2j(z>oNb;pO!j@m;WSKf`!z&6hAxwu|b( +p>x%L?xY(VU>nX85*>m^tuVvxViSv&u@na{@v+qygn0W0}N>ZmEgJpU`I~u#H>CQuy7M?vE?5np2;7y{hW!IJBw9R^h3*5(Q8V=f#+uuK*M=#B>pV`~6^=E +eX*6M*43tYmZGbg(GnD6q$4Pb;s>>PQdkW1DXQyQFxq3AHfmgC%9?#Kg4B0b+G%b^rm&U0;2JJ%XU&R +XV|OQ5(7|m<$)k?5=_TJ(8yOx5g +u9mk*)QA&ME7Vkde7W|-8pk>m!9!I4d?`92K)Cq&vf9X2vZ>((O+}a18GHIbij{VbBKuJk1lLKUHKU) +j!vHE6lNjUkdUI9~uewrhN@f`jEdbp+AHH3}#ZMOjhjP?4= +1b%8ilGwvB7qSoP|*0zVtB6PNQv6zZW{!NKjnN#A50)|r?bb3JRG&YwTAy;4@te_L$voe1{FuvADa;; +u(Dh+^3k9Oh#WV+!CWE4|Ja3^2sdInjD)Y`zIS6B)w?YQJL#MZI1sYo5Qyp++KFwbUN^p4U_*rIycmyj*0xgr +f{9O1zti)C7X$t +A3HZx2Bj`9S=LS)#l#=Uido3*v7?jyD +2`XoX8b>|y4PX%hqDOiFut0;sWSXIKpgH!pA;w9#Xhxp(SO@VF`T +)6R4HYwh=CEfmECa2efuPxI-Q&*MIig$l1z`f@*_+ti9hY|>xo9x8dl@y#;O@UX?GTb>E|N~rL%_JSw+ZDfj2FuXz|uzI8IfKBZU_2t0M~ +`#o&mu((>x{57qsRLHF)_d0}96Yr%HK%m#4PMEt%C>ZbdseAF1q}L;i&hU-cc +WN%5cm;=k=!l309m&=|ul)FF@gbj(=w +dcYX5Ma{YEjMIjD0@Fk+KQQol0be>>Ynm}%Pwxx>`%wgIzML*}Q~}-7AD*4jFNcPxnBC!~Fn|4bXRxp +!v=zy#m3B4`=5*1iRuf(b!?Z!&#buXdt)qn#;BvUPKfVvl*J&49(&3B%=b&#eQ#|Hrhavpv)(tC&j`5 +1?wQ(@TQ^8KOmf2If01IYuvqJW__X=yTnQBhS3GUVxMsWXll67l|b29S2aV{^c->v<8@QbMfJai9C8Z +VyDJhvr|%%__TfxUAh5miwj4*=pSEVsHLW3Z~h!Aa{J3_{KtATIAqeFj%%6N&-Qtp(U&&39EB;S!2WX^7SN2SeT7EbW^v-|OxU=V;W9KkwA9gx?XY?<^J@LicNpfrCtW01GR+lND5C9u*5y;Bl~rbJs^AQB0mXZ6Xxg*ad#c$V7 +=p=sP3-x`{eq@u8y%c>b>e3hwB&nu)2E2Pnu0wnMjvF^BppLJ`Ld6&0+t3-j8xzhx1ug8-n5%DAMIMt +Gw0~iQ+}Ik&Ct{OfXy@so0iMzH{J2uy2 +)6?G)RM_nngyWs13pBA5@81BB4f`z2a`ROrc%)R9(IMkpVeX|UC?*7b0wCZ|aQQM8wquHjR_vy#uY74 +>9EK00K+fTxk}-=p`WR@Q&Z(FNItD-c4({IH^xtdt-)ZviG4XxP>!8iHJ$?Pn>kUc}2ED!hsq5c;JyfuF&!LWr8r+_*ZD<+-Kl!p6^rxq*?PCW*-12f+G(`|7Ap4m^FSSm%}DG>rVaW6ooTA +?YxZ1W+;VuuR$jHL@amAXLVq_pj +=7jz#mHI$aDTk*2I_5lKqSl3V(RP%d*M_d6m*s)ow7xPs0b+hxZvr%Tc%46)&7cd~Z}aNZlFXIxjj5B +aW&Nc@c7SRObGdElYq+niI52*jd)sR@C2D-WI2Vmwe(*<1Eri~dQ5u8?I99q{A)VTFU>S$|Bo@qYnOO +9KQH0000800{-FNai)y_>c+!04yH>01*HH0B~t=FJ*XRWpH$9Z*FrgaCy~PO>^5g621FZpgfh5R3&qE +G9L$R<=`ZC?Apo4I!^X5Go^w^NWz!|H~=YG`|J001Efewa=f*tRaG2O0ve5n?$@uO^Yino$5fS-w#JL +vt4vgDXY1A4)|Dt_`XE>8{QUfE>1-k9^JQJtPR-{+7o~NTkPC0~x>ED-d=|#(O1a9~+&9D0wbW)RRUPSAtU%#__MRKG?(6sPrAHv&bo2y@?>@<@PdP{rB`H0sOCHJx|w-vd? +oW-(&xcMV{32WoifVFd~ZNxwbN!LbZ2tc=oh2^7qhiFW%#Z3mD7uwKL22=Yg?Oae6WT65_eM5!EM*d2 +r078Y>`T1Y$X;-EXj(ftne`5mphDf>aSWmRVY(+m%rP8?5}mMK1t_Q*xs|9ST(z*LBghK?L5NCdD?kz +WiOUkL*&}1r0d0N<*Tm>6Rf$+os!uuF0Qg8D0f${)=CuCSE4R2DtnR1N{LOdBv426x3F%ffquE{IlT#aQYyY +~|dG=PjXD>DKgi@T1h0HS7X+=l3AX5l2r_##0T|dV*GS}I^)=K3RHbxfesep;&X<3zX5YUXIpJBc(i4 +0UQ`;@uP*kH0}=|=iBPw-FKcwgt0E)k9G@DLOxRbDttv4IbeL)K72IpQ&_2w-?EvXZGKXi^(p&F5pU@ +@2G6FM3S){JeN2e*0rTLzX4@kH=5L2_K)9#DQT*cSYp<;er$zaj9d*JbWKNsNJhv7K=;@H4`eaE>kew +jHg|uxsP7?xn0gv;&s!I`M6_oD!P{DbH;u??|9UD)$9buk~LCra%6~lta!7@!e+bignd!8bkCRlOuY; +f`^!0nl4Zo`cQZ=b5li4fOSY!uf&@lwN^K0XDsILH5`q!927DnCkb>UViegJYD +Dm~p=hjB1D1?+rHGK_iGKG?})zvx?mpQz7fB_#FGo&gkIXG_zi9z)yyYfWkl~^_>p)KxuxU#oJ;Pv%R*!7h5y|IQ-RzF>S-_=7`JQcV4Ww<^$_(g#KWjY(SP3I2EVTKufVwFN2)LhEc;dW+{k`n%Z*&v3BwZU8q=Uu9MSQx5doFCI7 +~Zfg8b9i7ZBzue1vmcQ7X3I)!aAa$W>II*oiFw=ulG9OUhhk_A&!COr-WNUIO2?XiVwcg;Hfi0O* ++m_S8WzY>-7G*Qbjc|fEik+?9VcY8ItARc0o0#QfQF3`n#Q1he25_Dk_k!qjsH`5~Hv~%>_H0U%RG>` +_!%Tuy=P7AIrwy~HUsArrX6$%P=~fRgWuy|}OO@&+MGDbA*BlUB?COBJY(}JPDi-O(it}T?;@q +CBE;zz|EEYr`T)_k74`?s0=o_E7PP}?W4HQv(X362%=%BiSSkv_}DER;G6>Z48AbMnuRyC>$wh);kw0 +iNNSw9EPq<0NxHlcCQ^abPXwuY5oUK$qk2isiuKF-&&szn_DCz^7M!8Vl!;L|+* +PPQ&{d2m%x%zx1K3%@~$K|`L&*JSz@!{jWcz=2S=0@Nk-22m8ccVGLGO20W>(WBQ0rJ?@~Rryfq{nI7B&$_dITYIEM{x)TXnY8tJY7 +2m-y*2%gc=+XEL)^Y^JLCOuMx8Mf^ecgS1Wi(`cX*_uI<646i1*p-Z0Pw_GtsZ*%b$ONq)#*T+&@pk0 +5169<&Qu8{MHIL7|zcG}JDRX3pYYF48q< +nM0nhFSm+y5Q_c4nxxj2bDeHG(PQ#Gpa+G}PgfQF*5&&GB8DXY@;Uyk_4AMB$ItO7U_5GKie5)9?3-? +sXHVNW;fu`nC(>~@n+8yzA6e>07(D^p>5KQ^BE8xmgQSV6WiV(QY)|0UWO6!|_V@OU^T!sAKM0I^Q#; +{9w^dmA^fE1FbkI(~fq??Y0}oqRnsYcHli?2r{{c`-0|XQR000O82?eW2QW-jNrY8UZbCCc53;+NCaA +|NaX>Md?crI{x#eMsi+ctLa@AFq6a=oI~67897+U+!x)ODQ3=kt1fJa*Ea$yA3DA&EN_sgRUslzsf|- ++cfeUet^mZ>!zoa7f?+xVX44TwIJsqj&YbD=OKFMwVG8bJ5k}?cwdKsVJ&k-pO_}8l9|~dMoDh)xO&| +ay}Quc2_r@$QEr~?mIc>zfVro`?6lIi)yX^T&@d-u&m2cF1wsYw8^!=`a=Tt^|DDbyHE*%{ +D8GU*sHzKBeN%-@W+$)ekS{FaPuX%Qu&={`lr{DlR{~ef!6E?_Yjvzdf0F&8E5BHFBBZTnlQ`7;R +rR*m{mYMx)2Z|J^qG3?LsJA{1`Cu`b#r*z7nFBPxpPQ^hx~2Pc1^L^cOhZ!vgJ +*NC{I5!mIA`2hq?NC41hz1%dD!aV#$Ptcnxdno<(N#^78e|rm35glau)zCJQ*pg}54jhosxRsH?82%k +fl9l>7wY;^6>QZ=jL$v +|6WW8S~9|q?Pw}SzvX`>Q}u!VE^9y>_9FK2^z5J0XZXLTzmI)BKR48D^~!$Y>TV;Ourk*pIo81RV-+6AcCsx% +NO}L6RIg+7gNeBlN+4mb@iYxer@{bC(DK|u4;Qw2hRUPWUunw0SA+z={FL(?f89KS#R0YU40V@@_Nqv +J_4LlPJ4RR_L2Z2fne%#?30VB7Ii)@EFTvGrtK-^R}ZK}IC$UU*!b1JJ;bJRoN8G2UVk~9U44U~B3vT +QHD%gRLgYxXUw87PL@3zQkemzSV<&OzsssF^Jf+|D2nXaM<-s?JOc2!-S~y|gA6hV%k(0prZv$ +w~VeJ_icRwjQ8!@mx5Xf#BH$c}%h{6(3qQS5yV3pFkQ&?rd$4Ft7K`QkppuPsO6BvgXjg0Y$AsSna;- +P>1!w5z=`k`x(=-W-SDT9i%4gr$_5q0s7Z_uvbVEN&@7LTne6meFNZtY)(n%D;gLxQ2cG(HK5}fIxVXcLgc8dq5WA-dH!eRvuETs$RUg1l?FRKy2lKW>PjExj +QrJda>Sg(0LrN6n}z+0Nu2}EC2_2+4m1fwmNhhL_XmG9|#kf_*~Tr_Rq~eVLldsOm*Cq;P#yr+ZHfg_#%Q7;M1bQ@16QR&MZ}^N;9kzs+efl+Ur6Bdfczf7wL2afg%XS;Zv+V(kQ{QRMb0;F*P~(yMi6};wx(o~=!t}9-)m(^bg +|_S{p3-YxK!-=R*uV(84gPS8)MjKrI1y&Ajd?96aM4#cW-b8fVb?*3 +`7z%3?#0!0}B=)rvQ{eQvjsrHcHh}-ih;I&NZ_!ezbJC0dh>dg*o-tbA%(1b(O+Wt`sPD#m#NHl-|Z%;2TU*pobl^{=8TWCw +Mt@C|}l3u(8#?LYwk$nVK_Ax6;oZmEFC`1tc7Qnb}dQ~sW^MFp3+UL*BBlzEUum1B7FVCUZ(7kq3@5> +zN4Fub|tQR1P$$^1iNn&#epri8OKmq;@!VZXCVGIR+i>eZ0Luo~f(sn18#R`cLJ~=Rgb@wqV_cGR5P$ +larE)Lb5u7MU{F1NCsllvtpQkk;Ef9((!sZ>YSx=U4>!xBA3)T?iMhVq(vtiU1%25XpQiS~X1iJ?w%T +L5QR#7Jt?u=^89CKK!*7mBN;qfm8@`cOUn%>FAXC_$EYTJp9^^86d<4qdTiBdIqb+PXnMt6Ct5d76TT +wS%F}K|5nL-&NVo^CkQ9^$II0pcisGwIh*a&2j_URXZUpjgYN?Ji=$T*$k8n=&5LswvaSikr0F_R+vP +arSGmHrL+lGMcC~-B1-&9+z+4E(5H&^QB-}n)A+%)y4v2Wgt#J9kuHV*c-#=dU)T;{F63)*W-lUL?~z +pTIU#p3I5)NRnp5 +hqv0m@^8b4CsP>!`2z>3lpn@iFAKf}ZUjOgJLHLR^5T4g-YXa~x4_CGEx!hp4)sF;TRzT4K~;mF<^Ry +olAb1`DE~?CEZ>7EU3^^exUIR7Lu|r#t%BQ?_pMPcQ~qQ8UtO8`N76u$k5$mTEi(O}>kdr2-Al_CRX4Axp +Cxc~Gj+GU89Pg~)gNirqhZ%U@E9d&H-9pFx2tIU9ww)@aisCP3^L38Rx>yfHTd*Mwkk$qYqxQIRZr>QWLrQDdU_4zDSz!@5PS +Bi``E>pE7IcJk$3k<`iv)`rKqo$Pr^YKmHNm@#`87b>+!JuIRT)WCG?Jr;O3?-2^R;pH@nIIkB+Vd*&B*KcgAO1G^0K0hHYWpbbH?U +P7Sr>!PVB>b^Ng+luz%O7RY`^fLNJrwa>g+0p1^l@K1f34BGD~iUD7P<8?2)!eb6W$np;!|=@mZ9GbV +E*1&)zYiIV5&vdvCNkM|?QC^Z7`8D}mE&(K(488pIyT1L9tp-i-#kscJd!MS@{p746nQxW`@x9n@F=7 +Dl*#xB=?e?Ne>WTl?nx0$ZQ>ZWO)Gjw?zzQCg`6Vtu7;6`RMR!v|-7S?{Y(aaoMuL3t`re^$W?ov}<6 +C}1&_1!YP8rFOsEV1?1T=$`OKH?mdg5p8^@B6&r2CW%~_!eRxIca8qK&Vq&wl^JjqC9!&JnUP#5#DOx +OHWY`GjGv6B;&gH~J9YhM_Ot@i)EF0w+8x0TULNeq{Sm>WazD*?SOrFR!00T-6R$lXKCFy?7GyT}}dzw?X6=7cNY+j?jh-UP8gqZzE15^w +1X7Ub@Cc*BoN1){heOJC;Uy&7;mGVpQZfR+3V{{u*iAx9n<;fZA6DRpfmsFsae(tc~yJEWJyES{UVpK +5-rHl@`0XdW>^fHT=`-=vTnwu#Ri*#%d@OFO;I;Nyh0!)XiXhQ%P@SeS(9FJjw<@$|z1o`)>97QCK5< +Q_1eOZf<;3sn_u_y96_gF$*t#6(2prWAkf%+7LN5O8kJDBy9u^kgDZLUevVnMAAhllnAmv_?1EpjG&I +dV(&IEv*isFy;{GD^_k$PHY8c3v7QuQW!_IPppe|s=4G(9t#8L5`IXe#A21gU18`6rwSx$&yrMl+BFn +LGXiG00ni&FPcXS6hRNm-$>ZJp@P{djqTGDu2Lju@X#(A0v;e&vXURyc%PLv_rCFn#(sY?+^0@Sj$I7~j +AfV^!H%m|wa*P(z{s0STJw^aKtXZBnjB5E8qF^nS1FP#RZ`m(7A#OFp{BCxjl{w&Q?lR5?CW|eJmv)$ +y?1pl>IiC{ov;;swQ(qnCcvAFOF-jv}iyqeJ9R{x}njAedk?Gia|I~%p{qPU*(zON)d>|^NrAkVO6MXa +SZV9*_6`S6`sN(#+%-5>7=9xsgEalgX2X+p5K57>8v*oyI{6J%BlrNI4cblNw(olKxX{hahMF$M}pY_ +RFVFkq^nxA`7@1wh26PmYmo{=mU+Z?N+2uqy_rN&?P;=s7*Yl1(! +Y}~9$X&7BZ`n!F6^jD&{{s_^9WyrVl}X-=@IXlnMWekVu6nP$I^u;bX9rt|Ihf?|^tK;JRORv>T4G3C7C5f4!lxM1WEnLV}EZ!yP6a;>*wt(jr;0F2t%1%j93AdX9jB +Vbj!`4`w$=q4jX3}X`JwLio!Kj05&Ox{bhBhRis1*n~XIf#P-ma(Db`Nb;TA<*Md*C?q=S5FA8>*;2O +e~JJvzo!b?rA5w>1%K1DPRVbZqLw_8qmBS4H7D*9%dcf*hA_G?m2vY8||bc^;Ij)m(46O_c~)m{LTP~639^|b!rDi+;Njzui!XpJ +E^sBwTgF&>t`OzEh(TP-TKBtHha&iO0{~eR89L!>FBVz7Asd^S +7uQT5jDQq|q)|8@dS3Pz~@pjz2`w(dVJmncX +ak0-`D0Kz^_BC9h4K<1s4- +O+2jF=cRed2MXCk}*T6g2S+7b}iaSuOBpD)zf|ljWY(^1eB+nuDeSsf_GVLq-L)dQ`l;17ovm8B}jXy +zAQ{+e9@2MZ^NM7@76zoOVd4W{@1|5O-eyH=Wz)NpIBqj-2)G9X?T`!?on}3W^zdFuK%J1;EaCbU4w3DhbJ{cDuVL?aC$?-Vr>u5{ON(VG5*X^!CSrsSL{Sh$h9QRJECsWBnV}mdL{1Nm&K_{ +&Ij&}O7vvOM0Ug}$ND*b9!&Gl)kq5GsF<`-I^Hj}O)K?7UZ`MGp96!*Rj#C3Y4l?{VO9MaO|$57Vxn^cVsyIJv`dW#L!3uhIW8&D+-22XH +DKf3*^Sm=tL6sIhll82Uqxhsgc9NbV<%5hnBBHerq2LJfb7Sk7mS*Yb8H^$bt>u{Mf}1BZvXR*XYv2^j!J7lTrhh=zpH_K4aPU&9 +>LHApikdLG6?B2EGQLwa-OHeU3;{ckTYoWssy3P5B&-qgLSl3P`j;}ge(F|3bCj~Qr`D8mpBP!?~$=+ +9BgLW&Bz6Q{9QScO~CB(eDFB5Z3CS*&(%-Tg`BP**A#pQ4Sei-Fa<^572!4-csQG$}8=DAWF+u1J8y> +1aLfpzU|FuAUL9u%F=qA!z-gvKG{UdnXubSnLx5q<_sAqrDy)V&bF2ny5HWc;I017bms*5M%N%cue*f +d06ojRUc|>LL-S5)e#*8qrZHLRr%_{6qW39`G<@S^%pQf;tjLW52|rX_n_n#2<}6@; +_d9K2EE!*9-QE#=b%#rhJZNZM?D=+AtO7g*AULyd1N*;ZY!27TcoAh{xr7(%3uMM*9Gu +T5M&G59DT^+OFndx={N;!)YRP>{y&0`l%l}olU7GpjY!%ldUOzNK}Skzug=UL5aarSZdhu>T0oT)I6b +whoYsUJ@1~vG2otspC*82OSc!gOr`ApjWqoEVSFSYa +UmU3yvTD2M@7cXGf>|_yOT&Q*D({aIE##So9!`!>Ie +sasRgLi_eo43BU1!SN^`Ao8*AS<66`h|m35W)#3qHgaFp;581B|yZ2jQV#c^ocC@6@$w3A>|+BbNO0! +99qsbk(hWe`3gN>#-m0Tp44~i-&f2#F?2!JxNm-q}~rjosv*|(dX9uEQ0{Ul3GR$eupPEIvh@KPpW?4 +NHP5B!Nb{R%euWgeKyj3osPlU;~1yE6uZbF@Z+?g;o`xLPojq7n2R2qz@K~ij0O@vZ-ze=;T}8EHgM+ +m=vWJeqfeJ9NRd$wj9iUA+AYij;1*_(IXL%S8;GW$+KW?x+Y1Q>A0Ct91aE|}`ev?jxls(=RPAG+%G&OwIQ +!>k&z$r*BX-~4$6vk}TqO@hU(Z*}hQX(@6(4PCo)wlnCB~pq%c8l8l23Z!`d*K_n8-Kz(2kR?ng!7uhbq4P +6v;IGcxpuFcF+nA8h;}h;$>Gyzm%z9GEbHsf%%O)`JjA=sK +re?B22D<{9nd^?*oaR{ovoCtpKXOoeGMEH&=zPsVQq-+? +u{erWx2JWzal&l(aTZ{|DBmWX7UZujwM4q)A{DsG$G2l^L*f=wX2cL-1Y-FYnzgX +Fabls4pxQMvI$k4tpqs+DJ9G|$#F|7GgkY4y5rVVS^<5Wq(ZpIAPoQmj!4w3X`nqk&jvSHL$SD>k$)45qn6_9Pw@ +3mYPDLa6Neb0L>=Qq^pH1=veaQm9iA4SDIXa1T +#Sv}($Hh4-9~2=rr`}y77NW4K!xdimAZ$ixAjde6C3g)s5tA~=?+KZQA%Xs +eu$vh;HvvDsZpVtOFniH^&C`V`e@K|k<(v{pI)93A!OIUVvGor{rSEs-?0RP+U2R +yI@F(y_)iyPLs+xcH}M&mwjHdNdpYhLe)^fU5c&lilvyBwSG#MQcR1mMtP%)E^}$g2HSYtxlSO6GbPm +ajrd3g3d{E&^IkZl!PrCw{$}S!A`~ZCWUlnyrWqF%MJ*t>52s;Gx4D+G4;r;c2#EL`Bpi1lNpoQs-st!{z6}oMyU?Hh+0=J(u6 +5mBI+C4Y<)~yM%ZuPU91-2eS*!RzUaB}dt!agpqtJ`1ZL$S{URY;8pDwOB0gf{*ovg(jT +dtb8>Yx0Z5wq{_~=<}sK2Ot*3gflTx?LzOJ%CS>MPu$z<9?P4L=J4`0VEJo<9stU$|yFLCKw@zfICPHa~o%EZhBT#fuFgkys +U^lw|mc7qH+Gb7@&*SPkTs?+IoyCYeiE<1L@x}qAFr)fo$0Jb%qxkC=k8kO3PO2&9w0B-`&B+(srZ_~)tXkrKXn1a{#G;-^7@Bp7MF +{D*Lx;|qC?_brPBPT#lA1CJ$zlwjPEIc|KjyDO`(#gJ$?5|pGFyr_z&N6>Ybg2o}8Xh+O@I77x6BHd# +q}k_&M+%Q@k43d@s;OyZN)10G_aKKZ_D9bNkt&Lj`w<2(Ui~+32>ruvo=l&f|Les!|CT;_JpbvZzs#= +n-cawRvj)RnUpK=MOvN9v8_H&iX;oAtTT4EF<>AF_TA=Nc$~h&(i!uDK-PE@&Zjf84Q9HGlGUaqYe3L +~ZZ<|7=GJAaXOe7rOhy)J0ax=rsNOs)oW8^TLGzk5x*|I~WBekiVc +FdJ)U4L?6_4!_hu0DU)SBdh8F-Ck7bhY1}=*~?2`VZkcU1RD${07wVSFd@8`D@HdYb>{1VzWiSx=Ia5(Jn*9 +5e)WcmgLa7fK<2)t3oqt_>TTbQuCM&~eZoEz%Et4THn_#VBWK%$oDtNPU)yc0;@o|1@F8ouf+IVUFkvxO*PU3~z{_u#{9E`?xu$p1sIr-NWAg~mgGDshl9G +P~dQ+jhhNS8+%p%@?&Wqkw{m{de%Lo|`zKG0uh)(z5=UwmJ2g#pLCYL65bt#{h-=`r>5(QmGy$VGDX- +@m#3?PNTRNehlYdN;t%Yf;C}goBL7&sf8EET%{x#;G18=!(X}ecx#9viA==3ihdT4Wfe2#d{Fp6JKYfilt?5-D`onKO3D}eUvR)yb-b+sZ@)+&m#0qQW?cxm<&+SF2;f^;L(YlW^ +rLcEZ{TdFquc=E5gLg)>}S-f8t(H&arSgT_c6@wbr*Wo!!xK>bMo9o`GlSi|JK5A54_l-J+_3-WobXs +H&lB$*P*3oZxlcT#y_D(AC3~Z=vE0irqcItg_sJsk(T(Iw&u#E>D| +!xCoJG$njxysc-s(V(utHSSM3dp6~4*2^Ut#vfSq~(RX`g&02P@R$s|mj2OoPdZ|v;?iD*v)Q~x^zjX +sOYwL8Qn{^J1n;3eH$LbV3JCkgQkgv|+A06wd%7`c8)Nw|R&+&{o^v2vYaO;xyBk`lk4NaH2$*FzGb6 +&K|tjT%%kyj*8CAB4%=pcd>)MfV1fL +S9UA{=~H{8-$zJVI1#3*g@tP@oS35vsqQo0OpJ +jy3y@K(YuJPi-(bw^3}tsmGWXN-cmj;N`yvWl@#GstnN%mVeIy$#HY`KTZ!8acx?%spu9Lk1YGKbvbM +0}mnh}*>5rZ|by8UtbuY3V-I9zqFJ>(#7vv)me0t>Mp)^$`{;?f*L%ShDGT+&q$ih5C7(JKO<>DnI3@61Bz{{v7<0|XQR000O82?eW2b +W!e_lL!C+02TlM5C8xGaA|NaY;R*>bZKvHb1rasy;$Fm+qe;a*I&VUA0($leQ@87fa;KaQMwP!=WaV$y=3kD^+FKVJEa;2f<3E?%76ZmaA$ +bw`Rsip;-9Q5aXIe@bg^ckQc@R&>ut6M4 +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 +MKvcn3;5{7cBj+*l3FV}Kar( +d4KmBjyGv4FEiI6y>qWkla+L!%mk`7_E637Ise%T?@MfBLsAsy2Fai93p$MIbe}}j&j^)C6OBb&4tr^p&$ll*20?-&I2kNuLulvR4&6Lx*06hoz-=BpyP22$apUJ` +PV=eHDNl^a8_N}$G}X1bQ13uWvq_kI?FNq8f1i3^+f-{65xfk1Y)7ODBRz +{V`%kKMPNo;tk3Erlsz$oiC$z!e6{uox#qO`D2z7l9z +6yn`2aLI=XC!2oA6w1s$#7EM_QjM5lFfu#ra7|WP=&m*YrEozu_B(xjBr~=;RwK#@w2@5Q+NX0G|7P+ +`O7)8x*K|hpXqd_^ZwjIyeGKwfqnl$UVAMr*Mu2gsfC7SsRuoq(HIaU%9YB;fw1pjSfFpgx6uJFAf=SkF{PNlNC(PU5qk825GDnABM +d;*lLI?w2Mdziab2v^%_rbn}(g;Ve;S^A+J{|PGRe!)W-rn?wX-bdAh1DQ1jIfJ`Nspu}YJG@B)<*%} +bvwaxmK@JlJk@KOs_u|)_3Ggb3RS(x<3pE(K~yX>QHTsJD87=8!UdKvx+b2sGkwwrQ&Zu6<+N +1J5(c^(>vud0l(Wi8hxWWN#D9xeNNPf$z=PD3)MP?ah|ySTuKeIa-%l8F)3Abg6THY>p6}+p{@U_?5M +`0MAa|=ZL~G3Ix5BQ98}Kzr&;IEKyh}Ay@<5UI*x7PD%~}&ruYnThMLRQ&cOlhF>L-#XAiJG@*fZ+FJ +%dYx`Gt)Yz!C_LoiQo(I2yrS3jQj)2>dmF7w)9Z8~FOl*;s|+J%%GOJSr;=>yaV;3ELe#_+n-YIq>pz +M-`SPu1DT$wOZZw8K?Jd(xYBeLUOm2trAtMaH6|z>iS!rqT(jZdn>uIta&IRYSmxOP;N|ET5K`Vdr+X +kADUR+tMau5Q&_6(J +hy0mD0ylR$9`5EPhr-AYud;$f9-*b5N_LVp{VDUtTPpbCF!lJ%C)2`2k@5pt?ZLq371o +#}q>`uYFG^PhUD}7$?%5+0aCnK2I=ITwj&G_DVxRhCEr&c&g9!+V+b%=zDc_&OACcY(M>>9u{s7h7JX +s7T>gQfJ%}MYe(`qlQAf6wpHE69Yy$O@=|QZJ;g`h8oXpYga8Tg*~G^u#t60Ri^IF+iDbXs* +r&tUtot2xrAcb7t|R*itCe=L~BmQqC?tp0Y)wI{gyDC#kdlRn_t2pD4tv?1FK)zG-E>=c^UO^WG++iU +g(^QJGiO%w!9lLF&04h4@bx_31Ex0@qfW<9|E1;|!1MYK0#<%Si8B8=YiJ5VuWakl*0>PP-wNX@152O +&`!7Ug)^=X~{QR#Uo;xuN4fD$)x=mQd)U3$W{HpfKY|Dt#LYur4QujJ8sLaaH5@4sl4I;P)h>@6aWAK +2mlEMt4Q0dclLb%005i-000vJ003}la4&OoVRUtKUt@1%WpgfYc|D6k3c@fDMfY=x96?*RE@~4oSZ5^ +3#O0vSf?$cn4S|0*q3bH+uxjEWsydIC7=&WByvXj{(>mnf8BoToCg +NGK6b~p6uR>b8HbncXf4jb#+yBbzw9b9bLDZO<6U;qO5|fsGBs;vtk*f#XOj{Sw6>`W~GAB +cBNE43Z_|+R=Z%6&hFBs!fNZZiH}AAYEhNzAW0T&(^e`;f^1EY(`jAiZKD#tKRS}^VC-uG +lsj8|d_06I!W=&bs6jj^=)1*mw>0fEo=KL-lWY+-&RWhs#k53nRcPqjrqMZwHvkGEC54&zfG +$GnyHVtr_;1n-+!lj*;aX-E>tr8{ySC7%DLh~YMo{|AW_w-(nUYgoj}=Sn>e{w#k{QII(yJ%uA2&)y8 +K|M1?6l7BwgC*vg%<2!_^{8Q(|6ji@Z$dx(t#bY03o9oCIxw!-v0Z1@$mfn1G(2|gD!vy$RBk%gU?v(y7U&*}yTpib)9N@4Afmmia#F5X^zx=7x=y86$H +tK`k)+l%+F-dz|lrYmam-HYEgDGY>Zfq?ml&j9PBZl{~7oT<8=1PTxYzMN;!g;}~mCY>jBGcVg_5>)G +^Qh-OL(s?plwZ&aMIf^P=P@F4eh|u%j-T6?0WI2|VoBE +P&*U9xCFW>bU1?dO;AJ?-g+cc)**L3?Xy>oGrI)-|8%cN3u*;YW6K4hNPfW1%BSz44u1|-dXQ3;OsAo +IrS@?P0GWwFSX8)5b%k{^kBXoB-#9B)_o_~__Q7gyJpAKoXgKfL?+>eJ$Fz&(u$Qf`K8#Yd*Z8PFnKTVQ9wC%~#I%LY+HAuBaH75pa8f>%U +$WUGhx4ZVBKuLACQ(3C-*t+U1e!ixlV2E#K|$ehUN0S)3n!$=GL8z~Sc25A@$D{n&cI0~|b?O6asR>A +wSP#rLSR{)m(zzzU`nkPg^83b6D?k0NSq49U9U%^zW3cs4y{Z2PDxFNJUjR1=gOK@E%nqbwi1In@+{k +>v*QV?|&3`q$~CX7lAKZMwG&5Kz1Eo{=_s(V9wAcke*`P)#FbTFQ01amv%DgPbBs!XO4Gh~T2jn(uR=PDQ540&t=JU45{?@8 +ExQLf=5T9xVWt}Zo4P!Ace}Ods=bE1M*qmb3r?DyzflD>2SpHf3ERJI=0NnyCu^3m7v6Lx~qcNVtN;3 +!I1<++sN@ySe`xst07Bq4g95L46zOMdA +rJHM9>;Yo9CL!#A&hNX8LiAgT74*k}XRGiyjHO0@WUTgzO4EfW8QnJGGnG@zgc{+SZN0tJI0`c<}k^E +%bzW9>&2Lu@a{MJX33$Cs=qHm +rLr(6R00)Dn*9L~2RMvm88vBMyKk%CDEVeMZHR2y!eh75QdUTo7FB|k;Q6Q7V@+bShbq9Ro3iKfNWqYYfw@EZq93;eUFSO;INB``V92!07 +W4WCsGXf{ahOXG50(a>cy;mhfx%-)WbYms=5iI0WL1!g&@b$4|`viFy!!ZQ{fyXE+lIRo{=DE$_;e$U +getaoVr^Q*IO-es +qh%6krJJU@x8zJ@1t$t~+jxfzC*Wwk009a81)U4}#dyjhD^?YAsIv^RiCB*&!8e*`k9uo)Zv{TM_MYi +ki8IZL!S1q;kld0a`3@DLxtG<9(^rAR($J}FkQc{@~WK$#JWE=j)(*Q}6;|1=5A-KU=}uN;+ds0Z{c&1$6wV4lrkC8NoAtLf{Yi_JO6Ti{;zOcwzR*&(5=6 +PZ%d!y)V02ZiI76|Hi+k3&R2V`F89oxUCiKayHPI-(J&z>GUs>59{|nypS%GdtXrC5c|ZLa*6`I4%L* +EQ}@&X`;xVsT#^b?$<5V062_96vBFQ?u!^?l8ICP=GpDd^V`T3Np>#0MUHg59rQcWqNq|RJn0!I!}+p +2AM`S%wD}M#1Cy&_2{4J!gfA(2W<4s@Hbl^owgpzsVr5>^8-@h0pV2 +KXw&+<91jp5M9N|=0STbUxgekJ7yaKDZ-5`D4cf5Y0#m;0p9|uqSt} +bh7tzxs{;%yYq6%`nx1F3jEj^e6rs%#UE&&S>XhFs4OSH_FnEGTGZ^?k@{(;DE}b)nwXu_)@mnu`*6B +lvyrt?hb&lk)HXH1{C$ushQ+OC7LNyJg2PM!=f-f>tDjg+5Y@g +=u=;~uGgSc(@>~;8&$O&Ys4k8NIg-2NbJ!xQ&@ENqQpWFNWKet(`m82m!c>UC90P>`TMMUSO_6bjgB` +-ZN%LBHFV%yRRTNnQ?q+S1g2k!zL3|3j40aNJf<-~kVcv#Ow6_${*>1)P#x7q10JRIEXzhdkc +wxGLj{KdJ)&+85LG~IK~Gnq!7bWf2Aj68E6X{+kFe^}EJ-Tt9ca|zgAXgY=GX8`FBNS*y`927{%|=fSdMzl1aqdmzb$xG3{EnG<*iBSn)k9Gw8hqhw@sE;2%~1xI=5V2T&$PcxL +~E~R677$N{mHs}JEMa`Rj#mlN}K`sYpZ|Dq@pc>$Z*??X!|m*PvWi +Z>2wpNDXs+g9*j1sCG&)>PH(TM>9`l@{W$B +A3`Y-335){DWA6OeT#mxO{&}qP>pEX_Gd%0o9P{i|90^HrtWzuu!IRy;BGCAfs(NXciQt4X!K5tJR!r +cDA<3s531IEN-u(eHcQK?y<4tf|Vl$Hqe_>&e*$r*+iV_~DuS)kRTAFE$}Ir`suuy3Cc;AC_%qZ_wYpLH|usgJB +PV!m(74^}cr3FAHm)&6*Hul2>b>PP7j?v_1rxk?KJ8%6f{Hv^T+eGMRAKhArWQ*g*twrT%86@dRBl)j +7?Kq+k6B6S5b9g>)cCyBt<9r4kl{WmOat5fNGNR#bDh2uyy|m(Ab|AHn)%YZ3k~2U{555-}> +~1pp`vKfX>Ja2p*Wt1eTbjtQZCTGc3NZk{jA(%%GrG4)mwH +C=Agcy<6qBA1R-j!1t9kz?Q-QtErfbQ^!PU{jhfvC{T}%)GQhO=+JZxUf9E`(x0Q~15ZD^9k?%4|ovw +*gu&CBbTS0D%H^yc6nx_T1*@S#BR|F;5-WY@AUzW1aOx><({xZ|6qqBE5xI@-2>Gyyxp(7jO8EPVlzU +_J#sJt!;h&HM+MaS8)C@VGYs{=O9L#vy}h9NT_`Tu5GIQ*^6R71Zl7~Ta2Lvq?KL+$}8^01J#=;gIrh +R{4lMXbWQnt>?kukxCWVOqK6jMC?@IVex3V+VFc?4Fnb$GSLD6XI9?1r}ho$h0u?cZT1kn*bd!*}c-{ +88$`k_RNW}@LtC5HY})t;u~@M$t@dkVl7#Ax +5r&DFedroFPh(z>KPtZOF5zNa0vy?0T@v?Cn1U|ya1B|p5*EUX^aaPRocCN-mIJ@JH?`iR>+bN2^iA* +onU^c5LJ)hE^nlROOP2OU4m7*Bw|K$&xd=iBt-Ur!? +ct;?S2W8HR60hroFWK7SBf`PiP0DAH>OQ1Pzyk{z#XKQ2O8G)l;ZT&&o%o3d +XeZm|E*LPq}wFPbrqFRE*hPa!h6R|S?3>SOoyq6zEV)rH&1s3(Ysio;7lX0Ntu@loV!>A-&TYy>f^p_ +p=AYC-FH%kq0hsd0qg7!85k1Smj#CBI-D@SOh?y$l7-<{uREsxQ<9RSuAX32^Blh&8_G@t`8{;YU+xZ +Lch3n8*Ks-6>iHbD~`f4qo9c_MdFfUt+UC-EmKN0cBSq=vU^i@VvOX>UP_uima6CO%YS=Zix7rFMweZ +4bd}wa$k{&0@^Y8gy+kz_m+aE1TJC3M$eeU|4($&<8*n2-xtfX;CqzmZ?^MBsfYo)?Mx^J%a=DS_35Z +GHvR$njy^E3OA>qeF{^+c%lJNvaHus5tw+*Ys(sc4WQfbM8R-gjqBh#1!i^HG!?9^lVH>$u+g5G4AAJ +kqdkER3N0jr4J?FoLkLBK)VN&GHaoElbksVgCme`Ht{E)h7(P^b0G8v`9_kh#o6a!S)eaOq`Z@XVpL= +@ubp*o;Y@T&i*A&Bh9_(MAgdDgR@(&8Dj<1u;pFdw;xwn6~`sm(%`1Hp?c*B?gy0aBD;Eafa+`tf6i> +$wCAcP|wbdM-^Oiw7}D7f8{Ut=N&H0@vNXvU(KPXac8sEjPxcDWussfX5Sb|o&#wwRBCC#WN6JM#}p! +d%n>EaEx)D+BgdvS(qF3@tLcNzgc9Np`)Qar4lKo#%R!06^C*E1(@~0CX{1AQ5vq?0995gNu(7_=np# +G~q8kp2!{th!zx?+RnG8wJV5^-A+Pf434w0)~-cEdX!ec91Rc}?kc6^q?auw{Bv`5^4+a9Qu6)KEbd< +c^ZB!{0y+X{X;*4JhGMM_mv;zwN!GkWwG&HfA%54q4lTIM_!-&J?K}rY5q2Qs3|0nI6yb$Y_MN?MoN| +Dh7f38>SobN~Hmr2|n>2fNNTlJeraai9@QOB!a5G$7s{{Kf0>Ezj4iu3N49*3wxN*=O?uE8*`-Z4>5O +RrIuf(uvF1}bc%;|q)ne!hRKU}%68_ubNknMQwlhkCob)pwZ`zdq7MKqmJTR`7`ql#EcUMm%>nr2gcU2vaPS&Dl%#6p79)P!L3R#2L+ +OB1?UD6L~r7~iy43q89f;u=?4c;??o5GrZ$a?S4W1M +zyUl792yoW=Rc(4i3TNuXOZU}@UyJw)7jeLr*L|P#*V%dlQf$Ye$LvI93+jQj!F1M`tub67QjAKMg7p +?w(sHZgOOfm#$U#j8xdN-~><%}+X8$7g-<|$T)xJG7YgvNJ9iy5wU|obXn;nUQVQcs(uG(3^3I{hB^+8#LG%Lly>xChMJq5&(7(@+KoDM+3O_91GD +plDOlx3*2YS#I&EC45(0TDg2R0e>cu7R +MCj$o@clwy(laMP~5dT~a796&<;EkclKw}Yl76ceY+oR7E-=g}7hidqo*T~>8^0aE0eE{=C{C_ae`=gEGIu76&wfAlOt +$i23-6T-WEDnC8lM4zc;K4(&Jq4ziQ!tCbvO8zS#D<>oIYPEgsZ*tnmXR2l+S#>2O;Vtx!8ayi^c(l+ +i{MD{HZD#SRyGB&RS7d5X>JHsp-Dm#o^5_!v&i=W_RmcDegSsQkf4H}pn?EXO*OE(ovobVb}``d3G7F +qReeQ{KPEv5F@y3}p`=p&hJ;OAv@+Z(`_!WzMuh9-#?C-GHpvkUr=t(#9a6{DenASb6(Nd-i(m9=`;}an`Sym~>hs%AP8U8os_sa$F0|`uFPk$ZtI&Y4Pl(=U`%-4xnh1C@!rIDS_arei8?G-db1 +d2g}TR5!v;QzElyAD-$Z%TW({~r!uHUSkW3Vs8ck{yK6UgVyjk@ch=qrT~ck>8v>znwTJNB=U;U(?pN +d)oSnc63qTFqJE@X8FXVm7h2WJ&&Je4uu1B!vf~cb=qLyl8#%g5dZMYJp2-WnMW^K#k-5FC&AQake%2 ++m20RscM{3uFzX)vbc=Kub)fqH7RS1vg>?s7`ycazH(L*K#z5=;`z+%1dXQH8OcBz;HXZ+2U*LwyRAxKut6F5htHS3HX&93Y1LM*&}@c5A5uLEeh8kO?WcRkr>X}bS}T{=D^H_t!4w=VWF91DAk}7gw_HfL3wxYwO0i>~Vb?>+C~v4r7>Sw1we_81K1+EKgBLeklaH?3?0dbPvAy*@+F*VGPcD +pM*}RB~DZw$mA+eyajcb8XREelJV61Wc0pP +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 +@I_1$TGnQ;@*FWRnCm!cG{ +;QGmB#Tbo=NE-Wp-*F7plg3M7rHN5VGKh9Q1RCow4C&KW_J33X4sa#teusV0f977%_q2dz9~*4wNxA_ +?;BTJV|FVh9bh`%6rhlcmt(iNh{)?*D;>v5A3a`xAxk$Yx9H{Tn+OBIx?5*wXEeFmRm_<2wASGHu-EUw|5}}{BGK#hSnVfBVsHpVI-TZJ7~7j= +Q&=6!6ZYioyC47j^854u{d)YjKb^mS{nHOGU%ghOKA8J>&GW(VDX`6gKMsb+2ZsZ*R~6+B`v-@^R_9| +#y-EBh#z@ozO@*x?8TYIS%n67AY*@c?Z$)`Fm^ma=UX3!@-}ceJMjfS1wml^`&zNDb7d0|5l#u2j#S@ +fR1iKvc%56p5zCLjtEn_B9fiw-n`3%uDsF5=Yv@R&QDd)Rc)pn38(VCQn$!l4`@@Y(b?sP6fwMKrekzLEr(=d5or=Qa7 +n!-YYs^W1)i!spTjgDba9D@%(Pi|mc(JlvWGjt)b=%Yx)57M!q?|DvwuJ__p6*bmp(Jn4(HC}?E(v`f +LcGLI-mPEzhzQGK5J{?*bH77B5WZHqopd>MBEFRp|fl9V5=EP6g0F$j!wRD?`Ab1(zkfKla^84?{@7} +)t$u=hwP^eGZeOfG{RiPHjxZvSG*dc`6;qS9-g~78tTVGt7C!RPT1ugVW!BL&A^BlK@(2>a^n||_EZA +0Y-wx(L^+^A)o5U+++B}YeY(Zi~uxIQ{%k1ESPAM&!QaIMHbKwofdhxo@K{&DmXwBgJcfXW5^NUg;9p +?ucjGFvZZ81%!aWVBe|z9ZpzC%;1{9?FQXuad}U#OJa7;bUjh*5swRHIY2F3g;-KRa7m}4XeY}X@$FL +4E3fknK3gfhX2oFD?}F0oRw&gy&+q=jA4Vz_utu={(%Mp{XS-VTeP{&!@BcFlhpctt%uxA#Z@Il;__HVfX_TPUG3?H%Y_fL|qZi6c+Nl__EM+;Qu{}3M^$)V@N8YGzX$hA=C_th^yF3o9LemrxRDm@pv6Q<+pl+v~8yi)m7K()D0mD!b6*EkyG)ibrVd@lR~QQC*Bvll1=Az0lj2gi(d;q;Jxf+n(@1B5^MVT$Yd|a4(QOYGEc_ +=P^ZpPHt9rD$aipM3H_waK)l?qPmLb2 +upEo0&DZ^Vf-<4^WuM?z~a2T<@y!lHeQPiOOE2g!!U-@Mk*o-PW}eB?lS5$im1l&D +*2b{q&3TL_S&ez6Y8EjhLz8&}lZxowxin8PsjVuWa-3>lfjF2vcSE3>k)LXpybOmQ<_t(;%PEZ;u&V?wQ*_UwY$b`k;;o~K- +j!5F};kZpD4pZN|fK7h&;;yHb(7C-3wrx+ucVq)`s6YazCuk#8I^=P2!PS{&p=2Rfp_I89J#GIZ3)g5 +}i(nyE9QJ9D6ia#cPU>A3vIXets=yy?&eOeBA!JBgfN6!h_dE0}AUC{0_}_yuIm|wi7Dt!E%VTJgF4; +yC$uWNPWi@Q_0#`8-xf1nhM&@a3xar&vGR)flODcQ0GpAY)ERW!8Jwdt2Q?nv~VtC=-o_@Jx`J47q+= +xwWoMfTj`i%?&T`Pf0ws;fkcLaOv-EQr^v9;#?20B;74ybjg+XaPr9dxctH%JY-B9d*;43~p2zI(tZj +b*i-PWPoxFG{E-x-SE-(J~;G%7diU1g}n~t!0b<*P0eNo9&CQw_@0PqPgMNQD<_njMyZ-eZepbvh6`G +u=>$SY~g9l!w;k0Oj;geOJLol>om7E_Ouw$$EEG~hu!pLURC;D3uXw6f+WbJv#%=rcGtao3}cv~@&9G#`Ao``5x<>1_b6 +Cr|jA0s-Nix(k_bN_yCGyRGXp8*FZlKORk7tzRyi3uSb8B4P_EX-<7c{IQF*`+wGm}WmjImNyL +U0WpT?v%<@uhd$xBnn+IJx(!NVxV08_1xv=iX|x;y?LMCGH{5dmpIN!17O#SvAt`|O~BDQ7(DJjKHvc +q=}F} +?4!V7I`2=wJ0&dWJzs`O5AI|8hiw%iW-PXBXNzy!v(f<3X?Q?&rJ~h?~4)T!4v-MO0J^0a0f$ +%_eX%Ox2R?KfIe`rw9RT(COy!HJpsVGCuj)6Tyf%JD@9H8=Lz9Ht}{{r4a-DY(L67q<}V6_!}N)psn{zy)A~an!H=HCmbDXU6 +V>`lf8_J%SyWVWBW3`Y1{Qp0`5s%m{8lA-8^7;uVqm(c5VlTX;7t7$q +0ji)>E(U$X(2EBb_yILe5cMN{HUKu^2?Eky<3ak)xY^f$w`i1(2>klTWI){op14|4V6wq|5@~zInnA~ +tIKkG-+{MiC98mLPKOX=E!%bh9n6ucHe?BTex8t8nr&aR)dqC5Qn&aV0S`W)Zp+_{O|ib>~(YUkL?5D +yawwM@Hyvqk)(wk4|Z!md!`j^B4rS-DHB-(9*QIjsSeOnBqrD!N**G45wqCRBNK+-Y{BFkop{Ko_N2Uagj8z;D8rr8Fsf;jUJYf7LD^o_5H9t&` +_%u>T>zI^Nm3aR>XRN?@{$MskvcA51*l=KkHu*{bQT;$XX8q5;sSUH96QjlHCkaBDJvUDH;vsVym?+J +>q|aI2P-s#P_Wu9wC|EPwy`Rw0I{y(S&Xid(q+8ePZ?)!0I|U%lVJolX_6j>0+2?uoNJ%FsuTPg)n5b +>D{l^QFA;u5ixq6o{W+&kY}bk1exYAodj{_H+_oMpsF;hJ|LDXGLKFX)2+s&YBCeaT6d(XUSDEL)dHr +-V8uKgpx#|yV*41hf{(Y$>4suqP~PTO(kch=rv7_4ERxt^(?Ml+J20e*;Q +}_-r@pc1A!Td)?pdAA~P!tf2sEh5V#T^GWMt51TkFQSrDh}KUy>N#Ce;J2CRBs<`hGr2(rq +sl?x)b53+Pq+bUv#OJPVb#P)+afMw%b&(WS~P9pz@`Y4Y}nG)Ml(*rL8;7)aJF11&#w-wy`Y?TGeDQJJw5Na`it`DV$EkFtkNC=KJQ4G{Bogat})xW)Ht2aJwAQ?X*m^}1;(EJPn+~9 +}qsda)IrNHKK5Q;{Ab&v%Lt-6qp(pCzbc6pW?OZdJGbWn)! +}_>G6V#2!7@f(hI0`MXjN#W!34cF3H)yMVF9;)B2m@+xF-*6fE6X&8nkE>1?|m$zgzcHG_B;k+Ujfna +w>jDu$eA!+n-`{idR$S}P=g3XgSwyyZAfBWkG2}a4;fY2@bb`+G{(&H+**bSN<#qR|P*nb{%xI}_x$TQ2%=&OZM7(s6r1-KoPf +_ccXo+Oh@Z}eTk()d9_4tsQEsBi4rW7v2@2Je;B0;wdzIQH|2oFyHwMI@`B2X&8vfITHff{o`qN6c;6 +`UXc#oYr{cFmEg-Jz|bObR{OcbGR_Vtua$%;W=~A)x+RZxZ;syLUgHM(TPzmK@naUf6JhY3wa5<{Vjxu9DhMr5 ++Mv#{{cl)wQ^R-;n#~V8vjUSkXYy +s1^%kXL70PV;i=-pGt&rfm8eth$a4G*6kvSIHTF(KecnryutLHA4%1QUhBPJ>m?+oH=8&R=l?Zu>GSq +O#28No&gyTr2S83VhsJf!xY)@^1LR6q9O$QLw6%?yB6;v(t)!>}(Rw=7eD{%=wp;f=6Y>@Qo#*6Vi#I +KoYeO^NgzvXIm`{kpgYuEK?encSt2&mERQap%m}zffVn>zpvs2*AuxoG+$1LCc$=`zkdHltuFUpOR%q +)pq@&sQ)l?aDn<`@%Je1+MFwdzEEvB)MH)U;OO>&YUSKA^zS$^XJaS9yS1Vl@I=rwY}#`Q$b#-AP0|69tJHluS5kU{2)RS +;P$A`)O}@N8PiU+)Dq9nziOK%T|+U>fMT{mS@Cd9p@$IduX0dv#DSY?`lm|d)wJkQO&`E`0G)dho=nq +2mD@~;MtfDh+m2~B+!qMkPa9Ol0Xhd9l9UFgG3VZh={24CF^|{1Nnje;-DbWSL)cG9nlwi?DfxUn0E^ +S^gnzG%Buf|bNg^89=4scK}&2hHM}g49__<8s*yG3-6#d3mS4+A>A})5M3fryd@pDv=*L=ND2c?2B4vR$qmx6}b>Idjk7dm-_e+=P|qhtJ|4} +T2dyTNn#ZZL%J`X2>T4&>;m(NV$ld-awBu@%vyzsMN~jCY1dF^TuCz-HuF8_?e+T6IC`8lURI3^RIa9 +JB${H%1OV#p<3^M}W1-FT$uDbuj(mt0+~#`9Wktiz5OgjPeWHdQir2#M+{i4F3?wws3+3xUYcdObmvMs9{^QUi6azd-nSDs)lz~QX +H^x~vO8ah@mLxS9bTVY +2e=d%of)%Sm1N90Q_{ct`f#WM$@+bV$EaJ||N>xt-fRx*2Py*lGTOLA%&VbnS0@)ha>v?QJ=H0Poj`Z +SRW{klzI)*C^T>KT9jgAGoqr!)?p&hgy@AWk@?F9ofx%<$tsY;sQCn}r3^z7M+|I%^W+V;(utBTht!< +LydRQd-hqdfrmtQjF+LAbR07*KWO(cNckpeiE~IxhikXlt{G(3Te<2bM02ozM70T6nV)GMy-yTwZHpX +K=b2^)Vv+Mcar1n77wMg3faQ6Wu!w0(2c8j;U7^fqZOYGD}-<5>w8x;-SGguJv2=mUHGzL=N=ioX+t% +o(e;4XnlnBP=&$}vc*UV}K|^NXr7vMqPX*nHP^Z1KyRh2+lXY +f-Z%Qe)}T6z~Gpy$4PNqX96mbq+xH+H_5+DZ>8a*WKY-~3jF92pR#?t#8)03qu8!<>`zKCAk#8hm|*R +=wc&ZDvkYerQHgINnmsD#TW$oYy0vTFQ+kAb$>V=;n~ZZ=0ymojqkDJ8p7HA)vVR}?AMudC)o+EzO5s +4aiHif#ZimsUzkINqS$M~xbNB>W6Vb!WXd5`1wywQ(if=BpC#`S((CV^D2Q(|_D +Z3wgvojITb%ZXI_#fQWKvR`v(Nay!oHOrjkR$0sm{?IsVMB*hc(z8c%y4T_%xd3Z0cNPJN#_pU?J7eI +jYObcIB)E16N9T@wPN5Y3{>Yz`mZ`fIe+8$MuRh=7ssDsNzmWq$T%|n!wcVNz1e_{9z0GJ3lXtFt}Xt +H+HF>uDxz6@A^y5Q_!z8-LUkvI3;eeE$wA{S#L#Pl^ctalau5%sz1h#NPD+px9=PVF3^U}Xc{1V4>W^ +<;4f=;q*UA~ICq!^sJVrqV>%v@lDwj9YWr~wpFB3f9NQJSpQ3p_kh@mswSjH{mdY~c-0Fh%3>bsT+yE{(@63%#xa%{MrTzDbdns+3Kk1 +01nLd10>eAM6{RwC^S4Z+t_yYi`)rw2gBjh=f_VwZhR$!g^yfNf>uTj +YTCdhx~k1i@E&DV>8%3+=X2wg-astCpSSjP!`Jh9GEFG*DZ|0|9yD&C)b31{b^?U*YSgA6p}-6Cp0@O +|E_7jh(x3+O&jgHkCD}$j5|Bjv8Agvl1^;~Iujbi#)KS$tvN@kznWl?~%7vdt88-E|CO`qSgU^Wh;&= +%g!=-$(#(Y?L5*w2cUx*gh^z7^F1|_~z5HIf(@>L(cA1f(IL%xoyDq#;PC}o>)uj4Qft%DZ9UyB+ILn +VJn_;9pvdVa=153MqNlWBx5wEFs!R(^VQ{b$PBo{!^8d^bwko>pc-FQ7NDl3rSN?C?5)2H8wiO)5 +d`Z!bwjx)kuJ#&P +un$5YV-w3`i7?eiIGBmQ +^Kmf25dNc&_X28fuYd`b;1wDoRIPybfwq^&AgGh=WY_u$O|>x+l7fS(80{@zyk!2$5^$B_Y)Z+NG2d9 +}mnZxx&_QKd*uA`sbiB&Tpf>V-UAwNL_{7|@Qw!B*3`IAI?Ii`&k$K!cE-7m6KGS*P_}@MDiX;sdYhA=52^pVmHn5~+K02TH(p>Wq +vMl|kjfV25aIfT?S)B7dPC}P$2*;@&0OI~f8|=fklKe;asKJJ5tohN*_^l`hB@g)ELn*fmy{!NSULvd +EsfLyJLD}u>r(~HdqI_}d&OPIxqVksmC$GPzBq&;t!6LIcjMcpX?tO4TDPa1x%jQu+*ECD_2na0G6|8 +QA88VUH@O1*cCyk2@pgQg*F`m6UXTVo)$Dt-*OZnkCzDi3kVa>~!pO7BN*EM9oy@NdR)Ke!q!(WO0S}`IbqK5>~IevZAkq#ot0Cr*?OQiCIOQ%c? +FED7K_qu|VvKt +XNnGmMn{S|tRfE;^*7Cq6~pB}-@&3u$IqOXQ%B?SASDGnd|d@@PHpciq8TLgYX%&i|DvWD`NJ*wGR)P4dFK3aYIWzXcrRjJU?{ +5J8;26C)DSV!%(-yD&%lvd24<4&vjY0fNvlsC_@yG|05(~#yO42qlb6P!PxnSxWLv9<=T*xhxyG#JF1 +erz*8svbd=5_QMnRPO!lORxLhYuRd#UuZB!{@(Y$Ki2JaW;ExrJ`p2Ixo$~`X-jQd!kCD%EHqXmv{fO;bpok{^zhE0vU!oK?WsI +$C(KF06BhuMMomosHy(watQS&GimuVPuQShN}L&0YwyCtj1WC`FTw}kfG`*W7wdi#<-4SSZElp$q~u^ +Q>GmN^z3)3s9 +R`d;@Yp=y>%Dj$PtE90A=nB<;s5gsJ5-Hu)HijOs=!LF;t~Uxic^5mZq^!8*<;%d(+C=0jK$qpcW8x3 +DG0#2jdn#P~9i$?O6$BokL&`Gb|KTXAqQ$*$sqNj%TQ6OdLPC9y@;h#0-TcA!sbEm{qSLB6Is&(?Ilg +fwU^PX*KkKcJ;U>uHAD6kC~lzU4w$oEnV>NV||wwS#6i!OXK{7`h4;qy3Z# +k{}E6OVZVaw!9HuNk$$2&7&y-{3B3rNmWl2w(4|N(7aRwBrLzR;I$z)McBl)3fWr`aA +mixpO6(}?_LYmoT_f3(Ku|*vc<}9NPQB +yg0!vg-iI~YY^kao{5*M08p$Ap6C`H060#taZltpehBX&#{qH46Z($M&?A%_?kTIVi>DIPjrPk8gCkw7_xsfERMP +j)#DkQ5Sk*H(FkmqhsFlp>f(kRJ14LpMy@z`juyxF<_RTMNA=_v3z~g +cq`m1!_NrxBEF%1T~%6FaX%{c5p^I26N;}0$8BFn@smiE41)iKntW2Re+zJqtXIWQekz4K7g?%{;i1A +!$FCNRzg-V-!yj%9iN1=t_r_I!Ip;hs@xX8%Rlq=347Br2v#PSQ-gkDyICv6Or!aMb1)Vdkey}@UUZ+ +=zzbf0V=+Tp4_nS!4O>M;oP5GGk)+(5|9O@D=dul9jp|e0~KVTMoXth3uY|n>qcTYkM;(?q;C(s|mB5 +&C%H5L2So#8yNC-MtD^kR{;zP}4ZX6Wr)fs^TMbGSEaoW6W1luX+jHW>&33dJF^4IW}QQqU&J#f3zK1 +R5N4~KLP +ClrRCD0!kxqu-cPX%%zLDv38UTWEGR?7)6w((;q&98=T8Ttr-#SG(bG}iJ~s~5F6s9zP9qj_Jf;fv1OAUReFp-8~&-bV;Xz!SpTj1n|NfJqloREiZMmZ? +O&85$OTBX4rcfh>=oJ=e4BFQBT%i^=kN^}y9WaWui{gAT6KAQ+miM?z`rGT+>a)%3*xGMW$uz$X%;PP^&;S`)ieXR)I0DG;U%am +3Up1OO1>o`GiD(TqdR!8lEuqOJj3`aFA8mKew<6zMe(-Dh`?5Vx=0AK3JBo#h{R*7n?+=n#eAy`!i{p +VyNVjH%M{|ajcf1UkZ}EV*G1kD4}2k%`i(ZZ5nq7R)jYD!Db`@ +%g?p})<^dQSeI#ewBvVTWwog4BlG+iw}l4-gmc;esvbIg#>$n*>bkJA{{7P>=4BcpGNh3>MWkA-}gc6Zay+zB<{a4*te3+ +Z(!dwal>FRutJcviDdt#Xx~Ova7WsZGanPWk%>RAJ|1roen(S~y$Z42kFcS`g4xR0Cb{w7p*qs +qLGRgN1&vg@jR8{;CWo4;@PnIbT-t3P<|B9-e>17dIFB8EG=!;^#IVSR}qlsT$q#t2Oux^V7JsR1%n) +35oISbkPrK#x*Aw0W)U3Bo=ktJ+Qq#_aEpxQr|79nu5}n1N>oq!^j`?6+S1oE|(VI&t9Rh1QalvUAHTBhfB(Pf$Bb +vllti!x24;vyPdCFNxrM|BY;`CT+i%L*zK=XH|h&@#YDq7gKUW$P;dR23KXZBnLCd>kcJRgAI(AV%Y2 +G@qtU!u;D)amr8p>di_qAUk^X{267w^@Bv%qse^C{ZIua*))? +@5CGw$La_4+#({l}5rNqS{!M9cv-$ZXtFB^zGR_eE`Mie56+RxNIo5;Gd{vZDl};uIBZJ=4h)oZv473 +A4W{BEakVS3ZUKLY!Jb6ivx0P3jCzoes@;b4V}VlpXiVTjOdKF)Al)ecSIF}zLAi<^`N#q +*vQHT0N!g0nF*lPs^Qs|1E{j(BJ6LGys?#2QzLy{KxSgbXHPR+QAJKi&a%;`{yR=-un%A9p|OM+ZmI` +w#E_^WfF~tEjVk1iw3R^y9(t_wRlx1LB2=z6 +9-HqPwemFkZ`{8i+L-hWK5AWX{?L!w{0pPa>Z(o0acJ|-wzdeQ#LCf$o+W$}Z86ADUdw59A?fw8`_&| +N!d-wiN9}fQT{c-gDyTe!e@bJ5R=-uvjhx^>v+dtt-Jls8a6GyLh-|YTjpX$8>I3K8>^zg^;_vsO|xC +{T^J3e^#7ICxp?(Ojh_=&hVe)r*6SN`$fXg`j2KO7t(VqSlE2Mr)%q0T!312y07GaN)Z_8eg7*?~ehm +LHDxO@ChP?;ZlQBdl&uB~I?f*%>U_v$JRzecb4DIvbkbCe5_g$i&L>F;aA&||aLoXe=tp2HrkineSIjrYMc#$Amh#G-q)7=~hTpeQbq5 +^o6D-=2abS-ao0}%}=n5ZNjQ|?47cL?;ppQ4%7*Pux?YvA%VzDE^cYT%A*cIwKA_m~p}+gA@VO{~^_h8wsRD+AO~5XbOlDU}#pRwTs!_d%#uD8$6`>zMj##-T +m7{*N8Fk_g{M&r|yAH#?!f`=GZ-De}R%SVDm{N^*tXyS&FkK08%h29Xa +uYG92V_pgp`010IiG)MF7}iYiJ+HlD2OkXPj6y_JOjCs+m=-|CR +MrP>v-RC98^Pvw7u}?vu=ed$nn$-~M)X{#pK2ap37aY`MEG4Mv`}%^q~d_+YL>vxiPDN}xN7=uGuSqh +NmWR16oCnS#qv9f;)uy0ituS#j|P2g?x%Tyh^YvD6?I^Go{n)(9u-p*mn!1Cz>066i-0s@$DOYS+k@? +nd3u~pQ+cx;y#W#dvG0FH&;M=b`M*X#9PeStWDb;7av>52MHY-+%~y-;fzzrsA<;xknoMG{^0u2Nq?aL8$>x~eWzrf0;(5*)OKGMS`AEc(%m)~ot;G0 +#VJQB3q}n61)z0P{IY&y&&h#>SJsVOIuKJx1q%lM?!&G{f2Xth +t{ZQ3UlYqo$KT!I1F_74H}ey+bB)k#_N59!ZF_YH1Vud}kE7jFy +t&0p)?oSJ->r|-%)MVa!C$=$nr#NTJU?b7!Wp}ogt_8p9R|I;X?xA=~p?!%e|THnuM6>)Wb+$^YfP5t +O~QYRCuq%`z_*_AGKL>qGxVkvFoga4q_$h-`He*pOcruQcO12bNyl@IA<`Uy)NU1zgn;^|ydkjg(aT9 +4*4>NkPjtCDXa}t;|{Y*UQVY){{8Wr!+p>a>N|cmN8NF^EEE1coB1%u +=Mxvn8~Er5wx=t*R7yIWX$?1wcNg@nxXnjELxedRV#;eF>RzRv7y%pIt9m-I;Qb}JN&KEnWNA&zGML! +Q0Dt5K0-Gk+DGn6yL=EEa|FVC=qCm>ut@nzFe)7IdZ?a-uA;mTxT5F8AMpY`GPbRO@^ZDiONKvVp)B; +u-p`e)UqE^jcd8-Dvqja3g+93vsVgcb|1{_imz>K5|9~D1&WM8IJ*yCuPD*g?U#3*$pdqX`%@t6x$Fh +z%g)#`HMFQ%$Y@=JbcmdS?v&}b)aO$*atV4i2AVw@UK^O;KmC^qaWc>#fDIUoaC|87!@t_e|~9cRFej +$wZXF3C_Y6<5sii|iBdw1iviLaoG_6a-It4uA$R?fsw5z5(WrJBe_A*ioSS6_)FDzv=cjvWo~y?T{3B +_Gb6L&JIAGkm*1fvKZ!wRf+{ +dRKvFuX(zfB__Kk0dAmH&Q`ocp46@bUv1Cg-L5by&+(5teTZDk}IBjF+SlAfgs^N0bPDXE|^>>rqLKv +K<8kk0M~(E(J!{fvug=!gh`0%a~3(nXR@Di%o-1!_O1f6KBc5yK!!W~xb{QU#>8fm6vBB096OAjKn*e +1DEo0$m|MY7+|qHb7FV`5jWp&(UBoh+YnCyy)*hbs5YjN>C%}M=Xt4cqF`2H?ZXm{k$&k+(J(n51?v* +z;Ip3XwB3_sAG|`0pdC!`zk@sRw9+DB1k?SOdK5P&gY=IT$64IQaQ_C*waC%qY9qsZAu$)U{d@s>;oA +JW}$5#IMALmYh190WO|v_f_#Ph9*n2^X*xlJD9E`&g|OIdpyoZmCJCX}{r!`VE(AZn@byD +wrcr}RpM^RqK)@9d0i1+bnkPys}XMF;p0vLWF@nh9~Yi3K4CA}T`jC9oglKAk}|n`Cv*qWT7PH+nWmW +*`cUdob^)dG@T1#nhpG-={vk;{+%V6d9;HPo0MwXP0Tt8}{+t3^^Ytr)jTuN5du~4RONWb{|{YAYKUb +d6rhA1PYEp(v?BMvfrnN(eLBvMI5~ZO%sg@q*;7~gC?TOIs(>(!bOzKib_vQ-^?_&5d$==^Ve+Fvm1 +@AW~`~i!9c4tCh0a;qw)+}mI9X95ZlMuo>DD)%y9+e3|?+*h{*^)LqXgisYY3L22y)kqO<|)(0PGs7r +od(0Zlpws`c~jufO@%fBRqm&;R~Eoehyl`3-&Bj5@pD?Y-K6ot%$gdOI6hZpR|hjZG@MaduvzT+FpW7 +U=z-ulpNDw!_9cgTEA6-Ww4%M~Fq~qb`PBkGo(Md^@ +C;n(t;T&{=!8k(UBM;NMmlP6PbHR%@XIj>;0Q&VGt`5oHxCGCoF0S`nK0IYL2U-QfgG8SEEmCW#i^d4W(ZTo1MEa$S&HBGG_l0i99bL3# +K45**z3UX&_>9k?DSiJ!|Qb*tKKf^>vDKLIhIKVi{IVzdemUwCa%g5mfgHG-FaF?J(L_kLW!P^L89tm +Z+4!J@nCiWfBR@-`{=ZcpPpV0vEOq>z5Eb*>03QQQ~H7~u|$h6O{Hm4ULWMMd2LV?)5EkpQrcSQ`Xjq3KF<)auwpjGBVaH5e^vvh>Vv%E!yH3Dq;Oq553EBB=!Z0M +kGE82GWoHcTnC@lDDFY2jhCp%UHr*UIS{g2&BtRbIRKtST{`_re&4G^KTo;~s74CkF@wn>P>uaM)=T2 +aCYY`##hM}iqub+<^fJvMhtvrk&g<*F=hb3Sum`Pe@9<6}g!4Dn|At?K9M?DvaV@0YUOFJZZgR#^(O@ +a!6O7a|+aEw%{HB=3ZApXDetfZBjtB2NVNF3t5g&>f?g=i_f-X%@4}5oxmNG##TC%jB-L$DVz;7Z`@= +^IuTzo@Cc)wD5=x<`mqm~!MV5x;DU1dw`K_Ch#hp57 +nm+Dmx|%}hFA@eaPwE&+dO-@q5PXw;_zA)dky>1@U24ONKKL#H!xXPO9zEmr|1?U*-BvZ7dY32etkD? +l_yPw=wjzBD!qgEFYGrM3a`WXi~g$JH~SE{Q6x39J0Fw)!DLfBWrLw*dz8n10FI^ep5V;&s^7mQ8Ay45ewOLRw7@ +3WnQWlgG*MaOx8Ia9?LXTB)h;dTJcdi@hYt$DYl|vCwf|;kQi=M>-+X0Af8dYySS**+ExCWtZB9#&%X +9YR?lZ>=iqm)A_a={RMpLzqfpgPveTwS8kS7(Wlz;JtwG;q1dX=rKI#UapF$aDgA!c07=Rf+qP06Ofg +$j!6h{y8#1to_XbMjr+bTy)2_*!P2sb-lgMlQi!M=>C1*6atE+B6w!=2@6Ug9 +D?f0$@b~J_L{INYT?MLH+Ugm-h!<3(zN6fH!M$>f)SgUuCW7R13(E&z9tpY?C`jpr{po3Oz68!t**&k +Ab~F9n5?YXl@j)(!+gNZ@vIE2j`h7l1N6p2T7}ZsjV9sL+M@sKv9zp<+}OhB(0ui9PGqj|5ipxrsCu1 +Dsx(xBb!FNqLa%|M*?iJg02aV}iZq6nxX<^&A~Z%c?9rD{5gIv~c<`@N02wzUFKlGOk1C+e>Q?0tUE; +s}CK&09fhONhc3h}vWeCD`4cn`-{mk<1=(*7yRu2w}1M +&>P)D;G$@hD_LH@5)=>ro4r?*4@Qz2D?bYQwN{$Po3*qad7N0*f2i0f+c@?y9vbBBcBQQKS0>Pn(ljs +I26GynQI%2>_r>?=)N7;28-K@3?TgbeLTgbH^+?`c#u1}TaQ9r|z_uh3(`$HeiaVIw(i=UrEr5hVV{= +Re_TNB&b1Da6k!A+=v1rz;{QM4`R8(6o&0k&Wo-Y()>@Y)t$$R$^-+qP*GFt+MC$rd1UdWXmJLp-GK` +CGKhjiANAT5^hzxQU*SXzn6=6gIhFH_h<=S7~w?I*QV{Lkkautr5EQt?dTivSoI?!EqgDcxsYK4!mY5$IzDPvb(mO +p7088GduQR(~bmse*yRnLALSo#Sgv(vQh>0bFoVgEmW8p!~V!C`o7UbHTFGB9BUJZHCEc9!Xs0SwoTg +urmnSNX`|oNUYxG9%-bpmAU)Nb4J6v_kz5E@?51#ji>_gzwXWEs48>)}sXiD@c%>vYI}c}-+?eI-_mk +PhoNm3J-{Ik+eR2>*uW4p2lY6}Sus{NQ2Mjb0EwRW|87B@!L1Opj*LH7`$OZ$O&X(VWs5dR- +6nJu{{Wbe;&uFK#02v(Up3w;$2m;j%!vo}(OzGTJn;>j3E^$P=02hM`Xn%7d5r(8Y3}ou`MfoVi5uIl +zBrwtD!IR@{Ot>i{;kb(zpP+3AwnP1##UxT+nbyK`7r?wmZ>O$wyW13&1bPqu(t*OwD#k`Wz|^*hUWd +jLUE%=n3_Se=6phXq*Lf+(lb>AMjZCukmLDdDRvsJ)D`G*!7j&XZoQ30y9Dj!d@>V~S6&+ +A0!KcQmoTP6O5-kD>Eh4LfK3=@*{U^@uME@^n|r8@9JRL82&e_|mzM+l1&&JLh&+|w~=b_1#t!2*2p* +=Q-&V3)Q>d=XjhJ=nf23}f%J-I;p?xAz&lbkDSIL-WgGloZ7i{Z#^eQ`6Wgrky~cMTRIBrT^)o6etmN +Y_0g?#Nwao2ps?&bRJO2ica8DaVJfT!+_A0cmaetqCGfQ0v<>|oDP^(vyR%khRVCXy8B2P?3ZY+n~uU +p2(zT6FVS2dV`Ps!EKR3!Aw$tA$`USl?vBUm;)B5TD2Thw&Y#~`nv)G>1ne;)}Tii*d!?4?%^8}w)G%c@N1Jf1Li;LY|$fCZn +wYCXWe&MZyZtB?9yG;atNW$AcECAjldCOR$w!#*q0s#P{)7K^8h|fIbA`?SNDf_LXz>=Oh$pbsHaUEm +ts5*bKU@vQVm}tK~cgs#Vl?Ea7@qI=Aa<1BgSc$_FNednlWYh@?El{s^huhi|2%uDB0wPZZp^irNnz3 +QcSm5fp#&ebb>aAR;G!vqV6I5#LItC_@vMN;Cutz8yYUoyVXxk3pdRJbW8MhI~qO$51pUL4jacg?DShGLQU{W(f}FGjfsBH7tLQhL<_kwn?*O(zGbE_4tI|(wRT8NG4+t=)J>WExWY_;WiE3dh*pNJ76xf6{iF*;AQh5kV2pKaHs_iKgbk{RYvb4RV0=Oha78V%>M2abtz;VI|0Z8>55M*@G1^c3v<@ +e7r&XwS_8yrK1&D4I&AwejUMF7jjm0a9^zT=g}&uu%*^4*YZfovD)_NPJ9Jb#fC65~h9=ozNP<6d}FLMpk6x| +&3LpKYYxD`!%Vu3`rZ=SzS$vi(KemN}V^aOsn?uEY$Hk~lE +-mlVF-}5-;d+MT8Oj!rydHKsF@-e*H6tB2{Ct;jA82wAVMHK`IY&_{@isjg>Dh9OOdGK*7jLeTn{)+J +eU7f7osYSB(%3xApp>^G35oG_zbONTO#(}0$wePmmNc+(&?*npn{;wO=*0sr{<0Z$0G;NT=?TSnOJ*3 +1a9&HP3fX|nhTKm=Rcf48S(%Ot5 +I-9u~J5l)VysTu7;)I%;UYm7#fM@*HIY=uj#>f@rD&#*M95I0&KluNFwSJca*-*n(<-x~usShK&s; +-QC!{e-N{H`#ceOqgFIy{69bK*9zc7S#i*rwZ@~^;wntG!%#ELf~+vbLm!h=Vc3CeS(o{)Mv4~vhJ_2 +0ssA{>Eyd&d?y#;>_|4O4nY0DslUTRdVy;{F==fziU`!+9Y;eKB=*`KPRxh`v`0Kn0 +0@u+`3V>3fFKesrWNRZ0&epOrlO^ks|)NUPQl<0j5^nSBYa=!LQKyw<8eBxuZGXPKz~_snHN=^jg*57Q +pZHX#1->eT|2HxR1B#=j#J6*2o3(^e^W?QS}4z*xHo6_RUr`sDT@Wi`H&E2Dk!rJHOp;fyqZlC(#F^_ +rbBM2(ha%&?IA|h`j~g^BHCWUo!d0QNwhJ-wx1}fZc&z%ftkanX;-Fc!`aHyM+4gvo1R)F2G#Gkhe3X +>ht+vnJ*~E)r`1kGmOo*Vy>1kBpAjweb1Z*cidFC7smvN-=IPsgV_{FQHVyJXN~I_ +Sq)9C!}%(ucmbOxOca{b(JNIlJ*){8!uXIkI_g1#i3e0H?m&qVg$U|MQ~sYd^Uy^G)mNYtR09r3JSfV`ufF+8(p-EcRWXS3 +6BkV1W~t6Amq|9!r(+-DAw4^;G1{`x^@QU2*TtxqSi%5WF4ZupEh@za>5+dY%pQ77Km3RT_GMfuV2cF4_qpi+jKKPp<-4I?T7Bzl@Y@ZCfwn8yt%rSfbIv +w8Y@-xaL=KWg#N3JSEy}B`}Y?Y^>-XO!4|BUn94gSri1T=S8}THZ7lL@dw8k5+(=3;L +V((O1=TciIF7B^b*~VOXpH5RADRj1SHvF1k_WiyS6GRn?`(kZ!vmDK{(TOCOP5sKCxg;2@H)$35QnF!N*u~dzPD5sbBAX~3wraHZ+ +<5)t7_U!eqn(I@PQTI_qdz{nVIfXWA=`bxi?sY!ay{AAol{W?+0#IFO5L++lF+RkqM!)xZqvKvJLm>V +;@_S%{wULL;CK%Y4Et@l!U!hNQ&TH27pPsW5zBcbtw2S}GKZs?4UG5{lweV}^D6Ha1T<=iTo}7|G8 +Om~_L&lJ&`8*%*QXlQeMbg3B67|*6nI3tq5k??m1II3OdzFEzv)%BB4~F;PAwV~%&(!1z0r&F`gu3p; +r)nal^n56Fd_d!W7VHp828^4?2}648AHN9emn +CQXZ8>Rc(!m=?wNPjFa^k^tzZ>RrtEqD0L~BDjHSVoiooTM589QT^of4DKAKNVVZ-+f$U%g{{3J6{H( +uu*8Ba_C%+qR{eOS^cBgZ4>IL6&dx_4^Ed<#@jVq8M)Yf>iv#xIOi`WaNSIo}rA_>pPe58qGj4&JK7+GRn|o)*GZ8`1-6q@73o7W+5m!!So;wU_+H0O +05&!T@VnV9YgrJ$S8hpdv&~`P@ivvrbf|V^g{EUFdSVl$WZXpdfaMy?lT!HhNBYI8$ld*&nub%iqaKL +@WfQoTI#zo1V(Sdul8T>{&0AF_T%>l$NOh{-|v1n!h_uCWB#%JSaKO)Yv)CU;k6~^Z>o1CdjIcem&ry +f*=rt9r6B8(=<=h^GeL +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 +Xgw<|5n>o;vAkvUvEkHIY*3~&o +Isx$e`JTof7swe+G>;1~Cgbi-y7H?qzy8$6}Tr9NH@ohU31ayFoF1$7stywBDmAmU*_$Eg9IeULIp1v +@Dd?vpyYO7$*jAlBvg3HV2#>e*W*YEZ&uGiZZ?DR1u9M?GoL<9X?r1?KEd=OoCcMHl0sZ1|h&O*0G@!BhFqlq65GK3C?QAX_6QWa$I&>X0HE_*A1Y)ztJ +3S*gT+xA2Wl5_2@B0Sb4(Rsu(pj^#d3)S230+--5yKX+5{F_MQ?)dM3nfzkjmxb;FyQ3gXRb$u}M_^C ++0lWmh}dK5ZP#`@KU~WE?XoMyghO8+J#APKF)(BP`m`e;|8s09s452!W=TK?|^UgO7EC+M63WA{+UZTzj*e*lUq%?Uz2eP;Egzg9b~x!I%Pico@42b +SZ;;AQ3hw2apm_%Wf4t;AV^enVk*Q;l*-E@Oiz$~CpXpW7^RWc_@NtNAyL?-$ef43d}G>AqxQe^c*&6 +_d?F{<)#ve_DHP|J=4{-<7}3-!C?Q@5*J1^owTlw^jP9m}MUFpBt+bCS=XI{e9aiMK|EzX7CrAK}7G; +W3Mln&EHn)uVS8g$iHu_)GWVAOPz6T>BRo6ZJE9;>fQX#oj~0ke5I2XS&eh@5uq&1?J*?#mwRjAswnH +xoShNKQ^U!1g5DZ(L(WSlBH`ZecRalVL-dT3Isr=gu_(vAWHP%-s(woj70|k9*G@bt=94k%{b-}ai_=N9VnSV@-Lvn+I?&O8@DIL6!9>QB`il}U3NSCj4=@I?^{E|OQMBfX&b-@mVGYnEZPqg=mLp+l?I$Y8zo282=FeEE+rQEqQp0-!1AtaSK!L`Y|nCMoK3R^CIr2}&bI7bgmyhlr!>m+76yWYU>bH{6 +!Tu!xz&a@cM@!0Hq&Jmtr>0yEh4$D^aIZ3*R&5+-19=$>2@y(=O)Gl2~ba^{QIwcg~n&YUK4lV~6s&^`OLcXsvE;5sOurU&N_ +pMqcf-n2t*CNkpo@QI{n&6bkri%O0nmTM^QE!#t=6(s~ +{VV-Y#hQCcTSbdK6AamKqJGq{b~f0_D=SW#PLGS%{rFj|!VsvR`lweTX@+9WV8D^tB%wT*Cb+buo2Wv +Bq^YM#_I~P6t8+*+XR`DuoSQY6CGqW?Q8{q<#p;lJgjle&U#bPIC9Ul86|LqPvI#ZmtEH4_l;gl@p(p~E;E}EBxc#f7Xq;N*xZ~}!h;3Oes#+LFZiJ-0Drn5zL_;x2BC7DS!w? +-TF{41-${kRIJh7OL3awmUK>5>&)(Y_`^)u9*Mo-!Kf`VvP;?=tH5F?ul%Pfk6;+{5OyjAr>2kqXAqm +7x3c5l4lf-buD_O7)gn(YZ|ax4=f}kL$!SJn*eUAb-7(;5(k#;9pL8`tYzc68VJHBLs--Kmqonmn8a) +b7wp1SplpPfx;Q=9adDEC^A$Al_A&;P!~e~BfeoYniQ3hS{8;8YTgV&#GnWxP6a-q2H1>s=|Sfo~@Lw`&GA6H9+;^Lh*lT@D7q5j%;kA+3_oL@O +V(7~+F?_DjDSlF)h*UhrmkWB!=|i-F9ndCD%@M2;0l)S0d}8VR0CuMx4dn$Oa*=irmV<@Sd|mXobX%G +0(z-=cEd#+WJPSr2#ayieBVd^U}@zwa?F9N-s +_f|)yG_DLfc$$^+~OKalhKvKep3uTui=@oet@~6KIwbn63*bS1rjghQ?Ng0{4kt^)iuh`C^m8rO&)MH6Y#YsGMY>ZJVBX +|vY91(^$iVmwlXc+G@v5qE7#c3!0_5NK%s|r{y-qnV`T>FM2SLEHWM-$fY_YXIMm9Ho=R8N$O?spJcW +|)W!9(5nUHnYv%!yQi-PrLG#JmPGe3-LFswvdlvY4Z_ha6rzK7j|tWJ^SIZFo|pDExZ<(2P7-Df?_<| +B3L{?;~Hn7e=OuH&HOL1bWUU~C)$1D~Z{H*H{EbqYYecZ_He>`G(uyisJo758qf9#Ip1k38j8vb1-otS6*Tle-VZ>FH%qmvM8sC`C<;@ohoJ35`)ogah&>ry=GkNMa8 +dp)|1K7^U=MjnXN?f!VQ@A95N8Y4|kG&Z8ti3x$YZ9wA=M#33Jr`ZICN^MHofgktZptuwJS9WM|=b5> +%mbksPQ;fAY6aCKPg|OJG;F(V1cLRq<^J(bd~*z#C>yPuivb#Cuyo?1gV +u?v*ja3(TKS|`JR50VDWEsC6QuZV=V{gHMo%q61|zcHM!frRvdLuef(a~#7QTS*pG5CDM +`}XBBj-5Y9f^-zU=-+-L;kC5@6oeKQ9qNanVhjvf>hL-Ce{&@P_N92&Opwt)?O(U1LX+{k7d0z%F59^ +t8s_^2_-;S3%FSqAl#7^4S^*$MK;!lmt^Fu@O+g41=0sTe7U11wwriyI1G}rADFY}td`+$J0k0htYXt +=s~m>>?Du_}xk;+A>41!__4uXS>Ff|lL)Og3GT#lWs4%rZa-!x3Gc}yyM5TL<_2njv&Lgh-YJhfd^NR +wn3aV>-d693agwdMBFzih+onb5n9l}@oe(yLXD7M_mE;@bQ3QZV^2`~*gvd=EMbP +@wTLWTtj^s{eqF!X~?RhI*gJ`K0K_c34kgWtWoAuyn>zP`3Q!s!L2;I`1QD=1nnSp`uM(Frv$^Zb}|Q +>*Y93R8@KjflO^*%+$O!Q;KGZLPytC(JA`DEA6BM^a6k)zT`dS*>U`61FHIX=-+rFA#roy?2cGhgk$-u>b5`0S7SfBNy=hgWBN-|v1 +n0&-SesWv-j|I%??b6WP-I5L!73`2# +XD$UMQdCmJ8Y1lusJ%nr5BzYJ;Bz?c(h)ANtnza)_a{JnKjR!8=0_6Jcm94;EvB(7t#^)|!b$6Y+~pz +(W;8tV<`2=#Su;mnmF#V9yX|IbFJle(sI5qh%4}_$gU(ZRMPI?ur<#t4qT7A@?zmB$U7;} +0qbsqjt>t@&X_&zRTZeV}4=N!CY)sYUv0YA-fPuSnFy$Pn5X`LtiZc7)<>HRomF>!FZqARhDXqWf(5t +nSv)~{f${<|IS89g*m9ecOwJu(8t(4UwwYD{5iNQPxk2HP1h}7_RkQn{}+PzTDkF6@Y4oHg&87H@*%s +%7|fqoUDXB*26><5NTz7j7L(y&GD7iUh=?TJ+~1oA^cJ+-MICb>~#gFsl-FUEJaBHBZbLh)+%R81W!v +e77Zzb}>~t5pg}JDLnT!gO+kH;GMY9lj_|6 +*4ZklP^F+HpY9`e&87CW{*N||=difbgbo!EY3kQZLt*Z{A9xF$0BN`;dcs8$5WmH_o2GuWLgiX#QLSQq<=3cDy`|?OT8ZbMw-KEZNwUqZz%Y5q +(xB_nm$D@qvw8b732{s#uj107EPdv%gDM15NvJHu{dZ40M!iI_-k4gTT+l#V1_5+C9~2_qtjW=1}6C8 +c9pvyz#Y7iD#@p1c-CcA#NZ8gtspa7(h3y{yjLCP_d&VTb}n_^uWcn-W&zTy1Z|G$yEE1<{Lhgb>;<( +MAW8}xgq#aBb4~~HX*hh&ZZpKehR>Tm_#xsR1{!Q}3@cMD^LI|3R;QaN)Sso*D4C@n(#!PIha|t08#h +g$ff!FHZUqXqgg0sQ;c11vc!s_BxX_SjKD{y>aEC(;ubS_D(U=|d_TyDT!vvAL{?JyRiC=u2iqpAdzSIBU(Yrh6f{B6jb&g=r_sXMyPiZzY8D=CrI^Lo +MazpeNrFJn8Ga#(nf|iqq4-R&}CW7XU*JAu7(z!Hrbl54SiwnIrr8O3+iW$6f{e-Kp{zw^j>b$xGn;0 +R+8vZi<|at)GP%2xhKD3)4XZW%I>5p(ItJyXe;vAs42*8%m$==* ++?E(!J;I7&noQ`+vEA#Pcw4zLbHP7!Py1?) +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 +t<%Ca*@`lBhkh!AGy{-&^rJn?x&So3p*} +F4BY}9LvztgG{m7G}0hi%%Vfrfw<=|fN8QO}(zBY!)} +2@pG~@VGEQdj|RU^e5g(~p16mCb0jc9`nUodPB+u*=anDwTV{Md1}16eZy^Tgl0n^t(?^?jMLTG8;pI +RmPy(OK0BaoU!2o4}uBmV1Vz{*#KFW!BH4O|?{#Kvl+LzH21X_K#j0+7Klmt)VeIq4!GP>%(;n%Y^aM +C5s^W6XHgfz*(?7Y)j6MjRz`9<4B-X;jp_cldKi!5v6FD&d|q>~7uHqEl$@aQLhMKh@4+aHOY(`gfYb`pFk{npcPya2N +p_vyqAZ7Fephxmq!Xz{ukq?m*A2DYv+f-71PzDL*FI0+l`*t8ljo;C*XV_~j7u3&OWqthRG}K2>z5_$ +w!&P{LW-i;cHmNHoSx6y1GXtn&SiO+JEzemuy$+Uu%J)F^;oecoEv@-cAgn&%tKCJs2 +#|Ja$chPi{Bchj58e~Ni)v-P5DSjZoI@$Leu^9O@(ec=GU$0We+P9|bAX(p@VL-vL2)`d=BU8oIy?_B +Pon;1#0`=x##gF2WFPfnffaEiCjoDwjln{?rBE$K=(3J`slmaBAh{UIIAOVU@P6MOg8y$&U6T8=9(Wf +R_$(z4?s(XrvqV7W2i=Hj(x)annQpk?rsBwjXGNjh#*`d-{+O+E*m!w7bsF;J;$-PEZlPT4ue(!sbv5 ++2F&C0145B#lUB^Gf;d?tSiVc0o3uv1V+HOY}wD!Dwk5)y0f6XDGy4+R$*5H)_6F*nCdy9BaNP!vzV`VpKmwR5)ACIh5bSdWX!H{L;2Mw4Oz)(HpO=a2zg +g-)he3IWXqy6-+4r5GW(5MR<-oxQ}N4VID# +c!M#h~E6|AP@hoc~=Ps?iu7nEZwbtFB^>&oY4Cf2rz=Pk)TCq;e`dYNx?0`ReraoaMgPE<`;_QG9})ANS?P_+umcHy1?0f)VvOD$C;%|Jh)*)=e{Nm4{sa3U +QHK!dpC702SP>cdLFthSba|n1)T@@ur|8)bH)KYsLIzE35E4}ZYhe|28FRE$Xk*QUf ++{S#qSi8hvJKDL=-gQ0|<>QNdt}nVIS!O@bJ>2Ejlm$U1SR +@Mm8pWbNKb~NuhWXDX@hXYV9M1fMBs;cyAS{%JQc4Ml&+!ShNAet1SXr9;`InLA46c5Y>eiC#9wxxCR +r^6(on@?c7RFjT;BTVaLq#{sSV@P>m#`o6r47`rZDmd(WW=(Grn;XxREW~|#!#DmCokqD{INv1<_cI@ +Ch+mIL@vP30@)zk8B^)eWCR2$tn8maLa_Fje>O?s=iej+B`NGDqpQa}?~BO^1>M>6yI=me@AqFQ5`>S +)5GVhjGcy02s~Hvo`-7PL1IDqd9#I0&p$?n|0_&-m)!Q7ml`<0bvM~%5M+5sy*i+Fz08-)lya?3@Slk +}TOLr|$o(m1Y`kG<2foV(+dNq-UVi5jm5E1l_JbbY4o78MTYQxC>cImZ-_}^(+yer=nW$F*o055r=j@ +L$Co-P3GLTv-sz*$cr&FZ?<(&DBzhk;==%Qu#RdoEc|XNM~6gZ&T~+Bh1zP1;^OPy{+3sUmvL>@u`#z +eCo>22K6qI!$M9R3J2O?Q*OSyM!kzPiOn$x}mzcsd}>+q@vAbc)Ca^o10^FrRQoIRhw&Asx)5123UE$ +qLnJ#1b17>8=(`)*4(RRW>j^XVGSiY(X%CQ_ybak^%$Hu@mYg_tXOTE|eVHf(P$gz=?9 +ei0P|au4?uI=aL>FT5kRVE;znv#fXqdYI& +Xr>^(O8oQ&(*&@)tn1-*N`MH~DOw^nrb+mnBDcS0LS9j+yCxzHe|A$|_h6+K$Wtp;W@|M=PuGJ7`xrx +jqdqI;^+pCq`^7jRWCqhfvQQjE$)Vz^3*8I=-QViU6=Z;=4Bj^gi_U3lh`pa=&g}>i56tQtsVEZgW4s +B9^hv{X|i%RO$Q2zoKXqn5Jv4?P%(Z;X!v47a1goX&NULQXO0+M@rxyPeBShR*U~myG2N8X--5vtm3& +$hw8YRovPI4quuK3NEL%Fj;5Ng{!yJ4??zJ#=3}=Ky5}1DNwc))4@u?iX8}>$Tt0${sYxosTGsRaV=A +DFXVSlf_{F}BtDXFHI<)iJE^40DZs}#j@D#V)|Fc~A2Aw?L8< +ZB>)e>QLXwtj*`i3a#soVplX!QCli*E$s=K@q}*hR27KsjV5Ly)k02zOb++6AvL%Gd+oImhS0da*GOR93?%@wq@nIaL2op{FWSTb^q2i=UVHNDtBcunX +Z_=AS|zA!K*+4NcIV +=E@0E9JCu-hwLRp7lR&P(y$58VdLo*?|7`*}Xz6+t)gG57e>HVtxd5Ff>4;nO}YqVJKO#6NFP+@YJeTYWILuuT5P;+GxDFsEP@xGa6Kl4qmF#s;HD+JVn-z-P*a;@cwJqR2tdGMy423TFz?bx20mqw +;0a#rJ6N(|+!% +0sEeh_~BWCEq#TFVnB<%K>7I6T-DS+7Twe&lya5Lp{e32hEdPmu47{v?k`dr?Y&w6|2p^U_U(Ew7_53 +c^H}je%yuHSrS&3zxOQ(xR%}MKFpemuStDJjQ(b#MEjV}R-$%}HTKY}Uu-t|Mf`+ckf7VKp_6Ym`Vr! +JubrF(GOKnZ~_Nmp`&_9KeaMH$fPFG+$61C+m=+B(Mt2qBlI#MU8Jl6A~n25V{Gg_rNUpa#fHOG}kH{ +MjW2F}7@FpA8Xa=rrn`i*#?4DG)4l~SY95iitfG}j4RssFyTFwjAV$;ZJ{9MxzwBl`q1X$LjX>KO`a-5+JM%OWp(F*{n=O!!~kf7+4}Vhi0!<3HAZ~|O2u)pnD237LKIj +~1(^}!L$fO0F#w(+cV#dE3H+?H3Gp1|@n7x**vayzW>;44ATT)P+@V{v86H+!nh?KHXb%LAta^6iW9Udf4wD+lO%Wd2X;S68EguTbM_VzZ% +CbO89H${%aEzuML*38&I`l=xUn-hD>E6$kkK$mWD;vpeDV{(aQf@rw0 +pn-lg_d97;r8cxSykG!rV~>CZ%|F(WkX4aD?oqcH9u92@*bx78%OpxWbL+c?Kpy)7OCW&~LNWG0ez5e +ytggpj-2T}~97IxL@<8VF%bX=pc9y2GWND%Y-zUgLNKTCa}KOEqt8U2P&I2&*+nUVGC`G&(V%Nu_ +k@MET=s$rO4uN4E_WpD)vMpavxVi|iA!G-^wWy+2;1xgS>!Sl@MC+~(v$f-*QfzRX#2l79}Fw0wPm?2Zm!Ul_+7oMPsGbxH|m2FSb+$i9c9>tMlXh&H*j=Dd0Q +47R;Ss?J1c5M)dbLt_Ws37rp$0nhR+c9maFJ3_9pQCg5Pm?z%^BUE268K?WQd|>s5j&|IC_NiY--km< +2F~xu_nDG@afVmWr3}Te7 +T1Ce>AT;WzVli4s8t!}m4lmHVdU!bVWbFw$%IQbt&NacYHbWpFL%UXG)% +LNFhsOR*sl0WuCN4L6P_Q~ll7r!O+qUjQi7RQ~)Jsx@jgfl-shv~-M1%(CJ*Q2dy1d~>yd+_bWd=gF5 +X;E@42jH;C11nqBK$y +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 +#`cTi`e^ZW#6&qCCctv +n8;#zahujAcHATR_kY^sK2>3lymDE-`uyE1pim!#*CusDJM3-4RPq|!iYB-Hnx4SvA^|n^NN&_05F>> +vq~3+5#dU&@puypL+zu_>wY=_mhZ_A)W70Z?1-3=H|^O4KtM#HCA(oAQAqTl-%?K4RXrj}01!(-!TUb +P-sM9|#6XTlDLOro%c$HY;^oII@6q%ZI(R)|(?7iieua*Ado(=)Qntu1x2P#1#dlyf1g1FrsutcMPRJ +8@Pp00U{pA*S3YjVlU#nE$n<7yBo}BnjUb6cl8JYr7I47u@J~-^GbU$yuc@o8c;4Q=1IZT`0T5$c);< +`Urg{80XzvJUBG{I**fZTuhla~|(l)xRSN%A=zvwP5w!52rcFmtIev;{Oci(;;~5$@03@>YB;=#Q(_%U;J{Zng(jl +Ei#wpTx60{BuTOLd~f6~6+61g6xyw>lE6u?h5+U_DoVCzLx)RaJGJ|*)EJg3E2o(4MIoDggCtwjD +`--eWkJ2>SX+9nBXGokcAkN6_cowRYwpWj%xWhNG9(_P#-@yCFJfUqq^XGd9X!$Xz4WvWsJGeqa&WEw +c`Jze=WDA8p6~!5or7ARwQQZ(PfivAOS*sGq!2}wKtTy`$lL_zp}AO0(zD@UI2z6t9ta1w;&=u-(>2& +VMC=}Zc^!5hb$Nc%nRua=vvg3rr&v`!;ZlTj8k`UfTQCEWG0l#se8Awe1`>_vNy(%;@P>oZrQnTi3q%Q)GvzMJG3c-KU7-M8M6a+=j7|ZSbbig@BL-sVxw&$RRueP%#R;mW +b5BMzXfgG~%#G4$gx_HRt|K6NsTX+@`O4vVLia34B&t|hjb?I(D2WOS?Vg1Fl5v{CI>o7E_W}-ed5M- +}o(>zn_*{9rc@@Q1jY>FQR6daI9cstNu*iPIw7zCo?0TZ;%Ach({s +(rQiLUafy`jj!s@=Z?}W4*xysI7JE3tG(xuG{(~@4r;!6v@Ez8Lb~*Omc>L@2S`S*Whtp=*GEcOhgSzI@$#?K5 +*!W$3rZC&nLQkt6{|&qjqR4$;2A0L)?0pgaKeZ8_q@<&IR^ki5&&W#uw!#iXKw8hN^z8QzSq6lPy1JY +FfKGqt@k>hg8EW**>IhVv4HR`7T3in6q0n>0|G-KhQEopvyN3bw4y4RiJF%MS9q?~hdQQLS6wuZ!gCM +dD;CuNk<$fvFN!W58uQvn19Rh8s&!cwrhs}*s_NeyQRFIK6XtU4wrb>!{b!qk>c+F@|Jb~;K9(3PU&m +As0nr&Rj>f~P!Gz6;#&e+TN(SDA32If)Q0;VxeyFx~S*wl-bRC`hJC;C@Ymbd*KwX&< +JDSqfs3!H4iSiN9dTXtX!E4wTWaO&MRFq+JZEAxqw59dqtDj24RW-Y8IiDm5Y$V%z?XXgE&}>vnhHU5 +uWgj5fv@HAZdJ2vCWR$8a1DgK9b=^K@{fY>N-OPa0Yao(5tm|ZXi)gSQ!Un8LA^CLrFi#HV(`3ba?KV +Ni~(Pgf!s!2(R{5I0{@w&M}>!`&V9a&LEZPA2`xCnP?cT89ivhU^?jF2T_X+i{mkb!|t%$(cOCS^U*)Hc3_(B0O_~y{43??Zbe^^5CGX|w1)+DJBLli*Gu}#aWbT_+8hy +kUh(ER(X2hl;mSQmv|3vRG@ebx(>5T13Ve36v`I>hp(@?DU$h+$d>${5aTk+VFg#uk5pL{I~K-6t~Su +A!S)Wc{#)NqTQtw|nB$9XWd-yPwgz;cG +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 +yD7z=~bktB_D|z}f+1|dfToOD@f@6aWAK2ml +EMt4LrT2aRR{000F8000;O003}la4%nWWo~3|axZdaEp}yYWN&gVaCvo7!EVAZ488X&Ja$Mr(Ms%y#A +ob;P!zx|9wDugfI!;E&q>o#X)K4-ao%&DpG%j%a{)SE&=C2@6!z4QVQaemgg9LTnW8wML)b92?sYiTd +hg>`j^`set1l>(YRI5cWmyzQY~bp3i?Uo7z^iv;gIs*Y+!`EKH?+sj)%^pu2ZP%F42j*MOn6pTh&CI_jnFPgzyLDmOtj9^L?c+d38#Y{Ff+=JMalUt +)3Y*`#}9XBaHYHZtzY}1KZxBqlnuu)3ISSi?SKqwXUC?y6eUmLVCPENb_R>Io`KA%@6aWAK2mlEMt4JYxyb8|=008tR000*N003}la4%nWWo~3|a +xZdabaHuVZf7oVdA(ZyZ{xNV|J{ED;ruW;=Tn?qiw@abG`maDU~MuaX>Y~tAkY#O^G=pDl8R$={onT< +sV}5N*|{Q{akyCI-!H?QHpugERkkspdIVPbN01pmm7JZ8CM38``zBx=p +bMM-1$Q_sxFFHA^Y +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 +LzpA)U^`M>htPJf?%sVLtgUP)h>@6 +aWAK2mlEMt4NtxO#x8m(=qj)%1fNI +bCan%RcxivJ%63#H0|YiI1~w4tSOQuNZWdE?tj1C1wewIlI3_Z5drKj7K_DV@xkSAIQ*OiVUPt}uvMJ +0#}uBUJLbhcXW81#Sei$I#Sy#SW^4F4W^UxOx5MFZFnGmI<7Ar#ck7H<-iRH)e)IZ!`0oeyfk%J3n*g +wQ##0eTgI553&C^XFL;z$0!PY$G%PqT0-6-S!n5|OIS-fK2+D-5Hm}N0kwPgw6vv`@gL4>Tiz+AEgas +ZpHfslw-*`u3s;>;By_5v3uGC%h64UaN6!x~pX2yL{oHD|+x!WfRI8lSr%&;xd{R2Q}-0*hLZ)Phhz3-cJt}#^9=)t`Q-BE4|esDO)mdn|Cn8#joI|~>-luCU{`Y>HoLe!pG +_fmc6oaK`D}LiEBgSLmsdCJe0DLr0kSt&RHY&|n=X*t#dLo936hf!v-8=_A7dc(advZwbU$9r*@RtB< +~Os`&*zglyZ$`CzFJIyg)<<0IlKHghkB+L)5{wh>V-Tu{S6Xq@o92?j+Ftm$!BQ89JyqtSJ!{cXTN^B +VV|zf&!&+1VG7($KAcZwWze+K^U3UD%+4kklV7KVbp>>=g$O8lWM4i_F%#>Yz`xU*+0`XB=Je|FW)8_ +Qv~_+{Ab*)Hreijl&lV__kMpYwY$gg8aIT09U|voo9Ta-CZvX_-pBK}DBs-f<&Vkwjk!3?QxIGxGQcz +jPS>;)ta>rr81~maRPQ)P$4@ag41C_tZBQJ~NQ0R;REKMaN24!@!IMr#fEsC;j!o^_VIBpJuIEC6y*s +x)=*iTE9^-Dt5&yLf(pND|<0Og>g67P-u=C%DA>>;%0v*4>dBvT47TTpQ~6`Uz}811VSm_1Z<)Ny-o{ +l_to0M2YpGKg03*Vnh~`I_qx|BPMI +vDTaiY`LTSwrdla0zMNjmIzD<(q^92X_{=kpZ|yO4u6TQJ?Mj27v?l*5>enBui4&eCla7Km8CTcc5t$ +)7x)Wb6vGa|N@?G)?2Qit-91^gT$ZlDShxO*u(gKfc?G){k#%w0s`2-(digEL`7rz<03F!i>?2P!a*3 +XX4l03iMK>EyD4PZV`fFhV38~G7<2riAw|IfIEuA&w1@RRNZ-F1F7S6f*uk9HZURu)+sTbgLWlAx|%wQM)Fw@37Xd?cYcz3)+1G+F869W +@msyxhapdkqdl5zOJt&u?*EzxxW-vs`-I2c)e7lJ1SJ{Qax%z +cOhU!a?-M2%o~;A?{Us!>V#CVnV@e3V&`zyxF0##5+S2NjT($uSzy7#)*Ld4Mg$BLQqfYSfof21N&6f +Jf$ENPQ|hb4M>ir-a~hNBr^RGL9;pn!~hd8AK)PBfFqdZG<_u7oMhJu&1j3w%*&Nby$hpXysIl;* +U&2amVTt{!Cd8&p +xp)q_Xe)&)moBvasM=1XE142FPZTj(=DPt$;wxq%#G4U)*~ZFQg(gY}tX4b_0gn|#0=tcOOQ%rBDdgR +!Xi;PHqx_l`+NjlKSHiBN6SW$$#62O!<%ZXnGELA5Y~e@$!9f~l)&HbawEuzMSswpQ&DPQf;dN&~`xq0p0YI3Rvb9>Ep*Iri#hM77Y#c;_b_Brh +#g)DCJ8VgbreawyAcY)VDV&(Er`K;^AG1{u`9KoU!`OvKSR`k9$S!f~VFQ(;D@@eT6EC)=>>uP7P#D7 +08VK6RQtKkxO^|SJiw1QKT4;q5tJCB0)S#*`_fh;9sbVxE|NcLtHgqtJZlleiG5Z@V97Ciej0(~0>3l +SO1x$;sR_gp(vif&6w1@D&#)ZWr)@U$<&RT^b4ceSTpI1{^2li`w9b?j_y5)o6WwyInZB+mUcXf`DaN +RTA)!2p$+1>iGtr}Ljb^qYo7Kbi%1HqXbWmIepX+%h62Y(*aC5khKmFU54iqvpc5aszTp?yvd*4H>9T +xb}QX>F^qWO!yHD_*NKp~_`2gkQGE=<`4X5$*(f+@e8Y_@T9RsU!>kw>e==YfgZUlyc$2lw-E7t!kLm +#8MeKfp8MnyLaz;dO({_{F_S}S(<~|2XcQC!F}D;v_?@R?Q)&|1;990d#qrA*D>wjf;7P(rYUaUiiCS +i8f`&lu|QVl@vZ%i0hb&t?Yke0#jnYrYUJ7#g?HM~Yvj7&$OYHUP)Y>FO_g4G%;Nbw{fFa? +VuDJBZ;o6PobhkDK*03(OlU@J$RFo#{MmB2Jo6LFq;%894eAUT1i$Q4E2VBi!p;7R)&s(o8yswIVxEFstY*rB(eownH(y5p5Z5aa(v&S>0ZP@EVf;1-RueTyGuc5qh>|>$OAf`GT!EO3rtYItz{!VBYEWO)LJn+6 +7tY2_`o*LG@TY?WXrK4!BTy{L|7C7oKK7$8+wse%Wfy+xMt6LN44(=h`Orm*8xvRyNjbQ}U^!H4ns;Q +@sep(Rem8YL_u9x7=LTJvVjc1)=dV1@2t5g4kxl)I`S>7*QyWSh7@q}C&jOD$&V2UbFyyqY*vT +q_pM#^TQT6klsFFX8T&DIwfBJ@s@ULLUs89g-(@ny`t#B^e-(xmvkY>`Pt#(){e3&h5gZ1KQ72G9w;6 +s+~l+xES-HwoKQ?5jwY#n@VgY%ag=Pr_?8=mnOer(KY19$v3X^f&_-gNWeH4j@qNJmb +W_P7CU7)D0*mdkcn1X`g@KPXWYK}T2^~Bm%M>nVPJ_9N72pwBmGDg^hX@kT4nlZ +}S0(hJl0$?DsErW9e!982Uhwomc3U%txm!&2l1<5`vn +7!xYT>zs==3m*9yz5P-A8>@ +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 +X)?_Z;v6+*a +&Lf5@H!aq*E+ORsdeHnT!>M++%PiTnCVkjuEou6W1P*jENUycAY89;AjQI2Z@bPY7J%xq0YV{-;J?KY +|WE4+cgWS|L8nN7)pff3}tSiISqzZ8m~8gWI=l+qFPc#Kkd$t@vzje!r2rgqKRL7$_$k=!jsf`+`T%tEroj#6@$S2wD`G%nUG7m5a; +G=Fa{={2Vu_Nct6E*XeKK;&_*qlbeX7^5`t1uVq`mBq208a+81eFXmmnepWBsHAcS3O1&%rgO6=H8+5 +B**e}Tn<5;H|Zo$Me856JB_bP;k_G`9?N6uCc?u16O2;`>#X1|;{HjwZ2vN!TwhP;&SbHe{d$QXd0Es +i5#))FpC@WT`h0yhxtT5ux$81XsOJ{9hc!Z~DwYg?lB#bqq?W +U^Lc_qpbYy1X6LdHEL*)gA;@S@8tHU*NWn5OE7QUSd>7UA&kvgz{jLW(tbR+sU!+Ua>mU!i!1wMCCeZ +abI+BWbrm5uUs1KLhoL78&Z$gNt4gANV +up#tHB;CfYLm~sSZ+(B2&ILa0iPK^5vkUIB~ZzR+{;#h%lCicp9E8)RE&*f#V!ieJz6 +C*V{tiE*C-9`;{JUtAh*oG^u)Eh%P;1q9s0f!84l40%Oz^0s$Op864GpsRrC5%*rWWwSXE5Bl}beS6+ +e|D^ZgUVX`1x(I#yGq>9LoBRR)<)_2Mv-PV0oS?G6SXbX)cAOdqJe#%2raLD9CAL6GNRK!~WP65V9E| +z>m?6aBo7Qg7BV<@jd0d>%Eyh)VMhH3RXc3Lr^&+kIDuY+wLE1saJ%7h^W`Vt84Oj6MCBnh|r!yrZD< +R{e(1E57fTX4sf4YMOv(UEjh>laPh9LnEtQLa47UwV3hd2WP6!FNSI1MaitvZ(Sm1lGS3fZ(~g;99ED +k?t!k6>DS<*?0Z4bUSn`+r>a%gEh6m}KN_3UvJRw+B_&X1v#zV1VX4(OK#WG!Y>KY=DE)6Pt0K8y8H~ye1 +DvuBX=wL*t{G;q$v6-Vg>M6H;-75yKSA2+!>8k3X_s=A7d6_!Dvmux04(sBJ_L6!~%>*M#G3xlz+D>N +2~CC&H+N(JF$cmhT9lr41swoXfXNDqfPtfWHy8> +IpXjT5B{u5m|E}L~ek*WiIbMma)9mcL{l=RGbyGSm~#P=EW71SNH5gWp>;2*OxiL7U_(ODAb4Bd7% +!1oQPsgC52!4c~G~8=yy0k*O`dfM;4oWdZ +3@x254ng~_0RQEdUXhpl^2#qD4Dp@wpJ)FyE{V>UbNYfzoV%cU)X8l8^cdki9sEIvm6h$Lfv4TCI7O^ +yuTxUmP73T;wk-zqLFj|Tq_P)h>@6aWAK2mlEMt4K}&E>Nxj001rl001Na003}la4%nWWo~3|axY_HV +`yb#Z*FvQZ)`7LUukY>bYEXCaCvQyK@Ng25JmSr#iSc{jc(kTXow3FJp-B40UDswOpCUpM-mGLtsg8_lcxNOGc?Db_6Z~Vf;YiO{s+kuyfb0-N~hv#{@n520RWFL%tA=(psyk!ZBm +1DmX)N6KCOp64@6S(SWXB2xW3{?1gP-SehBXNn(x`;b4wOb1Ibj4KR~n2qfF8|7TVMUl`K9ab3_Vu7C +9VZjRL#P)h>@6aWAK2mlEMt4P{ZMx_G+001Zj001BW003}la4%nWWo~3|axY_HV`yb#Z*FvQZ)`7LV{ +K$EaCwDOO>dkq5WVv&T;+g~R?dAT4rx*~hjdjno3mxXELa8GwN0Y-*LVEIvQ5%(0nfaddGnr;v(eU*N +e_cmLsgxuo{5o$@n=xIwnQd?gyG^;9l=`4SJe2Y~+4)bz&V2(OEJkmVB3RMo5T +o-!FqyVK;yrx&@j$#L-u@5>7a-h(m +;e%ziJCaV~W$k{@FL6TNL;4t(r~ftYdG2@7JS!T#v@*CC|APKgHV7sMuOJ8Q(?UJj(>hp%qd8Y%2F^* +_lA7~i9OreajV?CTUrc!Jc4cm4Z*nhVVPj}zV{d +MBa&K%eVPs)&bY*fbaCx0qQETKj5PsiZAug2lhQ%aYp91^P=5p6UDF@x-9!fD6iWf>reh!= +a!eMwpvLEx;J{{VP8Nl>c~~*L +ME%O8sJIpNb8!ZHQk7vlX252DuD^cUueWUd>FKwp{nPrd&C0n{aAZXxYGI2aGa!#!=HlM{u4>pilhNf +~7GU|a+3j_kGzp0)T<}X!2DHF%{{_8dae`J0V@0`SaLe_x*|OW)mrIud2i#)nvP8V`IH_h2M1I%J>y9Y?#qta*OuBw?;h>qGhGGPM%{DY}QVb;#g7ltFIzw=P0Q +MGZ77hp9&MNr +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 +=+`Yp;v^FV`;wSUIMF*IuO~2Uw +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 +ZFL16HuQl*u$KY8QYnEa8fFt}ib%K +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 +{qP(YrYI#hD-DTkQ)8W;64TY@N(S- +Jks~FFSu&F&(Z=KcIb3WSpak-@v1!oI9aM)UI;b8w~{h~wH@^sZ~I_qn*N96CMF< +4=$q85elqr--4{Cr@ycg|VucnO^-%C%-KI~v)86uKzGGLqz3{sC-= +{25b_^(VCt2`|-<&T=ptMI{__NnM#*NKoOwk}y5o0=vU +?L!wP~N2_Q!*Vb0M9uO*$42g!Q=74ooWZhvWWs7P@YCa@OC_p7Tx28mBsxoixvAyMtFc;f^dUIILE?KH#`oMX__>)SLLW^D(aN%T3 +!qUY0@~0dzwX(yJTqIn>@}%L2tVE|xnnN}Pqaq8~1^h13XBn^=TmkoCOrdDr!VNB-8&=-~wgt%Fyeaq +r1M$-k`t!5#9aqU}Jgg&6Uj)U;JDs?PEtnIqJ@WxS;7>$?aIzm~X@XhjoozUC~ +T_MkbG-@N|nSfX5zXZ%)t^c)6l!E4Rx5{7h}>h=Yi?V1F^cdTG{ir;8un{LroLLzp0s@c#$M6)a3+4Y +M9~3JHeolrUc@Qi%&xr%fNuNsUukFVf~`(OC6ap0+&Lsk+E#%E$5Pj@iSlLBc9k;KW&F*|`J<>)=07O +9KQH0000800{-FNPX7WFNFdC0DT7l03rYY0B~t=FJE?LZe(wAFJo_PZ*pO6VJ~TJX>@5}Y-w|4E^v8` +R85c5Fc7`xSB%udrmGOa773|RB%lH8Z~#=f&7?Irb~Sb=`S;k)my_+%ja1d18IRwadE*(Z)UhV +MKN$F|Br^vl;S(bxu!ftLuNEo1zyAP>7c0RkjUf0ArN#s_B7C`Bt?%yFP;h~1#LJ^11I*xGZo1u0NxL +_KZ##>wbrcn(N=TX1+^7?miyLUY@2u%TeRHNLij)Pm(I!`wfeh}H#NLENohdpIJ03BgL*EB{d=LdOu)E735;74Mg|2*An0|IZmb8Zq_o+ +LoK3S^XMfaIVu*LG^lj|vo|lmGpd~!S1v#?Ehko>NT~6yGWMw?!HLiLBK||b``2?^d^gqCXx}OBx#5_ +858L*9e2H>!T!GMJt49o+%#%Ptl=h`5}{Rqq_A>F(>akaydqaTl9Rzr1!vwFUHLG(01Tkt0nG_@P4Gk +QC`bZNURnPht;4|b=7B381EbHvwnPILm-*S}A0)lqPCrY`R>R1Y9(jw1KFf=gSfz}1zcf6yisnh5rCk +;Vtkq#Y!&_iP)h>@6aWAK2mlEMt4IxPqCHFn008wA0012T003}la4%nWWo~3|axY_VY;SU5ZDB8 +WX>KzzE^v9xS8Z>jI1v7xUtzV{3+z$Umv7beLz<@7oU%z3vT9eW6(TSc?g)--(=>Ow|9)dkz$8F7TAj +40ipuzz@jS!KczjBRbiHNqOC-o2H1$N}rR2e5B7rTrJioZI$*prouFi&umvJCT>_f`IA{>BB#>3In8I +dqf!0z=JFv$&gmUXUAdc5xwJM)P_8a@p$%w!Q8VeB#xbGC_Q?T?5Z0xe@igTb;y9= +CSRBdI7K;gqs9Og4SwFhElVi;`DAZG26_cvf2*bd{_%&41{3uG7~Ujj53Rib6oynUf1`V(iC!u17b*1 +P7_$bnk5tf-a2*#aM6H(gu`{4x63Vq&?1gtnJ+SivG-j+^3cu4*?77b&3n1#a5kI +Yq3sHW+otrvXdb8>RRb1{iiY1XHy9oZ-?5pEbm5^x +u{{aOm;W1IvVC;l5wEajJJ9V`zs#wmNGJQgucTY;N0vK%obxu+&9XZYa*y3v8or@>_6&PT&VgAbp)w| +JGUlvazs-^%|22#M=rNL<(8kc3*O +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 +`E(OG*hf@6aWAK2 +mlEMt4P93*vL;R007jU0012T003}la4%nWWo~3|axY|Qb98KJVlQKFZE#_9E^vA6eOr6mII`e-eg%#^ +IiehyiJh6gct^dHcARPNq~n~}>Fzy_M@@^A%{E1Ei=H%PA=cjMZ@JDF(U|tprF`dqvx+#ps>qr;nez7s59IGfr9PF3`c$ndcs|Gf! +@H!c0KaLP&x?yEm*UUj>7fuiVp&Gli|A0~1)vO#9tilOvCjbi{QBfnjjX;?i;BKX>(w%;#6(<0Rl+}w +>2aOBujwH?Yw{F`pR|CvlZ5&r&60C$7*%2ppQ#tMl?|rTt0v9rGzS_$8~LR&n;aGSJiUG$l@*e{LfXU +kC4Fy)4X0Pp?3Owx`rI^inzb#OGNVrFx%xiFPfgx_{-sEB`cN&iw3d(Qvjr?muN0^NzLp74D7ga=zcx +ipGoI2X0FbK>Wp=QP>YF@TBuq-*pK5Uc&Br+X8ieb-D9&zRtmCpQ%CR7HkeH(f1Sd}BJZ?~p4mD(uj< +f=D)=@r#Cu7AeqY1Ga_*jKWj*o&Nt)byaw}6Q?WiBTJ;2)UGVLBYRggMZZd6WFwfbh`Vi)8|Yef#F6o +WytM=dbx=FDh_#@}CW>VIbSv%b7Z;~?&%%9#bvT`|de*BLnX-F)7#SbmTzB~W^rIc>jvi-##XOV#*r1c7z%`2pg?qSIXVy69O;|_&O`DqO8TpTmUVaX+?P2vfrvz^H+0jWfIPc#S*kvF#LNE?vDO~|N +LPn7Xtn*qIz}%O?9`Be&P9>$k5M+YDv+z@VYFTWw1XM2c0#C=#B+KdGH_s!f(*&X|atqhp$~V4nO+fo +I{h7(;v%dxzu9QRpLIa-O6BRpg4KZ#{19age>7A(YUu~9D>9Aq3K5vH9*lF=VvN%wdOy7fshLG(Tut_E1Mxj}0Cgpz*$iYFKY +?5R=EaftV*lVzuvkGj(G&k7{A2jE)xanhhmRi<6~i>C=V4J^KSnk@F6T4sx+8n;aZh7NV-htg$csx;W +#>gL;@mbGQa!(DjUjh0sehb@q*8)mXt+^XGOrf}YKbygL@5Ol6Yc{Z`&fS3KfG)KY9c{H)g5e6lLP48 +p=DUwrr+4~wGrPDCshSr$j<(kUw`%FP(*h{8Ux4j6s95yu-!>{2jP&oS^=8#rb7BJ3dcI`K$sNe0*sR +$SD<~EqZDBvB*ZgOH6;kr3W371jIOe@x=CUO&a%kT*-Au<0>(Kzdx>J4Q7}dERt4swxQECrtx*cDz!7 +l(lu1+-IY3Xu?4|(BLz_`i3TCAv!X2$P0Koyem)+56S*a1V50I +tmv*3bfGBxVpdiTlSz3)B^?9yUq&0zt}P`eiT@lZjqH>Nj)bp7jA=n=8;R3$Y&>t2P5FuRG$XomU<-KG*404h;r(N&U(0$P?V +J=h0@dJmC)6WYkar=ip1H?h%ybq{<=`uGSWP8L +^590LTL0!RQb@Y$Z^sSE3^dN9XkDGXfZw49?X{Re=E3)FwKnEJ^LUUyQ>qGb{&5|2#MSBXo%QxvWH6d +gqr3(fjmvs^mDW08c{p}4=l-~E))!NFK&5rom@@ZBy(BPb7`g{^fM#W4|)RXi$BC0G!Ar`I`XQekNs- +FIHF{>0^_4aDIV2x2D_320>pEr9Q{A*!t5yKoqy2OS`!5&!g`Lo`U?$zNZczBxX6c6QteLZGn`&LG!D +hNZV1jsP`gYwK;({zFox^A)cCTI^!OT@k=+;{@{#F;W14q4&fC!%tT$-5-p@Suwj!>hLyvR_a#$)hh!7+M`OoRnf$;YUs!50CiiE%UsX{qj-7+f +=Cd}1(@VjiVg15_5LIV@)YR)IJm!t*AlweHZo!cox+3QhXC#sewf*bOpwt%DW}Smyyg=-IV6jvc;<94 +r^=$$qnb34l7=j^a$X`HbKFjJ?&=>?dFmwg7>^51A#T}9bmlt$*asYE|L52pbyklx)1IME!U|vDD_(9z4 +JW{h#iI`WjKm(O42N06NqMY>{=?0IL8)Q+ +MN!*>Yht|!NpENt-r=Nc6I{CO@d1;XC2ajroA`4+6c~l)NTX2g8+JlGKz?uuaZ45TEgGSQgM^Co@EUv +4+9`38Z%~Aj%WT0A&z_H)=t@e&{JJ5^zp>HJ);v%W?p>&SaiUnK!u>rjJa8?*Vh_%*#=)(q<5U{5#z>mcspp_fcS|0T51qJbXl7U{mPsq+Rc|1VdX-DtYh +RGQPK-jlhg0q3v5Hlp;4kZ5AeM9iB`UVBzWgBGu`I)%J{0*$Lnd(4mnbgUm$?6o4;zeDNsgTi_GSpt6 +ln0oC7gSHD0lVb8ySMjId0n~t5AZOYPUCb2O~Ymx1E~YqvO_LTAS!xsB=RYQ*}-N;jLA^tp5IfM89>| +#bTC_hW3T`RWl~h(i|Og##QzcS=_mL!rVQQ$le9Kn|C-(b-6E0}a6Y*InEa4Pync56o%Tefi-r0QVL5 +>n=1{>Ys?^5hHNiz-=v2^FB>)?vC?XQ5GEChMo<<7`6Ss!H@-ssD8Rr=RdufEu{6g_FB4xK?@3w-k8W +!k|0=JLu(z3|OP7DyQ!H6)Mfw2ZrLHd^|gb+D<3P2rD*vQW$Dh)hwL<3J#pmxL&nmKkLBS{UMml`7?& +q@X<$Ee9rxc+~OwY)8V9ni$bCEETnM<@Du_nYX+#J^z1_%lcdw&}Qo_@h@u^x +m5Ro~F)8NJ)q)S&l3ev1|YW+8Q+sn&-iNnm;`l_`J;y-9;VUqPWA?1XlDcF_uQ?5?P8L0D=DK6Lx2!F +b~tb!m#eixb0zJ%@`r6j(<9*MVcV{B1EW=%eW5_1dm~gpY#9^s6QBtJVdic#{v)5v!((vvke75P2Pj= +iZLUx1cpv>EYYaCSsa+ieVSz=!Z;NCSDZ|BLz`%6Icw6I?Jy5FSdygzeUJ=XP_T@lF?vXh(3pdR8n;D +Az0r1Jp%Nvq=EY4U7g{Gepk`fe!VGbts3atiGUl`v%uxL}BIg0AEx8RJebGXiabySZF0H3s|IBSK9uf +TRI4vM=k|>ucNG17|SQQPB3|&g51%};~$kN+HOB&KA0R}<2sIKhxz4!n%@mT_{iBwnf*R +>plp-)9?G>lTNSqW;g+UKb5@su$g5Ah`g)5t-= +aenxxSL#}60`6f-h*F>N1^R>t5jx`z2UlrzcZrP;se1QjZ{rLWMyNUS${LpIyu8LsK;qk|!bBwKFCr` +;4O-)sV>_Fmzq>}=!wyg&jwkbA@UMKWGf+b1h>Y1>Ls8%F4)YKN~^uUA7-u5G2JS01>f1p(LCc}G8kI|aK7|i`m75w?JIoBcBiiPj=#Oe7%$BI2 +f9*)KBFlJhP3K#V6f7+l3OZ&?3<^mK80D1ke60K1d5Vf_DcQ`aZ}Jgucc@4FP0i$4%~i$8h8)IJ!bCZ +o5UatHC7ZjdYLrBNCY1x)PO}5Z(&B1X8M1_raYS@LhA8W^nt5;gPiLeR2*(3Sxh0Sk#s)KC0AHR% +x`JnTKq%MkH9Xb?II`U0#Nr$X=ac2$lY=jwBEjd-pAxVLh9{tUm1?}Yr8r41@h-|zE(EF)WHl*m3pBubg{NIewo-fQ0FCy%S{(Uo$zGLSQ{9%@J~HWy8Hp +CJ)SwS#2$ASsIwo(qvI==>y6six{h-Cw-<=NTM$q8*oVO5~46rhnkz_656 +F+d^Ettdb&r!p>_&^P4`bxu$jM1IQ$uz30&5yHPkg%Ag7`2o_4w4zE}A33_4(CNIDM7fjI-G{IT8-Dm +mDZCH=R(>3YT!|IHqp^n6P0O)1FmwTSaI*C(FFt8qpAZEGbbMeC(3_Q%eS0O*S};W7b}@C_B?%1605* +#!Id;fYa^wO9*UQkC)TW1%GP{43+(dUNm{VZnBAr!M?f|nypv-HhBvTT?WaK6$4x0QnFYen_!==g{+o +NaT&9bk7r$yvYeE2v}Vxl94wNAR9i1@dS9HsMCiF(Vkh-I8B`8TaJ`znuq6I0}X+cBmk=1>!HSr%7Wv +XF4d9L%1tyHX7s#loN_fe +4E=xc^w8c=a_y;&zf?HX)nACu-N#sj=(F9X4J0UT(8J4d9l-!L^|bbWW(7^QpLB>eH`{=>3%dt-hV}W +_k0mz7NPbj|hCAW~mWdSk9adifO8+kYy!Vax_h_;FA0nw|p%cdAp&k`jbMDC(xf@~nb?P+t9hyHpz*F +UVb&#>7jvad7u#Aa9~H|2|rNM*5)x>wDgG1ZYdW1}zIm9kFuHIJ{mavj +zxphsJqHhdihDPn-$tA+cOtZXeV+85N98hG7`Gx#r=})*k2&Q9#JY7H%=hAZ&DO+h(2^EGfIFMcx93F +=S^vS0PZqi#}9-JypX5c&b!?^VWeZ%W2uN`L`lD)8k;dF+Oi3HsVhDOSL9q*+ +5NOfZxc#YV=2!m)DB9zNA^gL8eWeym#R}G +}ItcAgu&$U3f$yz8;4t9kP;B5eAwH%JYE%f+z%oz#ZW{oxuot0?rwkj1F*!s;_Rj|IrGu=+mk@~8wT* +WQuN(7)WxcXzk0ICtF8L(M@zBjOxP`< +>hOSLHfi@|fG6T%DG+eDvPm^mQ$!FKo%K6(NJ}06qhJu@Y2kJQ=^y?cX +CRlsTyCfTn{SzxeR+vTe5`qU=6e!SEn9NmNk<7b4{nMR3&|Egg4oVdAt#AAh6BQ1y2i2vOeJdXtQ*GHUW+(KzpF298E@ +f@RX+q@?b;iK-h__K#Jq1x!wFmbc@kn+jd9LGQ>eG&TEvf842D6^6#S;Pgy8p% +XZbuGe@>SMB9)5TNqD3+@ooYkUoAo%Km8$Dl5UI(!-S}jbz+zfGD7$H!lF=2G}pun7Bwd+?o~3m5-joSIK>g7@tvNFF_)i!*lG6ej +E}AuS4SXdhH@~>yF_|Huu{#$Gi{W6X&!jUzQq +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 +Ty;n`8+AZqd2b<}?743WJ?xDG +>yb;j=E!!I9O`Dab+ExueRcli%@PBLVf9+L^fA-2vMuv9uYSPSro +Mi5et!Jsm2h7A`}g2~zW(#F1i0_N!I<;%(dh8;WBifNYJC&#j{abtYW(r^>MhcrW$4`$r@A-`E3we +%e3$yU^d~Vj(^<>R}@gr+KB&1ViO6b!14|eto%*Rk1(*LU6(3ZjBY<^8m;D1k( +k7hFI$3!gB>l?7MQC_R?D{2iFzkP{bSjIVrEWK*|7y5;EC^6ff&NvelOsD+B^8yv?I0{Rb_cbA$=9sw +Qp6t=r!BPim{a6^lqA0SvL?YnUwwU6Ne{Z<8j-v5}gS2ESdAp6y6Uc-N-FU2GUK>lgsxVwToV8u4^_AdvU#OSl1S32g+-ZvCr)CQ5h%T*C;wv6v+ diff --git a/graphs/static/graphs/js/notifications.js b/graphs/static/graphs/js/notifications.js index ad85a55e..f0f5d70a 100644 --- a/graphs/static/graphs/js/notifications.js +++ b/graphs/static/graphs/js/notifications.js @@ -15,8 +15,10 @@ $(document).ready(function() { if (data.Error) { return alert(data.Error); } - window.location.reload(); + $("#read_notification").load("notifications.html #read_notification") + // window.location.reload(); }); + }); /** diff --git a/graphs/templates/base.html b/graphs/templates/base.html index e8c7d612..ec7488be 100644 --- a/graphs/templates/base.html +++ b/graphs/templates/base.html @@ -111,7 +111,7 @@
  • Help
  • About Us
  • {% if uid != None %} -
  • Notifications
  • +
  • Notifications
  • {% endif %} diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 9bb76d00..401a07f7 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -25,7 +25,7 @@ {% for group in groups_for_user %} - + {% endfor %}
    {{ group.groupId }}{{ group.groupId }}
    @@ -50,7 +50,7 @@

    {{ group }}

    {% if event.is_active == 1 %} {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}. -
    +
    {% endif %} {% endif %} diff --git a/graphs/urls.py b/graphs/urls.py index 28c405bd..0560c5b8 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -23,7 +23,7 @@ url(r'^graphs/upload/$', views.upload_graph_through_ui, name='upload_graph_through_ui'), # notifications page - url(r'^notifications/$', views.notifications, name='notifications'), + url(r'^(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/notifications/$', views.notifications, name='notifications'), url(r'^notifications/(?P.+)/$', views.notifications_group, name='notifications_group'), # view graph page. This contains regular expression to catch url in the form of the following: diff --git a/graphs/views.py b/graphs/views.py index 03f521ca..c7311881 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -311,7 +311,7 @@ def _group_by_id_notifications(events): return events_group -def notifications(request): +def notifications(request, uid): # context of the view to be passed in for rendering context = {} # handle login From 99c3aff054076cd0ff208e84da5558d67fd8d9c6 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Sun, 14 Aug 2016 23:57:52 +0530 Subject: [PATCH 14/23] Update notifications code according to the spec --- graphs/static/graphs/js/notifications.js | 61 +++------ graphs/templates/graphs/notifications.html | 84 ++++++------- graphs/urls.py | 6 +- graphs/util/db.py | 56 ++------- graphs/views.py | 136 ++++++++++----------- 5 files changed, 137 insertions(+), 206 deletions(-) diff --git a/graphs/static/graphs/js/notifications.js b/graphs/static/graphs/js/notifications.js index f0f5d70a..12bab590 100644 --- a/graphs/static/graphs/js/notifications.js +++ b/graphs/static/graphs/js/notifications.js @@ -1,58 +1,37 @@ $(document).ready(function() { - - /** * Mark notification as read through the UI. */ $(".read_notification").click(function (e) { - var uid = $(this).val(); - var nid = $(this).attr('id'); - - $.post('../../../read_notification/', { + // current user_id always present + var uid = $(this).attr('uid'); + // notification id present if clicked on individual notification + var nid = $(this).attr('nid'); + // group id present if clicked on mark read for group notifications + var gid = $(this).attr('gid'); + // allid present if clicked on mark all notification read for a user + var allid = $(this).attr('allid'); + + console.log(uid); + console.log(gid); + console.log(nid); + console.log(allid); + // send a post request to the view read_notification + $.post('../../../javascript/'+uid+'/mark_notifications_as_read/', { 'uid': uid, - 'nid': nid - }, function (data) { - if (data.Error) { - return alert(data.Error); - } - $("#read_notification").load("notifications.html #read_notification") - // window.location.reload(); - }); - - }); - - /** - * Mark all notifications as read in a group - */ - $(".read_all_notifications").click(function (e) { - var uid = $(this).val(); - var nid = $(this).attr('id'); + 'nid': nid, + 'gid': gid, + 'allid': allid - $.post('../../../read_all_notifications/', { - 'uid': uid, - 'nid': nid }, function (data) { if (data.Error) { return alert(data.Error); } window.location.reload(); }); - }); - /** - * Mark all notifications as read in a group - */ - $(".read_all_user_notifications").click(function (e) { - var uid = $(this).val(); - - $.post('../../../read_all_user_notifications/', { - 'uid': uid - }, function (data) { - if (data.Error) { - return alert(data.Error); - } - window.location.reload(); - }); }); + $('[data-toggle="tooltip"]').tooltip(); + }); \ No newline at end of file diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 401a07f7..1f31ba81 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -5,68 +5,62 @@ {% if uid != None %} - -
    - + right: 100px; float: right;" class="btn btn-default read_notification" uid="{{ uid }}" allid="True">Mark all notifications as read

    +
    - -
    Groups
    -
    -

    Notifications

    -
    - - - - {% for group in groups_for_user %} - - - - {% endfor %} -
    {{ group.groupId }}
    -
    +
    + Groups +
    +
    +

    Notifications

    +
    + + {% for group in groups_for_user %} + + + + {% endfor %} +
    {{ group.groupId }}
    +
    - +
    {% if notifications != None %} {% if check_new_notifications == True %} -
    - - {% for group, events in notifications.items %} -{% if events != None %} -
    -
    -

    {{ group }}

    -
    - - {% for event in events %} - {% if event.owner_id != uid %} - {% if event.is_active == 1 %} - - - - + {% if events != None %} +
    +
    {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}.
    + + + + + + {% for event in events %} + {% if event.owner_id != uid %} + {% if event.is_active == 1 %} + + + + + + {% endif %} + {% endif %} + {% endfor %} {% endif %} - {% endif %} - {% endfor %} -
    {{ group }}
    {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}. Shared on {{ event.share_time }}
    - -
    -{% endif %} +
    {% endfor %} -
    - + + {% else %} -

    No new notifications.

    diff --git a/graphs/urls.py b/graphs/urls.py index 0560c5b8..c1537398 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -24,7 +24,7 @@ # notifications page url(r'^(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/notifications/$', views.notifications, name='notifications'), - url(r'^notifications/(?P.+)/$', views.notifications_group, name='notifications_group'), + url(r'^(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/notifications/(?P.+)/$', views.notifications, name='notifications'), # view graph page. This contains regular expression to catch url in the form of the following: # /graphs/email_address/graph_id/ @@ -81,9 +81,7 @@ url(r'^resetPassword/$', views.resetPassword, name='resetPassword'), url(r'^launchTask/$', views.launchTask, name='launchTask'), url(r'^retrieveTaskCode/$', views.retrieveTaskCode, name='retrieveTaskCode'), - url(r'^read_notification/$', views.read_notification, name='read_notification'), - url(r'^read_all_notifications/$', views.read_all_notifications, name='read_all_notifications'), - url(r'^read_all_user_notifications/$', views.read_all_user_notifications, name='read_all_user_notifications'), + url(r'^javascript/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/mark_notifications_as_read/$', views.mark_notifications_as_read, name='mark_notifications_as_read'), #REST API diff --git a/graphs/util/db.py b/graphs/util/db.py index fdef30bf..8150a265 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -4759,7 +4759,7 @@ def add_share_graph_event(graph_id, owner_id, group_id, member_id): db_session.commit() db_session.close() - +# admin function def update_share_graph_event(event_id, active, member_id): ''' Update the share graph event. Change its active state. @@ -4778,7 +4778,7 @@ def update_share_graph_event(event_id, active, member_id): db_session.close() except NoResultFound: db_session.close() - raise NoResultFound + return {'Error': 'No share graph event found.'} # admin function def delete_share_graph_event(event_id, member_id): @@ -4797,7 +4797,7 @@ def delete_share_graph_event(event_id, member_id): db_session.close() except NoResultFound: db_session.close() - raise NoResultFound + return {'Error': 'No share graph event found.'} def get_share_graph_event_by_member_id(member_id): @@ -4810,14 +4810,13 @@ def get_share_graph_event_by_member_id(member_id): ''' # Create database connection db_session = data_connection.new_session() - try: events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).all() db_session.close() return events except NoResultFound: db_session.close() - raise NoResultFound + return {'Error': 'No share graph event found.'} def get_share_graph_event_by_id(event_id, member_id): @@ -4834,8 +4833,7 @@ def get_share_graph_event_by_id(event_id, member_id): return event except NoResultFound: db_session.close() - raise NoResultFound - + return {'Error': 'No share graph event found.'} def get_all_share_graph_event(): ''' @@ -4848,20 +4846,7 @@ def get_all_share_graph_event(): return events except NoResultFound: db_session.close() - raise NoResultFound - - -def set_all_graph_events_inactive_user(member_id): - ''' - ''' - db_session = data_connection.new_session() - try: - db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).update({"is_active": 0}) - db_session.commit() - db_session.close() - except NoResultFound: - db_session.close() - raise NoResultFound + return {'Error': 'No share graph event found.'} def set_share_graph_events_inactive(event_ids, member_id): @@ -4873,42 +4858,23 @@ def set_share_graph_events_inactive(event_ids, member_id): ''' db_session = data_connection.new_session() try: - for event in events_id: + for event_id in event_ids: db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.id == event_id).update({"is_active": 0}) db_session.commit() db_session.close() except NoResultFound: db_session.close() - raise NoResultFound - -def set_share_graph_events_inactive_by_group(group_id, member_id): - ''' - Set all events in a group for a user as inactive - - @param group_id: id of the group - @param member_id: id of the logged in user - ''' - db_session = data_connection.new_session() - try: - - events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.group_id == group_id).all() - for event in events: - event.is_active = 0 - db_session.commit() - db_session.close() - except NoResultFound: - db_session.close() - raise NoResultFound + return {'Error': 'No share graph event found.'} -def get_share_graph_event_by_group_id(member_id, group_id): +def get_share_graph_event_by_member_id_and_group_id(member_id, group_id): db_session = data_connection.new_session() try: events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.group_id == group_id).all() return events except NoResultFound: db_session.close() - raise NoResultFound + return {'Error': 'No share graph event found.'} def check_new_notifications(member_id): @@ -4921,6 +4887,6 @@ def check_new_notifications(member_id): return False except NoResultFound: db_session.close() - raise NoResultFound + return {'Error': 'No share graph event found.'} diff --git a/graphs/views.py b/graphs/views.py index c7311881..8a6f03ca 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -298,99 +298,93 @@ def _graphs_page(request, view_type): return render(request, 'graphs/graphs.html', context) -def _group_by_id_notifications(events): - events_group = {} - for event in events: - if event.group_id in events_group: - events_group[event.group_id].append(event) - else: - events_group[event.group_id] = [event] - for group, events in events_group.items(): - if all(event.is_active == 0 for event in events): - events_group[group] = None - return events_group - - -def notifications(request, uid): - # context of the view to be passed in for rendering - context = {} - # handle login - context = login(request) - # Checks to see if a user is currently logged on - uid = request.session['uid'] - events = db.get_share_graph_event_by_member_id(context['uid']) - context['groups_for_user'] = db.groups_for_user(context['uid']) - context['notifications'] = _group_by_id_notifications(events) - context['check_new_notifications'] = db.check_new_notifications(context['uid']) - print context - if uid is None: - context['Error'] = "Please log in to view notifications." - return render(request, 'graphs/error.html', context) - return render(request, 'graphs/notifications.html', context) +# Controller method for notifications. +def get_notifications_for_user(user_id, group_id=None): + if group_id == None: + events = db.get_share_graph_event_by_member_id(user_id) + events_group = {} + for event in events: + if event.group_id in events_group: + events_group[event.group_id].append(event) + else: + events_group[event.group_id] = [event] + for group, events in events_group.items(): + if all(event.is_active == 0 for event in events): + events_group[group] = None + return events_group + else: + events = db.get_share_graph_event_by_member_id_and_group_id(user_id, group_id) + return events -def notifications_group(request, groupname): - # context of the view to be passed in for rendering - context = {} +# View method for notifications +def notifications(request, uid, groupname=None): # handle login context = login(request) # Checks to see if a user is currently logged on uid = request.session['uid'] - context['groups_for_user'] = db.groups_for_user(context['uid']) - context['all_events_in_group'] = db.get_share_graph_event_by_group_id(uid, groupname) - context['notifications'] = None - context['group'] = groupname + if groupname == None: + context['groups_for_user'] = db.groups_for_user(uid) + context['notifications'] = get_notifications_for_user(uid) + context['check_new_notifications'] = db.check_new_notifications(uid) + if groupname != None: + context['groups_for_user'] = db.groups_for_user(uid) + context['all_events_in_group'] = get_notifications_for_user(uid, groupname) + context['notifications'] = None + context['group'] = groupname + + # Better way of handling errors? As we don't have any GraphSpace + # specific excpections I am just using the generic SQLalchemy ORM + # generic exception NoResultFound. + for i in context: + try: + if 'Error' in i: + context['Error'] = i['Error'] + return render(request, 'graphs/error.html', context) + except: + pass if uid is None: context['Error'] = "Please log in to view notifications." return render(request, 'graphs/error.html', context) return render(request, 'graphs/notifications.html', context) -def read_notification(request): +def mark_notifications_as_read(request, uid): + ''' + View for marking a notification as read. + Fetch gid, nid, allid and uid for a request and + update the database accordingly. + ''' if request.method == 'POST': - nid = request.POST['nid'] - uid = request.session.get('uid') - + nid = request.POST['nid'] if 'nid' in request.POST else None + allid = request.POST['allid'] if 'allid' in request.POST else None + gid = request.POST['gid'] if 'gid' in request.POST else None + uid = request.session.get('uid', None) # Check if the user is authenticated if uid == None: return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") + + # If gid and allid are None then the user clicked on mark as read + # for an individual notification. + if gid is None and allid is None: + events = [nid] - event_info = db.get_share_graph_event_by_id(nid, uid) - if event_info == None: - return HttpResponse(json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - db.update_share_graph_event(nid, 0, uid) - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share event " + nid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - - -def read_all_user_notifications(request): - if request.method == 'POST': - uid = request.session.get('uid') - # Check if the user is authenticated - if uid == None: - return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") - event_info = db.set_all_graph_events_inactive_user(uid) - if event_info != None: - return HttpResponse(json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share events " + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + # if gid is not None then the user clicked on mark as read for + # all the notifications in a group + elif gid is not None: + events = [event.id for event in db.get_share_graph_event_by_member_id_and_group_id(uid, gid)] + # if allid is not None then the user clicked on mark all notifications + # as read for a user. + elif allid is not None: + events = [event.id for event in db.get_share_graph_event_by_member_id(uid)] -def read_all_notifications(request): - if request.method == 'POST': - nid = request.POST['nid'] - uid = request.session.get('uid') - - # Check if the user is authenticated - if uid == None: - return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") + event_info = db.set_share_graph_events_inactive(events, uid) - event_info = db.set_share_graph_events_inactive_by_group(nid, uid) - if event_info != None: + if event_info is not None: return HttpResponse(json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), content_type="application/json") else: - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share events " + nid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - + return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share event(s) owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") def upload_graph_through_ui(request): From 80124a4c1ccf7cb206377c90a71bd2826360cdc9 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Mon, 15 Aug 2016 00:52:49 +0530 Subject: [PATCH 15/23] update UI for notifications --- graphs/static/graphs/css/notifications.css | 3 ++ graphs/static/graphs/js/notifications.js | 8 +--- graphs/templates/graphs/notifications.html | 53 ++++++++++++---------- 3 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 graphs/static/graphs/css/notifications.css diff --git a/graphs/static/graphs/css/notifications.css b/graphs/static/graphs/css/notifications.css new file mode 100644 index 00000000..e418b71b --- /dev/null +++ b/graphs/static/graphs/css/notifications.css @@ -0,0 +1,3 @@ +.notification_read { + background-color: #bdbdbd; +} \ No newline at end of file diff --git a/graphs/static/graphs/js/notifications.js b/graphs/static/graphs/js/notifications.js index 12bab590..b91737bd 100644 --- a/graphs/static/graphs/js/notifications.js +++ b/graphs/static/graphs/js/notifications.js @@ -12,10 +12,6 @@ $(document).ready(function() { // allid present if clicked on mark all notification read for a user var allid = $(this).attr('allid'); - console.log(uid); - console.log(gid); - console.log(nid); - console.log(allid); // send a post request to the view read_notification $.post('../../../javascript/'+uid+'/mark_notifications_as_read/', { 'uid': uid, @@ -28,10 +24,10 @@ $(document).ready(function() { return alert(data.Error); } window.location.reload(); + // $('.remove_read'+nid).remove(); + // $('.notification_event'+nid).addClass('notification_read'); }); - }); - $('[data-toggle="tooltip"]').tooltip(); }); \ No newline at end of file diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 1f31ba81..6b190686 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -1,6 +1,7 @@ {% extends 'base.html' %} {% block content %} {% load staticfiles %} + {% if uid != None %} @@ -29,38 +30,40 @@
    -
    -{% if notifications != None %} -{% if check_new_notifications == True %} -{% for group, events in notifications.items %} + {% if notifications != None %} + {% if check_new_notifications == True %} +
    +
    + {% for group, events in notifications.items %} {% if events != None %} -
    - - - - - - - {% for event in events %} - {% if event.owner_id != uid %} - {% if event.is_active == 1 %} - - - - - + +
    {{ group }}
    {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}. Shared on {{ event.share_time }}
    + + + + + + {% for event in events %} + {% if event.owner_id != uid %} + {% if event.is_active == 1 %} + + + + + + {% endif %} {% endif %} - {% endif %} - {% endfor %} - {% endif %} -
    {{ group }}
    {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}. Shared on {{ event.share_time }}
    -
    + {% endfor %} + {% endif %} + + {% endfor %} - +
    {% else %} +

    No new notifications.

    From 7d5a2c9f07eee658ee5b81c8db72e5ac68222184 Mon Sep 17 00:00:00 2001 From: Aditya Bharadwaj Date: Tue, 16 Aug 2016 11:59:43 -0400 Subject: [PATCH 16/23] removing unnecessary packages --- GraphSpace.egg-info/PKG-INFO | 96 - GraphSpace.egg-info/SOURCES.txt | 28 - GraphSpace.egg-info/dependency_links.txt | 1 - GraphSpace.egg-info/not-zip-safe | 1 - GraphSpace.egg-info/requires.txt | 19 - GraphSpace.egg-info/top_level.txt | 2 - build/lib/graphs/__init__.py | 0 build/lib/graphs/auth/AuthBackend.py | 22 - build/lib/graphs/auth/__init__.py | 0 build/lib/graphs/auth/login.py | 61 - build/lib/graphs/forms.py | 84 - build/lib/graphs/models.py | 363 -- build/lib/graphs/urls.py | 209 - build/lib/graphs/util/__init__.py | 0 build/lib/graphs/util/db.py | 4733 --------------------- build/lib/graphs/util/db_conn.py | 53 - build/lib/graphs/util/db_init.py | 19 - build/lib/graphs/util/json_validator.py | 390 -- build/lib/graphs/util/paginator.py | 72 - build/lib/graphs/views.py | 2462 ----------- build/lib/graphspace/__init__.py | 0 build/lib/graphspace/settings/__init__.py | 0 build/lib/graphspace/settings/base.py | 105 - build/lib/graphspace/settings/local.py | 32 - build/lib/graphspace/settings/testing.py | 32 - build/lib/graphspace/urls.py | 16 - build/lib/graphspace/wsgi.py | 14 - dist/GraphSpace-1.1.0-py2.7.egg | Bin 150522 -> 0 bytes 28 files changed, 8814 deletions(-) delete mode 100644 GraphSpace.egg-info/PKG-INFO delete mode 100644 GraphSpace.egg-info/SOURCES.txt delete mode 100644 GraphSpace.egg-info/dependency_links.txt delete mode 100644 GraphSpace.egg-info/not-zip-safe delete mode 100644 GraphSpace.egg-info/requires.txt delete mode 100644 GraphSpace.egg-info/top_level.txt delete mode 100644 build/lib/graphs/__init__.py delete mode 100644 build/lib/graphs/auth/AuthBackend.py delete mode 100644 build/lib/graphs/auth/__init__.py delete mode 100644 build/lib/graphs/auth/login.py delete mode 100644 build/lib/graphs/forms.py delete mode 100644 build/lib/graphs/models.py delete mode 100644 build/lib/graphs/urls.py delete mode 100644 build/lib/graphs/util/__init__.py delete mode 100644 build/lib/graphs/util/db.py delete mode 100644 build/lib/graphs/util/db_conn.py delete mode 100644 build/lib/graphs/util/db_init.py delete mode 100644 build/lib/graphs/util/json_validator.py delete mode 100644 build/lib/graphs/util/paginator.py delete mode 100644 build/lib/graphs/views.py delete mode 100644 build/lib/graphspace/__init__.py delete mode 100644 build/lib/graphspace/settings/__init__.py delete mode 100644 build/lib/graphspace/settings/base.py delete mode 100644 build/lib/graphspace/settings/local.py delete mode 100644 build/lib/graphspace/settings/testing.py delete mode 100644 build/lib/graphspace/urls.py delete mode 100644 build/lib/graphspace/wsgi.py delete mode 100644 dist/GraphSpace-1.1.0-py2.7.egg diff --git a/GraphSpace.egg-info/PKG-INFO b/GraphSpace.egg-info/PKG-INFO deleted file mode 100644 index 1b070b33..00000000 --- a/GraphSpace.egg-info/PKG-INFO +++ /dev/null @@ -1,96 +0,0 @@ -Metadata-Version: 1.1 -Name: GraphSpace -Version: 1.1.0 -Summary: The interactive graph sharing website. -Home-page: http://graphspace.org -Author: adb -Author-email: adb@vt.edu -License: GNU GENERAL PUBLIC LICENSE -Description: GraphSpace - ================ - - GraphSpace is running at http://graphspace.org - - GraphSpace has three dummy users: - - 1. Username: user1@example.com Password: user1 - 2. Username: user2@example.com Password: user2 - 3. Username: user3@example.com Password: user3 - - Requirements - =================================== - 1. Python v2.7.10 - 2. sqlite3 v3.8.10 - 3. virtualenv - - Running GraphSpace locally - =================================== - - In order to run GraphSpace, please install sqlite3 and both the Python runtime and development environments. We have tested GraphSpace with Python v2.7.10 and sqlite3 v3.8.10. GraphSpace does not support Python v3. GraphSpace performs best on either Mozilla Firefox or Google Chrome browsers. The following steps describe how to install Python packages required by GraphSpace, download the GraphSpace code, and set up and start the server. The following instructions should apply to computers running a version of the Linux or OS X operating systems. - - 1. Download the GraphSpace code by running `git clone https://github.com/Murali-group/GraphSpace.git` or by downloading the latest release: https://github.com/Murali-group/GraphSpace/releases. - 2. Visit the GraphSpace directory: `cd GraphSpace` - 3. Create a virtual environment for the project: `virtualenv venv` - 4. Start using the virtual environment: `source venv/bin/activate` - 5. Install graphspace: `sh install.sh` - 6. Finally, start the GraphSpace server: `python manage.py runserver --settings=graphspace.settings.local` - 7. Visit `http://localhost:8080` and enjoy using GraphSpace! - - Running GraphSpace on Apache - =================================== - - This section describes the steps required to launch GraphSpace on a server that has `apache2` running on it. First, please follow the steps in **Running GraphSpace locally**. Next, execute the instructions below. - - 1. Follow instructions 1-5 in `Running GraphSpace locally` - 2. Set `URL_PATH` to the URL where your server will be running. *Note: Please add the ending '/' character at the end of this value: For example: http://graphspace.org/* - 3. Modify the `PATH` to point to where GraphSpace directory exists. *Note: Please add the ending '/' character at the end of this value: For example: /home/ubuntu/GraphSpace/* - 4. Visit the `apache2` directory: `cd /path_to/apache2`. An example of the full path to this directory is `/etc/apache2`. - 5. Navigate to the `sites-enabled` directory: `cd sites-enabled` - 6. Create a file called `graphspace.conf` and access this file using admin privileges: `sudo vim graphspace.conf' - 7. Inside this file, copy and paste following lines, after replacing `path_to_GraphSpace` with the name of the directory where you downloaded GraphSpace: - - ``` - WSGIDaemonProcess GraphSpace python-path=/path_to_GraphSpace:/path_to_GraphSpace/venv/lib/python2.7/site-packages/ python-eggs=/path_to_python_eggs - WSGIProcessGroup GraphSpace - WSGIScriptAlias / /path_to_GraphSpace/graphspace/wsgi.py - - - - Order deny,allow - Require all granted - - - - Alias /static/ /path_to_GraphSpace/graphs/static/ - - - Require all granted - - - - Options Indexes FollowSymLinks - AllowOverride None - Require all granted - - ``` - - 8. Install module to recognize Django application through apache2: `sudo apt-get install libapache2-mod-wsgi` - 9. Give permission to access static files through apache2. Navigate outside GraphSpace and type: `chmod 777 GraphSpace` - 10. Create a directory for python-eggs. `mkdir /path_to_python_eggs` - 11. Give permission to access static files through apache2. `chmod 777 /path_to_python_eggs` - 12. Restart the apache server. On a computer running Ubuntu, the command is `sudo service apache2 restart` - - Refer to https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/modwsgi/ if any problems occur with the setup. - - Testing the GraphSpace REST API - ================================= - - 1. Follow the instructions above to run GraphSpace locally or on Apache2. - 2. Navigate to the tests directory in GraphSpace: `cd tests` - 2. Enter python command to run test cases: `python restapi_test.py` - - This script will test all the REST API commands supported by the GraphSpace server. If something fails, it will display the error. - -Platform: UNKNOWN -Classifier: Environment :: Web Environment -Classifier: Framework :: Django diff --git a/GraphSpace.egg-info/SOURCES.txt b/GraphSpace.egg-info/SOURCES.txt deleted file mode 100644 index 19d5850d..00000000 --- a/GraphSpace.egg-info/SOURCES.txt +++ /dev/null @@ -1,28 +0,0 @@ -setup.py -GraphSpace.egg-info/PKG-INFO -GraphSpace.egg-info/SOURCES.txt -GraphSpace.egg-info/dependency_links.txt -GraphSpace.egg-info/not-zip-safe -GraphSpace.egg-info/requires.txt -GraphSpace.egg-info/top_level.txt -graphs/__init__.py -graphs/forms.py -graphs/models.py -graphs/urls.py -graphs/views.py -graphs/auth/AuthBackend.py -graphs/auth/__init__.py -graphs/auth/login.py -graphs/util/__init__.py -graphs/util/db.py -graphs/util/db_conn.py -graphs/util/db_init.py -graphs/util/json_validator.py -graphs/util/paginator.py -graphspace/__init__.py -graphspace/urls.py -graphspace/wsgi.py -graphspace/settings/__init__.py -graphspace/settings/base.py -graphspace/settings/local.py -graphspace/settings/testing.py \ No newline at end of file diff --git a/GraphSpace.egg-info/dependency_links.txt b/GraphSpace.egg-info/dependency_links.txt deleted file mode 100644 index 8b137891..00000000 --- a/GraphSpace.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/GraphSpace.egg-info/not-zip-safe b/GraphSpace.egg-info/not-zip-safe deleted file mode 100644 index 8b137891..00000000 --- a/GraphSpace.egg-info/not-zip-safe +++ /dev/null @@ -1 +0,0 @@ - diff --git a/GraphSpace.egg-info/requires.txt b/GraphSpace.egg-info/requires.txt deleted file mode 100644 index 9dcf238a..00000000 --- a/GraphSpace.egg-info/requires.txt +++ /dev/null @@ -1,19 +0,0 @@ -alabaster==0.7.8 -Babel==2.3.4 -decorator==4.0.10 -Django==1.9.7 -django-analytical==2.2.1 -docutils==0.12 -imagesize==0.7.1 -Jinja2==2.8 -MarkupSafe==0.23 -networkx==1.11 -poster==0.8.1 -py-bcrypt==0.4 -Pygments==2.1.3 -pytz==2016.4 -requests==2.10.0 -six==1.10.0 -snowballstemmer==1.2.1 -Sphinx==1.4.4 -SQLAlchemy==1.0.14 diff --git a/GraphSpace.egg-info/top_level.txt b/GraphSpace.egg-info/top_level.txt deleted file mode 100644 index b2105e84..00000000 --- a/GraphSpace.egg-info/top_level.txt +++ /dev/null @@ -1,2 +0,0 @@ -graphs -graphspace diff --git a/build/lib/graphs/__init__.py b/build/lib/graphs/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/build/lib/graphs/auth/AuthBackend.py b/build/lib/graphs/auth/AuthBackend.py deleted file mode 100644 index 346ab8ea..00000000 --- a/build/lib/graphs/auth/AuthBackend.py +++ /dev/null @@ -1,22 +0,0 @@ -import bcrypt -from graphs.util import db - -def authenticate(username=None, password=None): - # check the username/password and return a User - user = db.emailExists(username) - - if user != None: - hashed_pw = user.password - - #check password. if the password matches, return a - #User object with associated information - if bcrypt.hashpw(password, hashed_pw) == hashed_pw: - user_obj = {} - user_obj['user_id'] = user.user_id - user_obj['password'] = user.password - user_obj['admin'] = user.admin - - return user_obj - else: - return None - diff --git a/build/lib/graphs/auth/__init__.py b/build/lib/graphs/auth/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/build/lib/graphs/auth/login.py b/build/lib/graphs/auth/login.py deleted file mode 100644 index 68236a3f..00000000 --- a/build/lib/graphs/auth/login.py +++ /dev/null @@ -1,61 +0,0 @@ -from graphs.auth import AuthBackend -from graphs.forms import LoginForm, RegisterForm -from graphs.util import db -from django.conf import settings - -def login(request): - ''' - Handles the login request from the webpage. - ''' - #context of the view to be passed in for rendering - context = {} - message = '' - login_form = LoginForm(); - register_form = RegisterForm(); - URL_PATH = settings.URL_PATH - - if request.method == 'POST': - - user = AuthBackend.authenticate(username=request.POST['user_id'], password=request.POST['pw']) - login_form = LoginForm(request.POST) - if user is not None: - message = '%s, Welcome to GraphSpace!' % user['user_id'] - request.session['uid'] = user['user_id'] - request.session['admin'] = user['admin'] - context = {'login_form': login_form, 'user': user, 'uid': user['user_id'], 'admin': user['admin'], "Error": None, "url": URL_PATH} - return context - else: - login_form = LoginForm() - context['login_form'] = login_form - context['register_form'] = register_form - context['Error'] = "User/Password not recognized!" - message = 'Information you have given does not match our records. Please try again.' - context['url'] = URL_PATH - return context - # when a user is already logged in or not logged in at all. - else: - # the user did not enter any information into the login form. - # in this case, there may be already be a logged-in user. - if 'uid' in request.session: - #there is a currently logged-in user. - uid = request.session['uid'] - else: - #there is no one logged in. - uid = None - context['url'] = URL_PATH - context['Error'] = "Not logged in!" - - if uid is not None: - context['uid'] = request.session['uid'] - context['admin'] = request.session['admin'] - context['Error'] = None - message = 'Welcome to GraphSpace, %s!' % request.session['uid'] - context['url'] = URL_PATH - return context - else: - request.session['uid'] = None - context['login_form'] = login_form - context['register_form'] = RegisterForm() - context['url'] = URL_PATH - context["Error"] = None - return context diff --git a/build/lib/graphs/forms.py b/build/lib/graphs/forms.py deleted file mode 100644 index b5555e50..00000000 --- a/build/lib/graphs/forms.py +++ /dev/null @@ -1,84 +0,0 @@ -''' -See https://docs.djangoproject.com/en/dev/topics/forms/ for details. -''' - -from django import forms -from graphs.util import db - -class LoginForm(forms.Form): - ''' - Login Form used to show login fields in GraphSpace webpages. - This form is located within the top navbar. - ''' - - # attrs to specify extra html attributes - user_id = forms.CharField(max_length=100, required=False, widget=forms.TextInput(attrs={'placeholder': 'Email', 'class': 'form-control', 'size': '13', 'id': 'email'})) - pw = forms.CharField(required=False, widget=forms.PasswordInput(attrs={'placeholder': 'Password', 'class': 'form-control', 'size': '13', 'id': 'pw'})) - -class SearchForm(forms.Form): - ''' - Search form used to perform search on GraphSpace - ''' - - def __init__(self, *args, **kwargs): - ''' - Initialize the form. A keyword argument 'placeholder' may be - given. - - This can be customized to specify additional parameters if it - needs to. - ''' - if 'placeholder' in kwargs: - self.placeholder = kwargs.pop('placeholder') - # must be called after 'placeholder' is popped from kwargs - super(SearchForm, self).__init__(*args, **kwargs) - self.fields['search'].widget = forms.TextInput(attrs={'placeholder': self.placeholder, 'class': 'form-control', 'type': 'text', 'name': 'search'}) - else: - super(SearchForm, self).__init__(*args, **kwargs) - self.fields['search'].widget = forms.TextInput(attrs={'class': 'form-control', 'type': 'text', 'name': 'search'}) - - search = forms.CharField(required=False, label='', max_length=100) - -class RegisterForm(forms.Form): - ''' - Register form to help create an account for a new user. - ''' - - user_id = forms.CharField(required=False, label='Email', max_length=100, - widget=forms.TextInput(attrs={'class': 'form-control', 'type': 'text', 'size': '25', 'id': 'user_id'})) - password = forms.CharField(required=False, label='Password', widget=forms.PasswordInput(attrs={'class': 'form-control', 'size': '25', 'id': 'password'})) - verify_password = forms.CharField(required=False, label='Verify Password', widget=forms.PasswordInput(attrs={'class': 'form-control', 'size': '25', 'id': 'verify_password'})) - - def clean_user_id(self): - ''' - Form validation to check if the user id already exist - in the database. - - https://docs.djangoproject.com/en/1.6/ref/forms/validation/#cleaning-a-specific-field-attribute - ''' - cleaned_data = super(RegisterForm, self).clean() - user_id = cleaned_data["user_id"] - - check_user = db.emailExists(user_id) - - if check_user == None: - return user_id - else: - return None - - def clean(self): - ''' - Form validation to check if two passwords provided are - equivalent. - - https://docs.djangoproject.com/en/1.6/ref/forms/validation/#cleaning-a-specific-field-attribute - ''' - cleaned_data = super(RegisterForm, self).clean() - pw = cleaned_data.get("password") - vpw = cleaned_data.get("verify_password") - - if pw and vpw: - if pw != vpw: - raise forms.ValidationError("Passwords do not match.") - - return cleaned_data \ No newline at end of file diff --git a/build/lib/graphs/models.py b/build/lib/graphs/models.py deleted file mode 100644 index d3e70bb2..00000000 --- a/build/lib/graphs/models.py +++ /dev/null @@ -1,363 +0,0 @@ -''' -This code produces almost the same schema as the original schema used in -the current live version of GraphSpace (as of June 28, 2014). -There are two differences: - 1. The schema produced by this code does not specify data lengths. - ex. VARCHAR instead of VARCHAR(x) - 2. Tables in the original schema contain a column named 'id'. 'id' is - a built-in function in Python so it is not a good practice to use - it as a variable. Thus 'id' columns are renamed as 'tablename_id'. - ex. 'id' for user table would be 'user_id' -''' - -from sqlalchemy import Column, Integer, String, ForeignKey, Table, Index, ForeignKeyConstraint -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship, backref -from sqlalchemy.types import TIMESTAMP -from django.db import models -from django.conf import settings -from sqlalchemy import create_engine - -import bcrypt - -# Construct a base class for declarative class definitions. -# The new base class will be given a metaclass that produces appropriate Table objects -# and makes the appropriate mapper() calls based on the information provided -# declarativly in the class and any subclasses of the class. -# (Taken from SQLAlchemy API reference) -Base = declarative_base() - -#======================== Junction Tables ========================== -# Junction tables for specifying many to many relationships. - -# This table stores group-user pairs. A group may contain many users and a user -# may belong to many groups. The name of the table is 'group_to_user'. -# It has two columns, 'user_id' and 'group_id', which together constitute the -# primary key. 'user_id' is a foreign key referring to the 'user_id' column in -# the 'user' table. 'group_id' is a foreign key referring to the 'group_id' column -# in the 'group' table. - -# For each graph, this table stores the groups that the graph belongs to. -# Note that a graph may belong to multiple groups. - -# For each graph, this table stores tags that the graph has. A graph can have i -# many tags, and a tag can belong to many graphs. - - -#=================== End of Junction Tables =================== - -# ================== Table Definitions =================== -class GroupToUser(Base): - '''The class representing the schema of the group_to_user table.''' - __tablename__ = 'group_to_user' - - user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - group_id = Column(String, ForeignKey('group.group_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - group_owner = Column(String, ForeignKey('group.owner_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - -class Feedback(Base): - __tablename__ = 'feedback' - - id = Column(Integer, autoincrement=True, primary_key=True) - graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE")) - user_id = Column(String, ForeignKey('graph.user_id', ondelete="CASCADE", onupdate="CASCADE")) - layout_id = Column(String, ForeignKey('layout.layout_id', ondelete="CASCADE", onupdate="CASCADE")) - text = Column(String, nullable = False) - created = Column(TIMESTAMP, nullable = False) - -class GroupToGraph(Base): - '''The class representing the schema of the group_to_graph table.''' - __tablename__ = 'group_to_graph' - - group_id = Column(String, ForeignKey('group.group_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - group_owner = Column(String, ForeignKey('group.owner_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - user_id = Column(String, ForeignKey('graph.user_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - modified = Column(TIMESTAMP, nullable = False) - -class GraphToTag(Base): - '''The class representing the schema of the graph_to_tag table.''' - __tablename__ = 'graph_to_tag' - - graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - user_id = Column(String, ForeignKey('graph.user_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key=True) - tag_id = Column(String, ForeignKey('graph_tag.tag_id'), primary_key=True) - -class TaskCode(Base): - '''The class representing the schema of the task_code table.''' - __tablename__ = 'task_code' - hit_id = Column(String, ForeignKey('task.hit_id', ondelete="CASCADE", onupdate="CASCADE")) - code = Column(String, primary_key = True) - created = Column(TIMESTAMP, nullable = False) - -class User(Base): - '''The class representing the schema of the user table.''' - __tablename__ = 'user' - - user_id = Column(String, primary_key = True) - password = Column(String, nullable = False) - admin = Column(Integer, nullable = True) - - # one to many relationships. TODO: add cascades - # at most one user can create a graph layout - layouts = relationship("Layout") - # each group has at most one user who created it. See the owner_id column in the 'Group' class. - owned_groups = relationship("Group") - # each graph has at most one creator. - graphs = relationship("Graph") - # ??? - password_reset = relationship("PasswordReset") - -# TODO: change name to Group here and in the db. -class Group(Base): - __tablename__ = 'group' - - group_id = Column(String, primary_key = True) - # TODO: describe the difference bewteen group_id and name. - name = Column(String, nullable = False) - # Each group has one owner, who must be in the user table. The foreign key - # statement corresponds to the owned_groups relationship in the 'User' class. - owner_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False, primary_key = True) - description = Column(String, nullable = False) - - # This line creates the many-to-many relationship between the User class and the i - # Group class through the group_to_user junction table. Specifically, - # it links the 'User' class to the current class using the group_to_user junction - # table; this is a many to one relationship from 'User' to 'group_to_user'. - # The backref argument establishes the many to one relationship from 'Group' - # to 'group_to_user'. An equivalent way to link the two classes is to instead - # add the following line to User: - # groups = relationship('Group', secondary=group_to_user, backref='user') - # users = relationship('User', backref='group') - # # specifies many-to-many relationship with Graph table - # graphs = relationship('Graph', backref='group') - -class Graph(Base): - __tablename__ = 'graph' - - # The graph_id and user_id together specify the primary key. - graph_id = Column(String, primary_key = True) #id - user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), primary_key = True) - json = Column(String, nullable = False) - created = Column(TIMESTAMP, nullable = False) - modified = Column(TIMESTAMP, nullable = False) - public = Column(Integer, nullable = True) - shared_with_groups = Column(Integer, nullable = True) - default_layout_id = Column(String, nullable = True) - - # specify one to many relationships - # layouts = relationship("Layout") - # # Each node can belong to at most one graph. See the 'Node' class for details. - # nodes = relationship("Node") - - # groups = relationship("Group", backref='graph') - - #specify many to many relationship with GraphTag - # tags = relationship("GraphTag", secondary=graph_to_tag, backref='graph') - -class Task(Base): - ''' - Table that represents the task table. - ''' - __tablename__ = 'task' - - task_id = Column(Integer, autoincrement=True, primary_key=True) - task_owner = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) - user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) - graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) - layout_id = Column(String, ForeignKey('layout.layout_id', ondelete="CASCADE", onupdate="CASCADE")) - created = Column(TIMESTAMP, nullable = False) - hit_id=Column(String, nullable=False) - worker_id=Column(String, nullable=False) - submitted=Column(Integer, nullable=True) - task_type=Column(String, nullable=False) - -class GraphTag(Base): - ''' - Table of tags that are assigned to each graph to categorize them. - ''' - __tablename__ = 'graph_tag' - tag_id = Column(String, primary_key = True) #id - -class Feature(Base): - ''' - Table that holds all the features. - ''' - __tablename__ = 'feature' - - id = Column(Integer, autoincrement=True, primary_key=True) - user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE")) - graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = True) - layout_id = Column(String, ForeignKey('layout.layout_id', ondelete="CASCADE", onupdate="CASCADE")) - created = Column(TIMESTAMP, nullable = False) - distance_vector = Column(String, nullable = True) - pairwise_vector = Column(String, nullable = True) - num_changes = Column(Integer, nullable = False) - time_spent = Column(Integer, nullable = False) - events = Column(String, nullable = False) - -class Layout(Base): - ''' - Table of Layouts to specify how the graphs are viewed on GraphSpace. - User created layouts will be stored in this table. - ''' - __tablename__ = 'layout' - - layout_id = Column(Integer, autoincrement=True, primary_key=True) - # A descriptive name for the layout, provided by the owner_id when creating the i - # layout in GraphSpace. - layout_name = Column(String, nullable = False) - - # The id of the user who created the layout. The foreign key constraint ensures - # this person is present in the 'user' table. Not that owner_id need not be the - # same as user_id since (graph_id, user_id) uniquely identify the graph. - # In other words, the owner_id can be the person other than the one who created - # the graph (user_id). An implicit rule is that owner_id must belong to some - # group that this graph belongs to. However, the database does not enforce this - # constraint explicitly. - # TODO: Add a database constraint that checks this rule. - owner_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) - # id of the graph that the layout is for - graph_id = Column(String, nullable = False) - # id of the user who owns the graph specified by graph_id - user_id = Column(String, nullable = False) - # graph layout data in JSON format - json = Column(String, nullable = False) - public = Column(Integer, nullable = True) - shared_with_groups = Column(Integer, nullable = True) - # when was the last time layout was modified - times_modified = Column(Integer, nullable = False) - # If layout is updated (task), save the original layout - original_json = Column(String, nullable = True) - - # SQLAlchemy's way of creating a multi-column foreign key constraint. - __table_args__ = (ForeignKeyConstraint([graph_id, user_id], [Graph.graph_id, Graph.user_id], ondelete="CASCADE", onupdate="CASCADE"), {}) - -class LayoutStatus(Base): - ''' - Table of layout acceptances/rejections. - ''' - __tablename__ = 'layout_status' - - id = Column(Integer, autoincrement=True, primary_key=True) - graph_id = Column(String, ForeignKey('graph.graph_id', ondelete="CASCADE", onupdate="CASCADE")) - user_id = Column(String, ForeignKey('graph.user_id', ondelete="CASCADE", onupdate="CASCADE")) - layout_id = Column(Integer, nullable = False) - triangle_rating = Column(Integer, nullable = False) - rectangle_rating = Column(Integer, nullable = False) - shape_rating = Column(Integer, nullable = False) - color_rating = Column(Integer, nullable = False) - created = Column(TIMESTAMP, nullable = False) - submitted_by = Column(String, nullable = True) - -class Node(Base): - ''' - Table of nodes used in graphs. Same node can be in many graphs, but they are - considered to be distinct. - ''' - __tablename__ = 'node' - - # The primary key contains three columns: node_id, graph_id, and user_id. The same node may appear in different graphs but we consider them to be distinct. - node_id = Column(String, primary_key = True) - label = Column(String, nullable = False) - user_id = Column(String, primary_key = True) - graph_id = Column(String, primary_key = True) - modified = Column(TIMESTAMP, primary_key = True) - - # Foregin key contraint to idientify the graph that this node belong to - __table_args__ = (ForeignKeyConstraint([user_id, graph_id], [Graph.user_id, Graph.graph_id], ondelete="CASCADE", onupdate="CASCADE"), {}) - - # one to many relationship with Edge, since a node can have many edges. - heads = relationship("Edge", foreign_keys="[Edge.user_id, Edge.graph_id, Edge.head_node_id]") - tails = relationship("Edge", foreign_keys="[Edge.user_id, Edge.graph_id, Edge.head_node_id]") - -class PasswordReset(Base): - __tablename__ = 'password_reset' - - id = Column(Integer, primary_key = True, autoincrement = True) - user_id = Column(String, ForeignKey('user.user_id', ondelete="CASCADE", onupdate="CASCADE"), nullable = False) - code = Column(String, nullable = False) - created = Column(TIMESTAMP, nullable = False) - - #no relationship specified - -class Edge(Base): - ''' - Table of edges that are used on graphs. - ''' - __tablename__ = 'edge' - - # 3 column primary keys are used to determine which two nodes that this edge connect. - # each edge connects a head node to a tail node. - # head node - user_id = Column(String, nullable = False) - graph_id = Column(String, nullable = False) - head_node_id = Column(String, nullable = False) - # head_node_label column was added to speed up the similar terms search query on edges. The lookup on two tables was taking too much time. - # This column is part of index edge_idx_head_label_tail_label - head_node_label = Column(String, nullable = False) - - # tail node - tail_node_id = Column(String, nullable = False) - # tail_node_label column was added to speed up the similar terms search query on edges. The lookup on two tables was taking too much time. - # This column is part of index edge_idx_head_label_tail_label - tail_node_label = Column(String, nullable = False) - - # label of this edge - edge_id = Column(String, nullable = True) - # inicates whether this edge is directed or not. - directed = Column(Integer, nullable = True) - - id = Column(Integer, autoincrement=True, primary_key=True) - - # Foreign key contraints to determine each node. - __table_args__ = ( - ForeignKeyConstraint([user_id, graph_id, head_node_id], [Node.user_id, Node.graph_id, Node.node_id], ondelete="CASCADE", onupdate="CASCADE"), - ForeignKeyConstraint([user_id, graph_id, tail_node_id], [Node.user_id, Node.graph_id, Node.node_id], ondelete="CASCADE", onupdate="CASCADE"), {}) - #no relationship specified - -#Create indices -Index('graph_public_idx', Graph.public) -Index('graph_owner_idx', Graph.user_id) -# Table: group. Columns: owner_id -Index('group_idx_owner_id_group_id', Group.owner_id, Group.group_id) -# Table: graph. Columns: user_id -Index('graph_idx_user_graph_id', Graph.user_id, Graph.graph_id) -# Table: graph. Columns: user_id, modified, public -Index('graph_idx_user_id_modified_id_public', Graph.user_id, Graph.graph_id, Graph.modified, Graph.public) -# Table: graph. Columns: modified, user_id, graph_id, public -# Table: graph_to_tag. Columns: graph_id, user_id -Index('graph_to_tag_idx_graph_id_user_id', GraphToTag.graph_id, GraphToTag.user_id, GraphToTag.tag_id) -# Table: graph_to_tag. Columns: tag_id -Index('graph_to_tag_idx_tag_id', GraphToTag.tag_id) -Index('graph_to_tag_idx_user_id', GraphToTag.user_id, GraphToTag.graph_id) -# Table: group_to_graph. Columns: graph_id, user_id -Index('group_to_graph_idx_graph_id_user_id', GroupToGraph.graph_id, GroupToGraph.user_id, GroupToGraph.group_id, GroupToGraph.group_owner) -# Table: group_to_graph. Columns: group_id -Index('group_to_graph_idx_group_id', GroupToGraph.group_id, GroupToGraph.group_owner) -Index('group_to_graph_graph_idx', GroupToGraph.graph_id) -Index('group_to_graph_group_idx', GroupToGraph.group_id) -Index('group_to_graph_user_idx', GroupToGraph.user_id) -# Table: group_to_graph. Columns: graph_id, user_id, group_id. -# Table: group_to_user. Columns: group_id -Index('group_to_user_idx_group_id', GroupToUser.group_id, GroupToUser.user_id, GroupToUser.group_owner) -# Table: group_to_user. Columns: user_id -Index('group_to_user_idx_user_id', GroupToUser.user_id) -# Table: layout. Columns: graph_id, user_id -#Index('layout_idx_graph_id_user_id', Layout.graph_id, Layout.user_id) -# Table: layout. Columns: owner_id -#Index('layout_idx_owner_id', Layout.owner_id) -# Table: node. Columns: graph_id, user_id -Index('node_idx_graph_id_user_id', Node.graph_id, Node.user_id, Node.node_id, Node.label) -Index('node_index_label_graph_id', Node.label) -Index('node_index_node_id_graph_id', Node.node_id) - -Index('edge_idx_head_id_tail_id', Edge.head_node_id, Edge.tail_node_id) -Index('edge_idx_head_label_tail_label', Edge.head_node_label, Edge.tail_node_label) -# Create an engine that stores data in the local directory's -# sqlalchemy_example.db file. -engine = create_engine(settings.DATABASE_LOCATION, echo=False) - -# Create all tables in the engine. This is equivalent to "Create Table" -# statements in raw SQL. -Base.metadata.create_all(engine) diff --git a/build/lib/graphs/urls.py b/build/lib/graphs/urls.py deleted file mode 100644 index 7aee8be0..00000000 --- a/build/lib/graphs/urls.py +++ /dev/null @@ -1,209 +0,0 @@ -from django.conf.urls import patterns, url -from graphs import views - -urlpatterns = patterns('', - # name parameter indicates the name of the view. This name - # can be accessed from respective html files using Django - # template language. - - url(r'^submitEvaluation/$', views.submitEvaluation, name='submitEvaluation'), - url(r'^submitExpertEvaluation/$', views.submitExpertEvaluation, name='submitExpertEvaluation'), - url(r'^saveFeedback/$', views.saveFeedback, name='saveFeedback'), - url(r'^getFeedback/$', views.getFeedback, name='getFeedback'), - url(r'^$', views.index, name='index'), - url(r'^index/$', views.index, name='index'), - url(r'^index/logout/$', views.logout, name='logout'), - url(r'^download/$', views.download, name='download'), - url(r'^image/$', views.image, name='image'), - - # graphs page - url(r'^graphs/$', views.graphs, name='graphs'), - url(r'^graphs/shared/$', views.shared_graphs, name='shared_graphs'), - url(r'^graphs/public/$', views.public_graphs, name='public_graphs'), - url(r'^graphs/upload/$', views.upload_graph_through_ui, name='upload_graph_through_ui'), - - # view graph page. This contains regular expression to catch url in the form of the following: - # /graphs/email_address/graph_id/ - # regex from http://www.regular-expressions.info/email.html - # and are variable names that are passed as parameters - # into the views.view_graph function. - url(r'^task/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/layout/update/$', views.update_layout, name='update_layout'), - url(r'^graphs/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/layout/$', views.save_layout, name='save_layout'), - url(r'^graphs/design/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/layout/$', views.save_layout, name='save_layout'), - url(r'^graphs/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/$', views.view_graph, name='view_graph'), - - url(r'^json/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/$', views.view_json, name='view_json'), - - # groups page - url(r'^groups/$', views.groups, name='groups'), - url(r'^groups/member/$', views.groups_member, name='groups_member'), - url(r'^groups/(?P.+)/(?P.+)/$', views.graphs_in_group, name="graphs_in_group"), - url(r'^add/(?P.+)/$', views.create_group, name='create_group'), - url(r'^delete/group/$', views.delete_group_through_ui, name='delete_group_through_ui'), - url(r'^unsubscribe/group/$', views.unsubscribe_from_group, name='unsubscribe_from_group'), - - url(r'^retrieveIDs/$', views.retrieveIDs, name='retrieveIDs'), - url(r'^changeDescription/$', views.change_description_through_ui, name='change_description_through_ui'), - url(r'^addMember/$', views.add_member_through_ui, name='add_member_through_ui'), - url(r'^removeMember/$', views.remove_member_through_ui, name='remove_member_through_ui'), - - # help page - url(r'^help/$', views.help, name='help'), - url(r'^help/tutorial$', views.help_tutorial, name='help_tutorial'), - url(r'^help/programmers/$', views.help_programmers, name='help_programmers'), - url(r'^help/graphs/$', views.help_graphs, name='help_graphs'), - url(r'^help/restapi/$', views.help_restapi, name='help_restapi'), - url(r'^help/jsonref/$', views.help_jsonref, name='help_jsonref'), - url(r'^help/about/$', views.help_about, name='help_about'), - url(r'^features/$', views.features, name='features'), - - - # hack to get all HGTV graphs working - url(r'^images/legend.png$', views.renderImage, name='renderImage'), - - # Utility methods - url(r'^register/$', views.register, name='register'), - url(r'^changeLayoutName/$', views.changeLayoutName, name='changeLayoutName'), - url(r'^deleteLayout/$', views.deleteLayout, name='deleteLayout'), - url(r'^getGroupsForGraph/$', views.getGroupsForGraph, name='getGroupsForGraph'), - url(r'^shareGraphWithGroups/$', views.shareGraphWithGroups, name='shareGraphWithGroups'), - url(r'^shareLayoutWithGroups/$', views.shareLayoutWithGroups, name='shareLayoutWithGroups'), - url(r'^makeLayoutPublic/$', views.makeLayoutPublic, name='makeLayoutPublic'), - url(r'^setDefaultLayout/$', views.setDefaultLayout, name='setDefaultLayout'), - url(r'^removeDefaultLayout/$', views.removeDefaultLayout, name='removeDefaultLayout'), - url(r'^deleteGraph/$', views.deleteGraph, name='deleteGraph'), - url(r'^forgot/$', views.sendResetEmail, name='forgot'), - url(r'^reset/$', views.resetLink, name='reset'), - url(r'^resetPassword/$', views.resetPassword, name='resetPassword'), - url(r'^launchTask/$', views.launchTask, name='launchTask'), - url(r'^retrieveTaskCode/$', views.retrieveTaskCode, name='retrieveTaskCode'), - - #REST API - - # Graph REST API endpoints - url(r'^api/users/(?P.+)/graph/add/(?P.+)/$', views.upload_graph, name='upload_graph'), - url(r'^api/users/(?P.+)/graph/get/(?P.+)/$', views.retrieve_graph, name='retrieve_graph'), - url(r'^api/users/(?P.+)/graph/exists/(?P.+)/$', views.graph_exists, name='graph_exists'), - url(r'^api/users/(?P.+)/graph/update/(?P.+)/$', views.update_graph, name='update_graph'), - url(r'^api/users/(?P.+)/graph/delete/(?P.+)/$', views.remove_graph, name='remove_graph'), - url(r'^api/users/(?P.+)/graph/makeGraphPublic/(?P.+)/$', views.make_graph_public, name='make_graph_public'), - url(r'^api/users/(?P.+)/graph/makeGraphPrivate/(?P.+)/$', views.make_graph_private, name='make_graph_private'), - url(r'^api/users/(?P.+)/graphs/$', views.view_all_graphs_for_user, name='view_all_graphs_for_user'), - - # Group REST API endpoints - url(r'^api/groups/get/(?P.+)/(?P.+)/$', views.get_group, name='get_group'), - url(r'^api/groups/get/$', views.get_groups, name='get_groups'), - url(r'^api/groups/add/(?P.+)/(?P.+)/$', views.add_group, name='add_group'), - url(r'^api/groups/delete/(?P.+)/(?P.+)/$', views.delete_group, name='delete_group'), - url(r'^api/users/(?P.+)/groups/$', views.get_group_for_user, name='get_group_for_user'), - url(r'^api/groups/(?P.+)/(?P.+)/adduser/(?P.+)/$', views.add_user_to_group, name='add_user_to_group'), - url(r'^api/groups/(?P.+)/(?P.+)/removeuser/(?P.+)/$', views.remove_user_from_group, name='remove_user_from_group'), - url(r'^api/users/graphs/(?P.+)/share/(?P.+)/(?P.+)/$', views.share_graph, name='share_graph'), - url(r'^api/users/graphs/(?P.+)/unshare/(?P.+)/(?P.+)/$', views.unshare_graph, name='unshare_graph'), - - # Tag REST API endpoints - url(r'^api/tags/user/(?P.+)/(?P.+)/makePublic/$', views.make_all_graphs_for_tag_public, name='make_all_graphs_for_tag_public'), - url(r'^api/tags/user/(?P.+)/(?P.+)/makePrivate/$', views.make_all_graphs_for_tag_private, name='make_all_graphs_for_tag_private'), - url(r'^api/tags/user/(?P.+)/(?P.+)/delete/$', views.delete_all_graphs_for_tag, name='delete_all_graphs_for_tag'), - url(r'^api/tags/user/(?P.+)/(?P.+)/$', views.get_all_tags_for_graph, name='get_all_tags_for_graph'), - url(r'^api/tags/user/(?P.+)/$', views.get_tags_for_user, name='get_tags_for_user'), - - - # THE FOLLOWING SECTION CONTAINS ALL LINKS THAT ARE ALREADY IN GRAPHSPACE BUT WITHOUT THE TRAILING '/' CHARACTER - url(r'^index$', views.index, name='index'), - url(r'^index/logout$', views.logout, name='logout'), - url(r'^download$', views.download, name='download'), - - # graphs page - url(r'^graphs$', views.graphs, name='graphs'), - url(r'^graphs/shared$', views.shared_graphs, name='shared_graphs'), - url(r'^graphs/public$', views.public_graphs, name='public_graphs'), - url(r'^graphs/upload$', views.upload_graph_through_ui, name='upload_graph_through_ui'), - - # view graph page. This contains regular expression to catch url in the form of the following: - # /graphs/email_address/graph_id/ - # regex from http://www.regular-expressions.info/email.html - # and are variable names that are passed as parameters - # into the views.view_graph function. - url(r'^graphs/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)/layout$', views.save_layout, name='save_layout'), - url(r'^graphs/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.view_graph, name='view_graph'), - url(r'^graphs/design/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.design_graph, name='design_graph'), - url(r'^task/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.view_task, name='view_task'), - url(r'^approveExpert$', views.approve_task_expert, name='approve_task_expert'), - url(r'^approve/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.approve_task, name='approve_task'), - - url(r'^json/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/(?P.+)$', views.view_json, name='view_json'), - - # groups page - url(r'^groups$', views.groups, name='groups'), - url(r'^groups/member$', views.groups_member, name='groups_member'), - url(r'^groups/(?P.+)/(?P.+)$', views.graphs_in_group, name="graphs_in_group"), - url(r'^add/(?P.+)$', views.create_group, name='create_group'), - url(r'^delete/group$', views.delete_group_through_ui, name='delete_group_through_ui'), - url(r'^unsubscribe/group$', views.unsubscribe_from_group, name='unsubscribe_from_group'), - - url(r'^retrieveIDs$', views.retrieveIDs, name='retrieveIDs'), - url(r'^changeDescription$', views.change_description_through_ui, name='change_description_through_ui'), - url(r'^addMember$', views.add_member_through_ui, name='add_member_through_ui'), - url(r'^removeMember$', views.remove_member_through_ui, name='remove_member_through_ui'), - - # help page - url(r'^help$', views.help, name='help'), - url(r'^help/tutorial$', views.help_tutorial, name='help_tutorial'), - url(r'^help/programmers$', views.help_programmers, name='help_programmers'), - url(r'^help/graphs$', views.help_graphs, name='help_graphs'), - url(r'^help/restapi$', views.help_restapi, name='help_restapi'), - url(r'^help/jsonref$', views.help_jsonref, name='help_jsonref'), - url(r'^help/about$', views.help_about, name='help_about'), - url(r'^features$', views.features, name='features'), - - - # hack to get all HGTV graphs working - url(r'^images/legend.png$', views.renderImage, name='renderImage'), - - # Utility methods - url(r'^register$', views.register, name='register'), - url(r'^changeLayoutName$', views.changeLayoutName, name='changeLayoutName'), - url(r'^deleteLayout$', views.deleteLayout, name='deleteLayout'), - url(r'^getGroupsForGraph$', views.getGroupsForGraph, name='getGroupsForGraph'), - url(r'^shareGraphWithGroups$', views.shareGraphWithGroups, name='shareGraphWithGroups'), - url(r'^shareLayoutWithGroups$', views.shareLayoutWithGroups, name='shareLayoutWithGroups'), - url(r'^makeLayoutPublic$', views.makeLayoutPublic, name='makeLayoutPublic'), - url(r'^setDefaultLayout$', views.setDefaultLayout, name='setDefaultLayout'), - url(r'^removeDefaultLayout$', views.removeDefaultLayout, name='removeDefaultLayout'), - url(r'^deleteGraph$', views.deleteGraph, name='deleteGraph'), - url(r'^forgot$', views.sendResetEmail, name='forgot'), - url(r'^reset$', views.resetLink, name='reset'), - url(r'^resetPassword$', views.resetPassword, name='resetPassword'), - - #REST API - - # Graph REST API endpoints - url(r'^api/users/(?P.+)/graph/add/(?P.+)$', views.upload_graph, name='upload_graph'), - url(r'^api/users/(?P.+)/graph/get/(?P.+)$', views.retrieve_graph, name='retrieve_graph'), - url(r'^api/users/(?P.+)/graph/update/(?P.+)$', views.update_graph, name='update_graph'), - url(r'^api/users/(?P.+)/graph/delete/(?P.+)$', views.remove_graph, name='remove_graph'), - url(r'^api/users/(?P.+)/graph/makeGraphPublic/(?P.+)$', views.make_graph_public, name='make_graph_public'), - url(r'^api/users/(?P.+)/graph/makeGraphPrivate/(?P.+)$', views.make_graph_private, name='make_graph_private'), - url(r'^api/users/(?P.+)/graphs$', views.view_all_graphs_for_user, name='view_all_graphs_for_user'), - - # Group REST API endpoints - url(r'^api/groups/get/(?P.+)/(?P.+)$', views.get_group, name='get_group'), - url(r'^api/groups/get$', views.get_groups, name='get_groups'), - url(r'^api/groups/add/(?P.+)/(?P.+)$', views.add_group, name='add_group'), - url(r'^api/groups/delete/(?P.+)/(?P.+)$', views.delete_group, name='delete_group'), - url(r'^api/users/(?P.+)/groups$', views.get_group_for_user, name='get_group_for_user'), - url(r'^api/groups/(?P.+)/(?P.+)/adduser/(?P.+)$', views.add_user_to_group, name='add_user_to_group'), - url(r'^api/groups/(?P.+)/(?P.+)/removeuser/(?P.+)$', views.remove_user_from_group, name='remove_user_from_group'), - url(r'^api/users/graphs/(?P.+)/share/(?P.+)/(?P.+)$', views.share_graph, name='share_graph'), - url(r'^api/users/graphs/(?P.+)/unshare/(?P.+)/(?P.+)$', views.unshare_graph, name='unshare_graph'), - - # Tag REST API endpoints - url(r'^api/tags/user/(?P.+)/(?P.+)/makePublic$', views.make_all_graphs_for_tag_public, name='make_all_graphs_for_tag_public'), - url(r'^api/tags/user/(?P.+)/(?P.+)/makePrivate$', views.make_all_graphs_for_tag_private, name='make_all_graphs_for_tag_private'), - url(r'^api/tags/user/(?P.+)/(?P.+)/delete$', views.delete_all_graphs_for_tag, name='delete_all_graphs_for_tag'), - url(r'^api/tags/user/(?P.+)/(?P.+)$', views.get_all_tags_for_graph, name='get_all_tags_for_graph'), - url(r'^api/tags/user/(?P.+)$', views.get_tags_for_user, name='get_tags_for_user'), - - ) - diff --git a/build/lib/graphs/util/__init__.py b/build/lib/graphs/util/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/build/lib/graphs/util/db.py b/build/lib/graphs/util/db.py deleted file mode 100644 index 1d11ab9c..00000000 --- a/build/lib/graphs/util/db.py +++ /dev/null @@ -1,4733 +0,0 @@ -import hmac -import json -import math -import random -import sqlite3 as lite -import string -import urllib -import uuid -import xml.etree.ElementTree as ET -from collections import Counter, defaultdict -from datetime import datetime -from hashlib import sha1 - -import bcrypt -from sqlalchemy import or_, and_ -from sqlalchemy.orm.exc import NoResultFound - -import graphs.models as models -import graphs.util.db_init as db_init -import requests -from django.conf import settings -from django.core.mail import send_mail -from json_validator import validate_json, assign_edge_ids, convert_json, verify_json - -data_connection = db_init.db - -# Name of the database that is being used as the backend storage -DB_NAME = settings.DB_FULL_PATH -URL_PATH = settings.URL_PATH -AWSACCESSKEYID = settings.AWSACCESSKEYID -SECRETKEY = settings.SECRETKEY - -AWS_URL = 'https://mechanicalturk.sandbox.amazonaws.com' - -def saveFeedback(feedback, graph_id, user_id, layout_owner, layout_name): - #create a new db session - db_session = data_connection.new_session() - - new_feedback = models.Feedback(id= None, graph_id = graph_id, user_id = user_id, layout_owner = layout_owner, layout_name = layout_name, text=feedback, created=datetime.now()) - db_session.add(new_feedback) - db_session.commit() - db_session.close() - -def getFeedback(graph_id, user_id, layout_owner, layout_name): - #create a new db session - db_session = data_connection.new_session() - - layout = db_session.query(models.Layout).filter(models.Layout.owner_id == layout_owner).filter(models.Layout.layout_name == layout_name).first() - - try: - feedback = db_session.query(models.Feedback.text).filter(models.Feedback.graph_id == graph_id).filter(models.Feedback.user_id == user_id).filter(models.Feedback.layoud_id == layout.layout_id).all() - db_session.close() - return feedback - except NoResultFound: - db_session.close() - return [] - -def add_everyone_to_password_reset(): - ''' - Adds all users to password reset table (cold-start). - Only use this once so we know crypto algorithm used - for forgot password functionalities - - ''' - - #create a new db session - db_session = data_connection.new_session() - - try: - # Get all users that are currently in the user table - user_ids = db_session.query(models.User.user_id).all() - - # Go through each username and add it to the password_reset table - for user_id in user_ids: - # This is done to remove the unicode encoding and simply - # extract the string - user_id = user_id[0] - add_user_to_password_reset(user_id, db_session = db_session) - - except NoResultFound: - print "There are no users in the database" - return None - - db_session.close() - -def add_user_to_password_reset(email, db_session=None): - ''' - Adds a specific user to password_reset table. - If email is in this, it automatically sends email to change - password for that account the next time the user logs on - - :param email: Email of the user for GraphSpace - :param db_session: database connection (See sqlalchemy.org for more information) - ''' - # Check if current user exists - curUser = emailExists(email) - - # If user doesn't exist, return out - if curUser == None: - return - - # Get database connection - db_session = db_session or data_connection.new_session() - - # Get the user if they exist - user_id = db_session.query(models.PasswordReset).filter(models.PasswordReset.user_id == email).first() - - # Generate unique code that GraphSpace will use to identify - # which user is trying to reset their password - code = id_generator() - - # If user needs to already reset their password, simply update the code - # for everytime they want to reset the password - - # If user doesn't have to already change their password, add them to the appropriate table - if user_id == None: - - # Create new entry to be inserted into password_reset table - reset_user = models.PasswordReset(id = None, user_id = email, code = code, created = datetime.now()) - db_session.add(reset_user) - # Commit the changes to the database - db_session.commit() - else: - #Update the code for the existing user already needing to reset their password - user_id.code = code - # Commit the changes to the database - db_session.commit() - -def emailExists(email): - ''' - Checks to see if a user's email exists. - - :param email: Email of user - :return boolean: True if user exists, false otherwise - ''' - #create a new db session - db_session = data_connection.new_session() - - try: - # Get the user if they exist - user = db_session.query(models.User).filter(models.User.user_id == email).one() - # Get the string representation from the tuple - db_session.close() - return user - except NoResultFound: - db_session.close() - return None - -def need_to_reset_password(email): - ''' - Checks to see if a user needs to reset their password. - If email is in password_reset email, they do, otherwise, not. - - :param email: Email of the user in GraphSpace - ''' - #create a new db session - db_session = data_connection.new_session() - - try: - # If email exists in password_reset table, then the user has to reset their password - user_id = db_session.query(models.PasswordReset.user_id).filter(models.PasswordReset.user_id == email).one() - return True - except NoResultFound: - return None - - db_session.close() - -def sendForgotEmail(email): - ''' - Emails the user to reset their password. - - :param email of user - ''' - - #create a new db session - db_session = data_connection.new_session() - - # Retrieve reset code attached to email - reset_code = db_session.query(models.PasswordReset.code).filter(models.PasswordReset.user_id == email).first() - - if reset_code == None: - return None - - # Construct email message - mail_title = 'Password Reset Information for GraphSpace!' - message = 'Please go to the following url to reset your password: ' + URL_PATH + 'reset/?id=' + reset_code[0] - emailFrom = "GraphSpace Admin" - - # Sends email to respective user - send_mail(mail_title, message, emailFrom, [email], fail_silently=False) - db_session.close() - return "Email Sent!" - -def retrieveResetInfo(reset_code): - ''' - Retrieves the reset information for a user (for comparing which user it is). - - :param reset_code: Code that the user has to match to HTTP GET request - :return account: Account associated with the code - ''' - - #create a new db session - db_session = data_connection.new_session() - - try: - # Obtain email attached to code -> code that was send to email address - # This is a verification step to ensure code is legit - user_id_to_reset = db_session.query(models.PasswordReset.user_id).filter(models.PasswordReset.code == reset_code).one() - # Retrieve string from unicode - user_id_to_reset = user_id_to_reset[0] - db_session.close() - return user_id_to_reset - except NoResultFound: - print "Code provided is not correct" - db_session.close() - return None - -def resetPassword(username, password, code): - ''' - Updates password information about a user. - - :param username: Email of user - :param password: Password of user - :param code: Code that is attached to this user to match if correct user submits password request - ''' - - #create a new db session - db_session = data_connection.new_session() - - # if code in password_reset table doesn't match the user trying to reset the password, don't allow them to - user = db_session.query(models.PasswordReset).filter(models.PasswordReset.code == code).first() - - if user == None: - return "Incorrect code provided" - - if user.user_id != username: - return "Invalid code provided for current user" - - try: - # Hash password - password = bcrypt.hashpw(password, bcrypt.gensalt()) - # Update the password for the user (after encryption of course) - user_to_reset_pw_for = db_session.query(models.User).filter(models.User.user_id == username).first() - user_to_reset_pw_for.password = password - - # Remove user's account from password_reset table - delete_from_password_reset = db_session.query(models.PasswordReset).filter(models.PasswordReset.user_id == username).all() - for acct in delete_from_password_reset: - db_session.delete(acct) - db_session.commit() - db_session.close() - return "Password updated successfully" - except Exception as ex: - print ex - print "Password not updated correctly" - db_session.close() - return None - -#### ONE TIME CODE -- KEEP FOR REFERENCE -def reUploadInconsistentGraphs(data): - con = None - try: - incosistent_graphs = open("inconsistency.txt", "a") - con = lite.connect(DB_NAME) - cur = con.cursor() - graphs_processed = 1 - for graph in data: - - graph_id = graph[0] - user_id = graph[1] - graph_json = json.loads(graph[2]) - created = graph[3] - modified = graph[4] - public = graph[5] - unlisted = graph[6] - default_layout_id = graph[7] - - print "Processing Graph: ", graph_id, " owned by: ", user_id, "\n", graphs_processed, " processed so far" - graphs_processed += 1 - - if 'data' in graph_json: - graph_json = json.loads(convert_json(graph[2])) - - node_list = [] - - for node in graph_json['graph']['nodes']: - node_list.append(str(node['data']['id'])) - - cur.execute('select node_id from node where graph_id=? and user_id =?', (graph_id, user_id)) - - nodes = cur.fetchall() - - mark_for_deletion = False - - if len(nodes) != len(node_list): - print "Nodes don't match" - mark_for_deletion = True - - unspecified_nodes = "" - - for node in nodes: - node = str(node[0]) - if node not in node_list: - print "Unspecified node: ", node - unspecified_nodes += node + ", " - mark_for_deletion = True - - if mark_for_deletion == True: - incosistent_graphs.write(graph_id + '\t' + user_id + "\t" + created + "\t" + modified + "\t" + unspecified_nodes + "\n" ) - cur.execute('delete from graph where graph_id = ? and user_id = ?', (graph_id, user_id)) - cur.execute('delete from node where graph_id = ? and user_id = ?', (graph_id, user_id)) - cur.execute('delete from edge where graph_id = ? and user_id = ?', (graph_id, user_id)) - cur.execute('delete from graph_to_tag where graph_id=? and user_id=?', (graph_id, user_id)) - con.commit() - result = insert_graph(user_id, graph_id, graph[2], created=created, modified=modified, public=public, unlisted=unlisted, default_layout_id=default_layout_id, skip=True) - if result != None: - print result - else: - print "Reinserted: " + graph_id - - print "Done processing" - incosistent_graphs.close() - except lite.Error, e: - print 'Error %s:' % e.args[0] - - finally: - if con: - con.close() - -def checkPublicNodeEdgeConsistency(): - ''' - Goes through public graph JSONs in GraphSpace database and makes sure - that the node and edge table have the appropriate - values and nothing that shouldn't be there. - - ''' - con = None - try: - con = lite.connect(DB_NAME) - cur = con.cursor() - - cur.execute('select * from graph') - data = cur.fetchall() - - if data == None: - return - - reUploadInconsistentGraphs(data) - - except lite.Error, e: - print 'Error %s:' % e.args[0] - - finally: - if con: - con.close() - -def checkNodeEdgeConsistencyOfUser(user_id): - ''' - Goes through JSONs in GraphSpace database and makes sure - that the node and edge table have the appropriate - values and nothing that shouldn't be there. - - ''' - con = None - try: - con = lite.connect(DB_NAME) - cur = con.cursor() - - cur.execute('select * from graph where user_id=?', (user_id, )) - - data = cur.fetchall() - reUploadInconsistentGraphs(data) - - except lite.Error, e: - print 'Error %s:' % e.args[0] - - finally: - if con: - con.close() - - - # END CONVERSIONS - -def id_generator(size=20, chars=string.ascii_uppercase + string.digits): - ''' - Generates an unique alphanumeric ID of specific size. - - :param size: Size of random string - :param chars: Subset of characters to generate random string of - :return string: Random string that adhere to the parameter properties - ''' - return ''.join(random.choice(chars) for _ in range(size)) - -def get_valid_user(username, password): - ''' - Checks to see if a user/password combination exists. - - :param username: Email of the user in GraphSpace - :param password: Password of the user - :return username: | None if wrong information - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Get user if they exist in the database - valid_user = db_session.query(models.User).filter(models.User.user_id == username).one() - # If hashed password != the hashed password in the database, user trying to log in is not a valid user of GraphSpace - if bcrypt.hashpw(password, valid_user.password) != valid_user.password: - db_session.close() - return None - - db_session.close() - return valid_user - except NoResultFound: - db_session.close() - return None - -def get_graph(user_id, graph_id): - ''' - Gets the graph. - - @param user_id: Owner of graph - @param graph_id: ID of graph - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Gets graph - graph = db_session.query(models.Graph).filter(models.Graph.user_id == user_id).filter(models.Graph.graph_id == graph_id).one() - db_session.close() - return graph - except NoResultFound: - db_session.close() - return None - -def graph_exists(user_id, graph_id): - ''' - Checks to if graph exists. - - @param user_id: Owner of graph - @param graph_id: ID of graph - ''' - graph = get_graph(user_id, graph_id) - - if graph == None: - return False - else: - return True - -def get_default_layout(uid, gid): - ''' - Gets the default layout for a graph. - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Retrieve the specific graph - graph_being_searched = get_graph(uid, gid) - - # Retrieve the saved layout from the database - default_layout = db_session.query(models.Layout).filter(models.Layout.layout_id == graph_being_searched.default_layout_id).one() - db_session.close() - - # Convert JSON to cytoscape recognized format - return json.dumps({"json": cytoscapePresetLayout(json.loads(default_layout.json))}) - except NoResultFound: - db_session.close() - return json.dumps(None) - -def get_default_layout_id(uid, gid): - ''' - Gets the default layout for a graph. - ''' - # Get the graph - graph = get_graph(uid, gid) - - if graph != None: - return graph.default_layout_id - else: - return None - -def get_layout(layout_id): - ''' - Gets the layout of ID. - - @param layout_id: Id of layout - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Gets the layout for that specific ID - layout = db_session.query(models.Layout).filter(models.Layout.layout_id == layout_id).one() - db_session.close() - return layout - except NoResultFound: - db_session.close() - return None - -def get_default_layout_name(uid, gid): - ''' - Gets the default layout for a graph. - ''' - # Get the specific graph - graph = get_graph(uid, gid) - - # If the graph exists and has a default layout id - if graph != None and graph.default_layout_id != None: - - # Get the layout from the database - default_layout = get_layout(graph.default_layout_id) - - # If the layout exists, return its name - if default_layout != None: - return default_layout.layout_name - else: - return None - -def set_task_layout_context(request, context, uid, gid, layout_name, layout_owner, approve=None, expert=None): - - context["Error"] = None - layout_to_view = get_default_layout(uid, gid) - context['default_layout'] = get_default_layout_id(uid, gid) - context['layout_name'] = get_default_layout_name(uid, gid) - - # Get all of the available layouts for this graph - db_session = data_connection.new_session() - - layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).first() - - if layout != None: - graph_json = get_layout_for_graph(layout.layout_name, layout.owner_id, gid, uid, layout.user_id) - - if approve: - if expert: - task_exists = db_session.query(models.Task).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.task_type == "APPROVE_TASK").filter(models.Task.worker_id == "EXPERT_WORKER").first() - else: - task_exists = db_session.query(models.Task).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.task_type == "APPROVE_TASK").filter(models.Task.worker_id != "EXPERT_WORKER").first() - else: - if expert: - task_exists = db_session.query(models.Task).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.task_type == "LAYOUT_TASK").filter(models.Task.worker_id == "EXPERT_WORKER").first() - else: - task_exists = db_session.query(models.Task).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.task_type == "LAYOUT_TASK").filter(models.Task.worker_id != "EXPERT_WORKER").first() - - if task_exists != None: - layout_to_view = json.dumps({"json": graph_json}) - context['layout_name'] = layout.layout_name - context['layout_owner'] = layout.owner_id - context["layout_to_view"] = layout_to_view - context['hit_id'] = task_exists.hit_id - return context - - context["layout_to_view"] = json.dumps({"json": None}) - return context - -def set_layout_context(request, context, uid, gid): - ''' - Sets the entire context of a graph to be viewed. This is needed for sending information to the front-end - :param request: HTTP Request of graph to view - :param context: Dictionary containing all the variables to send to the front-end - :param uid: The owner of the graph - :param gid: Graph name to view - :return context: Filled in dictionary with all variables to send to the front-end - ''' - layout_to_view = None - - # if there is a layout specified in the request (query term), then render that layout - if len(request.GET.get('layout', '')) > 0: - - # If the layout is not one of the automatic layout algorithms - if request.GET.get('layout') != 'default_breadthfirst' and request.GET.get('layout') != 'default_concentric' and request.GET.get('layout') != 'default_circle' and request.GET.get('layout') != 'default_cose' and request.GET.get('layout') != 'default_grid': - - # Check to see if the user is logged in - loggedIn = None - if 'uid' in context: - loggedIn = context['uid'] - - # Based on the logged in user and the graph, check to see if - # there exists a layout that matches the query term - graph_json = get_layout_for_graph(request.GET.get('layout'), request.GET.get('layout_owner'), gid, uid, loggedIn) - - # If the layout either does not exist or the user is not allowed to see it, prompt them with an error - if graph_json == None: - context['Error'] = "Layout: " + request.GET.get('layout') + " either does not exist or " + uid + " has not shared this layout yet. Click
    here to view this graph without the specified layout." - - # Return layout JSON - layout_to_view = json.dumps({"json": graph_json}) - context["layout_owner"] = request.GET.get('layout_owner') - - # Still set the default layout for the graph, if it exists - context['default_layout'] = get_default_layout_id(uid, gid) - else: - - # If there is a layout that is an automatic algorithm, simply - # return the default layout because the front-end JavaScript library - # handles the movement clientside - layout_to_view = get_default_layout(uid, gid) - context['default_layout'] = layout_to_view - - # Set layout name to add to the query term - context['layout_name'] = request.GET.get('layout') - else: - # If there is no layout specified, simply return the default layout - # if it exists - layout_to_view = get_default_layout(uid, gid) - context['default_layout'] = get_default_layout_id(uid, gid) - context['layout_name'] = get_default_layout_name(uid, gid) - - context['default_layout_name'] = get_default_layout_name(uid, gid) - # send layout information to the front-end - - # Pass information to the template - context['layout_to_view'] = layout_to_view - context['layout_urls'] = URL_PATH + "graphs/" + uid + "/" + gid + "?layout=" - - # If user is logged in, display my layouts and shared layouts - if 'uid' in context: - context['my_layouts'] = get_my_layouts_for_graph(uid, gid, context['uid']) - my_shared_layouts = get_my_shared_layouts_for_graph(uid, gid, context['uid']) - all_layouts_for_graph = get_shared_layouts_for_graph(uid, gid, context['uid']) + get_public_layouts_for_graph(uid, gid) + my_shared_layouts - unique_layouts = dict() - - # Filter out all the duplicate layouts - for layout in all_layouts_for_graph: - key = layout.graph_id + layout.user_id + layout.owner_id + layout.layout_name - if (key not in unique_layouts): - unique_layouts[key] = layout - - context['shared_layouts'] = unique_layouts.values() - - my_shared_layout_names = [] - # Get names of the layouts for comparison - for layout in my_shared_layouts: - if layout.layout_name not in my_shared_layout_names: - my_shared_layout_names.append(layout.layout_name) - - context['my_shared_layouts'] = my_shared_layout_names - - else: - # Otherwise only display public layouts - context['my_layouts'] = [] - context['shared_layouts'] = get_public_layouts_for_graph(uid, gid) - - # Check to see if task is launched for graph - exists = task_exists(gid, uid) - - print exists, gid, uid - - context["crowd_layouts"] = get_crowd_layouts_for_graph("MTURK_Worker", gid) - context['task_launched'] = exists - - return context - -def submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, shape_rating, color_rating, hit_id, expert=None): - ''' - Submits evaluation for a layout - - @param uid: Owner of graph - @param gid: Name of graph - @param layout_name: Name of layout - @param layout_owner: Owner of layout - @param evaluation: Evaluation of layout - ''' - - db_session = data_connection.new_session() - - layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).first() - - # If layout doesn't exist, return - if layout == None: - return - - # If it's an expert, than delete the task at once without paying worker - if expert: - # Add this evaluation to database - layout_eval = models.LayoutStatus(id=None, graph_id=gid, user_id=uid, layout_id=layout.layout_id, triangle_rating=triangle_rating, rectangle_rating=rectangle_rating, shape_rating=shape_rating, color_rating=color_rating, created=datetime.now(), submitted_by="EXPERT_WORKER") - db_session.add(layout_eval) - - task = db_session.query(models.Task).filter(models.Task.graph_id == gid).filter(models.Task.user_id == uid).filter(models.Task.layout_id == layout.layout_id).filter(models.Task.worker_id == "EXPERT_WORKER").first() - db_session.delete(task) - db_session.commit() - db_session.close() - return "Done" - - # Add this evaluation to database - layout_eval = models.LayoutStatus(id=None, graph_id=gid, user_id=uid, layout_id=layout.layout_id, triangle_rating=triangle_rating, rectangle_rating=rectangle_rating, shape_rating=shape_rating, color_rating=color_rating, created=datetime.now(), submitted_by="MTURK_Worker") - - db_session.add(layout_eval) - db_session.commit() - - # Get the task associated for this graph - task = db_session.query(models.Task).filter(models.Task.hit_id == hit_id).filter(models.Task.task_type == "APPROVE_TASK").first() - - if task == None: - return None - - submit = task.submitted - - # If layout has had 5 people look at it, then delete the task, otherwise increment submission count - if task.submitted == 5: - db_session.delete(task) - else: - task.submitted = submit + 1 - - db_session.commit() - - task_code = db_session.query(models.TaskCode.code).filter(models.TaskCode.hit_id == hit_id).first() - db_session.close() - - return task_code - -def get_crowd_layouts_for_graph(uid, gid): - ''' - Gets all the layouts submitted by crowdworkers. - @param uid: Owner of graph - @param gid: Name of graph - ''' - # Get database connection - db_session = data_connection.new_session() - - try: - # Get all the layouts for this graph. - crowd_layouts = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.owner_id == uid).all() - db_session.close() - return crowd_layouts - except NoResultFound: - db_session.close() - return [] - -def retrieve_cytoscape_json(graphjson): - ''' - Converts JSON to CytoscapeJS standards - - :param graphjson: JSON of graph to render on CytoscapeJS - :return JSON: CytoscapeJS-compatible graphname - ''' - - temp_json = json.loads(graphjson)['graph'] - - # for Cytoscape.js, if data is in properties, then we need to convert (main difference) - if 'data' in temp_json: - return convert_json(graphjson) - else: - return graphjson - -def get_base_urls(view_type): - ''' - Assigns urls to the blue buttons viewed at the graphs/ page - - :param view_type: Type of view (shared, public etc) - :return URL: Link to the specified view_type - ''' - - # Modify the url of the buttons depending on the page that the user is on - if view_type == 'shared': - return URL_PATH + "graphs/shared/" - elif view_type == 'public': - return URL_PATH + "graphs/public/" - elif view_type == 'all': - return URL_PATH + "graphs/all/" - else: - return URL_PATH + "graphs/" - -def get_all_info_for_graph(uid, gid): - ''' - Returns JSON, public, and graph id of the graph - - @param uid: Owner of graph - @param graph_id: ID of graph - ''' - # Get the graph - graph = get_graph(uid, gid) - - if graph == None: - return None - - graph.json = verify_json(graph.json) - - return (graph.json, graph.public, graph.graph_id) - -def get_graphs_for_view_type(context, view_type, uid, request): - ''' - Gets the graphs that are associated with a certain view from the user - - :param context: Dictionary containing values to pass to front-end - :param view_type: Type of view to render (my graphs, shared, public) - :param uid: Owner of the graph - :param request: Get request - :return context: Dictionary containing values to pass to front-end - ''' - - # Lists to hold all tag terms and search terms that are beign queried - tag_list = [] - search_list = [] - - # Keep track of type of search that user specified - search_type = None - - # Partial search may be thought of as "contains" matching - # Exact search may be though of as "identical" matching - if 'partial_search' in request.GET: - search_type = 'partial_search' - elif 'full_search' in request.GET: - search_type = 'full_search' - - # Get search terms from query - search_terms = request.GET.get(search_type) - - # Get tag terms from query - tag_terms = request.GET.get('tags') or request.GET.get('tag') - - # Get ordered terms for query (ordered being if they want to sort table by its columns) - order_by = request.GET.get('order') - - # Extract tags from query - if tag_terms and len(tag_terms) > 0: - cleaned_tags = tag_terms.split(',') - client_side_tags = "" - # Goes through each tag, making it a string - # so the url will contain those tags as a part - # of the query string - for tags in xrange(len(cleaned_tags)): - cleaned_tags[tags] = cleaned_tags[tags].strip() - # If user enters in a blank tag, delete it - if len(cleaned_tags[tags]) == 0: - del cleaned_tags[tags] - # Multiple tags are distinguished by commas, so we add them here - client_side_tags = client_side_tags + cleaned_tags[tags] + ',' - - # Remove the last comma since nothing comes after last tag - client_side_tags = client_side_tags[:len(client_side_tags) - 1] - - # Set tags to the cleaned tags we formulated from the query - # This is done to append to URL of the different view_types we can have - # For example: buttons containing My Graphs, Shared, and Public will - # have query string of tags appended to end of URL - # This happens in front-end (See graphs/templates/graphs/graphs.html) - context['tags'] = client_side_tags - - # This is for the side menu, each tag has its own button - context['tag_terms'] = cleaned_tags - - # Cleaned list of tags ready to be queried in view_graphs method - tag_list = cleaned_tags - - # Extract search terms from query - if search_terms and len(search_terms) > 0: - # Set to true so that front-end will know to apend - # a search term to all views (My Graphs, Shared, Public) - context['search_result'] = True - - # Split up search terms by comma - cleaned_search_terms = search_terms.split(',') - - # Search string to formulate that will contain all search terms - client_side_search = "" - - # Goes through each search term, making it a string - # so the url will contain those searches as a part - # of the query string - for i in xrange(len(cleaned_search_terms)): - cleaned_search_terms[i] = cleaned_search_terms[i].strip() - # Deleted no length search terms - if len(cleaned_search_terms[i]) == 0: - del cleaned_search_terms[i] - - # This is for the side menu, each search item has its own button - client_side_search = client_side_search + cleaned_search_terms[i] + ',' - - # Remove last comma - client_side_search = client_side_search[:len(client_side_search) - 1] - - # All context variables will be recognized in the front end - # See (See graphs/templates/graphs/graphs.html) - context['search_word'] = client_side_search - - # Type of search (partial or exact) -> Used to fill in radio button - context['search_type'] = search_type - - # Search terms (Used to append URL to view types: My Graphs, Shared, Public) - context['search_word_terms'] = cleaned_search_terms - - # Cleaned list of search terms to be queried on - search_list = cleaned_search_terms - - # If there is no one logged in, display only public graph results - # my_graphs represents all matching graphs which I own - # shared_graphs represents all matching graphs which are shared with me - # public graphs represent all matching graphs available to everyone - - # In order to produce the number of graphs returned that match the query - # (for the My Graphs, Shared, and Public buttons), I am also retrieving the len - # of matched graphs for each view_type. This feature was requesed by Murali - - # For every query, we need to make request for all the view types (shared, public, my graphs) - # because we want to notify the user the number of graphs that are available for each view - # that matches the queries - if uid == None: - context['graph_list'] = view_graphs(uid, search_type, search_terms, tag_list, 'public') - context['my_graphs'] = 0 - context['shared_graphs'] = 0 - if context['graph_list'] == None: - context['public_graphs'] = 0 - else: - context['public_graphs'] = len(context['graph_list']) - else: - if view_type == 'my graphs': - context['graph_list'] = view_graphs(uid, search_type, search_list, tag_list, view_type) - context['my_graphs'] = len(context['graph_list']) - context['shared_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'shared')) - context['public_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'public')) - elif view_type == 'shared': - context['graph_list'] = view_graphs(uid, search_type, search_list, tag_list, view_type) - context['my_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'my graphs')) - context['shared_graphs'] = len(context['graph_list']) - context['public_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'public')) - else: - context['graph_list'] = view_graphs(uid, search_type, search_list, tag_list, view_type) - context['my_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'my graphs')) - context['shared_graphs'] = len(view_graphs(uid, search_type, search_list, tag_list, 'shared')) - context['public_graphs'] = len(context['graph_list']) - - #If user has requested the graphs to be ordered in a certain manner, order them as requested - if order_by: - context['graph_list'] = order_information(order_by, search_terms, context['graph_list']) - else: - # By default, all graphs are ordered via descending modified date (as per Anna's request) - context['graph_list'] = order_information("modified_descending", search_terms, context['graph_list']) - - return context - -def setDefaultLayout(layoutName, graph_id, graph_owner): - ''' - Sets default layout of graph. - - @param layoutName: name of layout - @param graph_id: ID of graph - @param graph_owner: Owner of graph - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Check to see if graph exists - graph = db_session.query(models.Graph).filter(models.Graph.user_id == graph_owner).filter(models.Graph.graph_id == graph_id).first() - - if graph == None: - return "It appears as if the graph requested does not exist." - - # Check to see if the layout is either shared or exists in the database - layout = db_session.query(models.Layout).filter(models.Layout.graph_id == graph_id).filter(models.Layout.layout_name == layoutName).filter(or_(models.Layout.shared_with_groups == 1, models.Layout.public == 1)).first() - - if layout == None: - return "You can't set a layout as default layout for graph unless layout is shared and the graph is public!" - - # Update the default layout of the current graph - graph.default_layout_id = layout.layout_id - - db_session.commit() - db_session.close() - return None - except NoResultFound: - db_session.close() - return "Can't set default layout of layout that doesn't exist or you can't access." - -def removeDefaultLayout(layoutName, graph_id, graph_owner): - ''' - Removes default layout of graph. - - @param layoutName: name of layout - @param graph_id: ID of graph - @param graph_owner: Owner of graph - ''' - # Create database connection - db_session = data_connection.new_session() - - # Get graph being viewed - graph = db_session.query(models.Graph).filter(models.Graph.graph_id == graph_id).filter(models.Graph.user_id == graph_owner).first() - - if graph == None: - return "Graph does not exist!" - - # Get the layout to see if it exists - layout = db_session.query(models.Layout).filter(models.Layout.layout_id == models.Graph.default_layout_id).first() - - if layout == None: - return "Layout does not exist for this graph!" - - # If the default layout is deleted, update - # graph so that it has no default layout - graph.default_layout_id = None - db_session.commit() - db_session.close() - return None - - -def order_information(order_term, search_terms, graphs_list): - ''' - Orders all graph tuples based on order_term. - - :param order_term Term to order by (example, graph, modified, owner) - :param search_terms Search terms in query (Needed because all search terms add two column (link to graph and node labels) which offsets references by 2) - :param graph_list Tuples of graphs - :return sorted_list Sorted list of graph tuples according to order_term - ''' - # Each order_term corresponds to sortable columns in the graph tables - if search_terms: - if order_term == 'graph_ascending': - return sorted(graphs_list, key=lambda graph: graph[0]) - elif order_term == 'graph_descending': - return sorted(graphs_list, key=lambda graph: graph[0], reverse=True) - elif order_term == 'modified_ascending': - return sorted(graphs_list, key=lambda graph: graph[4]) - elif order_term == 'modified_descending': - return sorted(graphs_list, key=lambda graph: graph[4], reverse=True) - elif order_term == 'owner_ascending': - return sorted(graphs_list, key=lambda graph: graph[2]) - elif order_term == 'owner_descending': - return sorted(graphs_list, key=lambda graph: graph[2], reverse=True) - else: - return graphs_list - else: - if order_term == 'graph_ascending': - return sorted(graphs_list, key=lambda graph: graph.graph_id) - elif order_term == 'graph_descending': - return sorted(graphs_list, key=lambda graph: graph.graph_id, reverse=True) - elif order_term == 'modified_ascending': - return sorted(graphs_list, key=lambda graph: graph.modified) - elif order_term == 'modified_descending': - return sorted(graphs_list, key=lambda graph: graph.modified, reverse=True) - elif order_term == 'owner_ascending': - return sorted(graphs_list, key=lambda graph: graph.user_id) - elif order_term == 'owner_descending': - return sorted(graphs_list, key=lambda graph: graph.user_id, reverse=True) - else: - return graphs_list - -def view_graphs(uid, search_type, search_terms, tag_terms, view_type): - ''' - Gets the graphs that are associated with a certain view from the user - - :param uid: Owner of the graph - :param search_type: Type of search (partial or full) - :param search_terms: Criteria that to filter graphs - :param tag_terms: Only display graphs with these tags - :return context: Dictionary containing values to pass to front-end - ''' - - # If there are graphs that fit search and tag criteria - if search_terms and tag_terms and len(search_terms) > 0 and len(tag_terms) > 0: - actual_graphs = [] - - # Get all graphs that contain all the search terms - search_result_graphs = search_result(uid, search_type, search_terms, view_type) - - # Get all graphs that contain all the tag terms - tag_result_graphs = tag_result(uid, tag_terms, view_type) - - tag_graphs = [x[0] for x in tag_result_graphs] - actual = [x[0] for x in actual_graphs] - - # If it is not already part of final graphs returned, add it in - for graph in search_result_graphs: - if graph[0] in tag_graphs and graph[0] not in actual: - actual_graphs.append(graph) - - return actual_graphs - - # If there are only tag terms - elif tag_terms and len(tag_terms) > 0: - return tag_result(uid, tag_terms, view_type) - # If there are only search terms - elif search_terms and len(search_terms) > 0: - return search_result(uid, search_type, search_terms, view_type) - # Just display the graphs - else: - return view_graphs_of_type(view_type, uid) - -def tag_result(uid, tag_terms, view_type): - ''' - Gets all graphs that contain the specified tags for a user and a view_type. - - :param uid: Owner of graph - :param tag_terms: Tags that all graphs must contain - :param view_type: Type of view to display the graphs in (shared, public) - :return Graphs: [graphs] - ''' - if len(tag_terms) > 0: - # Place holder that stores all the graphs - initial_graphs_with_tags = [] - - # Create database connection - db_session = data_connection.new_session() - - # Go through all the tag terms, based on the view type and append them the initial place holder - for tag in tag_terms: - intial_graphs_with_tags = [] - - if view_type == 'my graphs': - try: - intial_graphs_with_tags += db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).filter(models.Graph.graph_id == models.GraphToTag.graph_id).filter(models.Graph.user_id == models.GraphToTag.user_id).filter(models.GraphToTag.tag_id == tag).filter(models.Graph.user_id == uid).all() - except NoResultFound: - print 'No graphs that you own match the tag term' - - elif view_type == 'shared': - - # Append all graphs that are shared with groups that the user is a member of - intial_graphs_with_tags += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.modified, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == models.GroupToUser.group_id).filter(models.GroupToGraph.group_owner == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == uid).filter(models.GraphToTag.tag_id == tag).filter(models.GraphToTag.graph_id == models.GroupToGraph.graph_id).filter(models.GraphToTag.user_id == models.GroupToGraph.user_id).all() - # Append all graphs that the user shared for any groups that they own - intial_graphs_with_tags += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.modified, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == models.Group.group_id).filter(models.Group.owner_id == uid).filter(models.GroupToGraph.group_owner == models.Group.owner_id).filter(models.GraphToTag.tag_id == tag).filter(models.GraphToTag.graph_id == models.GroupToGraph.graph_id).filter(models.GraphToTag.user_id == models.GroupToGraph.user_id).all() - else: - try: - intial_graphs_with_tags += db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).filter(models.Graph.graph_id == models.GraphToTag.graph_id).filter(models.Graph.user_id == models.GraphToTag.user_id).filter(models.GraphToTag.tag_id == tag).filter(models.Graph.public == 1).all() - except NoResultFound: - print 'No graphs that you own match the tag term' - - # Go through and count the list of occurrences of matched graph - graph_repititions = defaultdict(int) - - # Counting the number of occurences - for graph in intial_graphs_with_tags: - graph_repititions[graph] += 1 - - # Go through and aggregate all graph together - graph_mappings = defaultdict(list) - - # If the number of times a graph appears matches the number of search terms - # it is a graph we want (simulating the and operator for all search terms) - for graph in intial_graphs_with_tags: - - # Graph matches all search terms - if graph_repititions[graph] == len(tag_terms): - - # If we haven't seen this graph yet - if graph not in graph_mappings: - graph_mappings[graph] = graph - - # Go through all the graphs and insert tags for the graphs that match all search terms - return graph_mappings.values() - - else: - return [] - -def search_result(uid, search_type, search_terms, view_type): - ''' - Returns the graphs that match the search terms and the view type. - - :param uid: Owner of the graph - :param search_type: Type of search to perform (partial or full) - :param search_terms: Terms to search for - :param view_type: Type of view to render the graphs for - :return Graphs: [graphs] - ''' - - # If it is a search type that is not recognized, return empty list - if search_type != 'partial_search' and search_type != 'full_search': - return [] - - # Make into list if it is not a lsit - if not isinstance(search_terms, list): - search_terms = search_terms.split(',') - - # If there are any search terms - if len(search_terms) > 0: - - # List to keep track of all matched graphs - initial_graphs_from_search = [] - - # Get connection to database - data_session = data_connection.new_session() - - # Go through each search term, aggregating - # all graphs that match the specific search term - for search_word in search_terms: - # matched_graphs contains a list of all graphs that match the specific search term - matched_graphs = [] - # First, we check to see if there are any graphs that have a graph name that matches the search term - matched_graphs += find_all_graphs_containing_search_word(uid, search_type, search_word, view_type, data_session) - - # ":" indicates that search_word may be an edge - if ':' in search_word: - # append all graphs that contain an edge which matches the search_word - matched_graphs += find_all_graphs_containing_edges(uid, search_type, search_word, view_type, data_session) - # otherwise append all graphs that contain a node which matches the search word - else: - matched_graphs += find_all_graphs_containing_nodes(uid, search_type, search_word, view_type, data_session) - - # Go through all matched graphs - # If there is a graph that appears multiple times in the list - # combine their result. - # Effectively, a graph may appear at most one time for each search word - matched_graphs = combine_similar_graphs(matched_graphs) - - # Add condensed tuples to list of graphs matched - initial_graphs_from_search += matched_graphs - - # Go through and count the list of occurrences of matched graph - graph_repititions = defaultdict(int) - - # Counting the number of occurences - for graph_tuple in initial_graphs_from_search: - key = graph_tuple[0] + graph_tuple[4] - graph_repititions[key] += 1 - - # Go through and aggregate all graph together - graph_mappings = defaultdict(list) - - # If the number of times a graph appears matches the number of search terms - # it is a graph we want (simulating the and operator for all search terms) - for graph_tuple in initial_graphs_from_search: - key = graph_tuple[0] + graph_tuple[4] - - graph_tuple = list(graph_tuple) - - # Placeholder for tags of the graph - graph_tuple.insert(1, "") - - # Graph matches all search terms - if graph_repititions[key] == len(search_terms): - - # If we haven't seen this graph yet - if key not in graph_mappings: - graph_mappings[key] = tuple(graph_tuple) - else: - # Combine result of previous tuple - old_tuple = list(graph_mappings[key]) - - # If there is already a matching node/edge id - if len(old_tuple[2]) > 0 and len(graph_tuple[2]) > 0: - old_tuple[2] += ", " + graph_tuple[2] - old_tuple[3] += ", " + graph_tuple[3] - # Otherwise, simply insert this graph tuples id - else: - old_tuple[2] += graph_tuple[2] - old_tuple[3] += graph_tuple[3] - - graph_mappings[key] = tuple(old_tuple) - - # Go through all the graphs and insert tags for the graphs that match all search terms - return graph_mappings.values() - else: - return [] - -def combine_similar_graphs(matched_graphs): - ''' - Go through list of all matched graphs and combine results if graph appears multiple times. - - @param matched_graphs: List of graphs, nodes, edges that all have reference to graph id via respective models (SQLAlchemy) - ''' - graph_entry = dict() - - # Go through all the matching graphs/nodes/edges depending on the type of match - for graph in matched_graphs: - # If graph contains a matching node - if hasattr(graph, 'node_id'): - key = graph.graph_id + graph.user_id - # If graph has not been encountered yet, insert new tuple - if key not in graph_entry: - # Construct new entry - new_graph_entry = (graph.graph_id, graph.node_id + "(" + graph.label + ")", graph.label, graph.modified, graph.user_id) - - # Add to dict - graph_entry[key] = new_graph_entry - else: - # If graph has been discovered, append node id details to graph tuple - cur_graph_entry = list(graph_entry[key]) - if len(cur_graph_entry[1]) == 0: - cur_graph_entry[1] += graph.node_id + "(" + graph.label + ")" - cur_graph_entry[2] += graph.label - else: - cur_graph_entry[1] += ", " + graph.node_id + "(" + graph.label + ")" - cur_graph_entry[2] += ", " + graph.label - - # Add modified entry - graph_entry[key] = tuple(cur_graph_entry) - - # If graph contains a matching edge - elif hasattr(graph, 'head_node_id'): - key = graph.graph_id + graph.user_id - - # If graph has been encountered yet, insert new tuple - if key not in graph_entry: - graph_info = get_graph(graph.user_id, graph.graph_id) - - # Construct new entry - new_graph_entry = (graph.graph_id, graph.edge_id + "(" + graph.head_node_id + "-" + graph.tail_node_id + ")", graph.edge_id, graph_info.modified, graph_info.user_id, graph_info.public) - - # Add to dict - graph_entry[key] = new_graph_entry - else: - # If graph already has been encountered - cur_graph_entry = list(graph_entry[key]) - if len(cur_graph_entry[1]) == 0: - cur_graph_entry[1] += graph.edge_id + "(" + graph.head_node_id + "-" + graph.tail_node_id + ")" - cur_graph_entry[2] += graph.edge_id - else: - cur_graph_entry[1] += ", " + graph.edge_id + "(" + graph.head_node_id + "-" + graph.tail_node_id + ")" - cur_graph_entry[2] += ", " + graph.edge_id - - # Add appended entry - graph_entry[key] = tuple(cur_graph_entry) - - # If graph contains a term that is in the id of the graph - else: - key = graph.graph_id + graph.user_id - # If graph has not yet been encountered, append tuple to list of graphs encountered - if key not in graph_entry: - # Create new entry - new_graph_entry = (graph.graph_id, "", "", graph.modified, graph.user_id) - - # Add new entry to dict - graph_entry[key] = new_graph_entry - - return graph_entry.values() - -def find_all_graphs_containing_search_word(uid, search_type, search_word, view_type, db_session): - ''' - Finds graphs that have the matching graph name. - - :param uid: Owner of the graph - :param search_type: Type of search (full_search or partial_search) - :param search_word: Graph names being searched for - :param view_type: Type of view to limit the graphs to (my graphs, shared, public) - :param cur: Database cursor - :return Graphs: [Graphs] - ''' - matched_graphs = [] - # Return all graphs that have a graph name that partially matches the search word - if search_type == 'partial_search': - # Select graphs that match the given view type - if view_type == "my graphs": - matched_graphs = db_session.query(models.Graph.graph_id, models.Graph.user_id, models.Graph.modified).filter(models.Graph.graph_id.like("%" + search_word + "%")).filter(models.Graph.user_id == uid).all() - elif view_type == "shared": - matched_graphs = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.group_id == models.GroupToUser.group_id).filter(models.GroupToGraph.group_owner == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == uid).filter(models.GroupToGraph.graph_id.like("%" + search_word + "%")).all() - matched_graphs += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.graph_id.like("%" + search_word + "%")).filter(models.Group.owner_id == uid).filter(models.Group.group_id == models.GroupToGraph.group_id).filter(models.Group.owner_id == models.GroupToGraph.group_owner).all() - elif view_type == "public": - matched_graphs = db_session.query(models.Graph.graph_id, models.Graph.user_id, models.Graph.modified, models.Graph.public).filter(models.Graph.graph_id.like("%" + search_word + "%")).filter(models.Graph.public == 1).all() - - # Return all graphs that have a gaph name that exactly matches the search word - elif search_type == 'full_search': - # Select graphs that match the given view type - if view_type == "my graphs": - matched_graphs = db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id, models.Graph.public).filter(models.Graph.graph_id == search_word).filter(models.Graph.user_id == uid).all() - elif view_type == "shared": - matched_graphs = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.group_id == models.GroupToUser.group_id).filter(models.GroupToGraph.group_owner == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == uid).filter(models.GroupToGraph.graph_id == search_word).all() - matched_graphs += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.graph_id == search_word).filter(models.Group.owner_id == uid).filter(models.Group.group_id == models.GroupToGraph.group_id).filter(models.Group.owner_id == models.GroupToGraph.group_owner).all() - elif view_type == "public": - matched_graphs = db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id, models.Graph.public).filter(models.Graph.graph_id == search_word).filter(models.Graph.public == 1).all() - - graph_dict = dict() - - # Remove duplicates for all graphs that match have the same graph matching search term - for graph in matched_graphs: - key = graph.graph_id + graph.user_id - if key in graph_dict: - continue - else: - graph_dict[key] = graph - - return graph_dict.values() - -def find_all_graphs_containing_edges(uid, search_type, search_word, view_type, db_session): - ''' - Finds graphs that have the edges that are being searched for. - - :param uid: Owner of the graph - :param search_type: Type of search (partial_search or full_search) - :param search_word: Edge being searched for - :param view_type: Type of view to limit the graphs to - :param cur: Database cursor - :return Edges: [Edges] - ''' - - # List to keep track of all graphs that contain edges that match the search_word - initial_graphs_matching_edges = [] - - # Separate the edge into its two node ID's - # This is done because in the database, an edge ID is comprised of target:source nodes - node_ids = search_word.split(":") - - # Get head and tail node references - head_node = node_ids[0] - tail_node = node_ids[1] - - # List of all head node ids - head_nodes = [] - - # List of all tail node ids - tail_nodes = [] - - # Match all edges that contain the edges that exactly match the search_word - # TODO: Currently the match queries are case sensitive. In future we would like to make these queries case insensitive. - if search_type == "full_search": - - search_query = or_( - and_(models.Edge.head_node_id == head_node, models.Edge.tail_node_id == tail_node), - and_(models.Edge.tail_node_id == head_node, models.Edge.head_node_id == tail_node), - and_(models.Edge.head_node_label == head_node, models.Edge.tail_node_label == tail_node), - and_(models.Edge.tail_node_label == head_node, models.Edge.head_node_label == tail_node)) - - elif search_type == "partial_search": - - search_query = or_( - and_(models.Edge.head_node_id >= head_node, models.Edge.head_node_id <= head_node + 'zzz', - models.Edge.tail_node_id >= tail_node, models.Edge.tail_node_id <= tail_node + "zzz"), - and_(models.Edge.tail_node_id >= head_node, models.Edge.tail_node_id <= head_node + "zzz", - models.Edge.head_node_id >= tail_node, models.Edge.head_node_id <= tail_node + "zzz"), - and_(models.Edge.head_node_label >= head_node, models.Edge.head_node_label <= head_node + 'zzz', - models.Edge.tail_node_label >= tail_node, models.Edge.tail_node_label <= tail_node + "zzz"), - and_(models.Edge.tail_node_label >= head_node, models.Edge.tail_node_label <= head_node + 'zzz', - models.Edge.head_node_label >= tail_node, models.Edge.head_node_label <= tail_node + "zzz")) - - - # # Go through head and tail nodes to see if there are any graphs - # # that match the given view type (my graphs, shared, public). - # # In other words, return all graphs that having matching edges - # # for the given view type. - # - # # TODO: ASK MURALI ABOUT BIDIRECTION EDGES - # - - if view_type == "public": - initial_graphs_matching_edges = db_session.query( models.Edge ).filter(search_query).filter(models.Edge.graph_id == models.Graph.graph_id ).filter( models.Graph.public == 1 ).all() - elif view_type == "shared": - initial_graphs_matching_edges += db_session.query(models.Edge).filter(models.GroupToGraph.user_id == uid).filter(models.Edge.graph_id == models.GroupToGraph.graph_id).filter(search_query).filter(models.Edge.graph_id == models.Graph.graph_id).all() - else: - initial_graphs_matching_edges += db_session.query(models.Edge).filter(search_query).filter(models.Edge.graph_id == models.Graph.graph_id).filter(models.Edge.user_id == uid).all() - - graph_dict = dict() - - # Remove duplicates for all graphs that match have the same edge matching search term - for edge in initial_graphs_matching_edges: - key = edge.head_node_id + edge.graph_id + edge.user_id + edge.tail_node_id + edge.edge_id - if key in graph_dict: - continue - else: - graph_dict[key] = edge - - return graph_dict.values() - # else: - # return [] - -def find_all_graphs_containing_nodes(uid, search_type, search_word, view_type, db_session): - ''' - Finds graphs that have the nodes that are being searched for. - - :param uid: Owner of the graph - :param search_type: Type of search (partial_search or full_search) - :param search_word: Node being searched for - :param view_type: Type of view to limit the graphs to - :param db_session: Database session - :return Nodes: [Nodes] - ''' - - # Graphs that contained nodes matching the search_word - initial_graphs_matching_nodes = [] - - # If search type wants to partially match node - if view_type == "my graphs": - - if search_type == "partial_search": - # Get all partially matching nodes containing the label - initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label.like("%" + search_word + "%")).filter(models.Node.user_id == uid).all() - - # Get all partially matching nodes containing the node id - initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id.like("%" + search_word + "%")).filter(models.Node.user_id == uid).all() - else: - # Get all partially matching nodes containing the label - initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label == search_word).filter(models.Node.user_id == uid).all() - - # Get all partially matching nodes containing the node id - initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id == search_word).filter(models.Node.user_id == uid).all() - - # Shared graphs - elif view_type == "shared": - # Get all the groups that a user is a member of - groups_user_belongs_to = db_session.query(models.GroupToUser.group_id, models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == uid).all() - - # Get all graphs that are part of groups that the user belongs to - graphs_in_group = list() - - # Go through each group and add graphs keys to the set - for single_group in groups_user_belongs_to: - group_id = single_group.group_id - group_owner = single_group.group_owner - - graphs_in_group += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == group_id).filter(models.GroupToGraph.group_owner == group_owner).all() - - # Go through all groups that the user owns - groups_user_owns = db_session.query(models.Group).filter(models.Group.owner_id == uid).all() - - for single_group in groups_user_owns: - graphs_in_group += db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == single_group.group_id).filter(models.GroupToGraph.group_owner == single_group.owner_id).all() - - if search_type == "partial_search": - # Get all graphs that contain a partially matched label and user does not own (since it's shared) - all_matched_node_graphs = db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label.like("%" + search_word + "%")).all() - - # Collect all graphs that are shared with user and matches terms - final_graphs = [] - - # Go through all matched graphs to see which graphs - # are also shared with user and take the intersection - for matched in all_matched_node_graphs: - search_graph = (matched.graph_id, matched.user_id) - if search_graph in graphs_in_group: - final_graphs.append(matched) - - # Get all graphs that contain a partially matched node and user does not own (since it's shared) - all_matched_node_graphs = db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id.like("%" + search_word + "%")).all() - - # Go through all matched graphs to see which graphs - # are also shared with user and take the intersection - for matched in all_matched_node_graphs: - search_graph = (matched.graph_id, matched.user_id) - if search_graph in graphs_in_group: - final_graphs.append(matched) - - initial_graphs_matching_nodes = final_graphs - else: - # Get all graphs that contain a partially matched label and user does not own (since it's shared) - all_matched_node_graphs = db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label == search_word).all() - - # Collect all graphs that are shared with user and matches terms - final_graphs = [] - - # Go through all matched graphs to see which graphs - # are also shared with user and take the intersection - for matched in all_matched_node_graphs: - search_graph = (matched.graph_id, matched.user_id) - if search_graph in graphs_in_group: - final_graphs.append(matched) - - # Get all graphs that contain a partially matched node and user does not own (since it's shared) - all_matched_node_graphs = db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id == search_word).all() - - # Go through all matched graphs to see which graphs - # are also shared with user and take the intersection - for matched in all_matched_node_graphs: - search_graph = (matched.graph_id, matched.user_id) - if search_graph in graphs_in_group: - final_graphs.append(matched) - - initial_graphs_matching_nodes = final_graphs - # public graphs - else: - if search_type == "partial_search": - # Get all partially matching nodes containing the label - initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label.like("%" + search_word + "%")).filter(models.Node.graph_id == models.Graph.graph_id).filter(models.Node.user_id == models.Graph.user_id).filter(models.Graph.public == 1).all() - - # Get all partially matching nodes containing the node id - initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id.like("%" + search_word + "%")).filter(models.Node.graph_id == models.Graph.graph_id).filter(models.Node.user_id == models.Graph.user_id).filter(models.Graph.public == 1).all() - else: - # Get all partially matching nodes containing the label - initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.label == search_word).filter(models.Node.graph_id == models.Graph.graph_id).filter(models.Node.user_id == models.Graph.user_id).filter(models.Graph.public == 1).all() - - # Get all partially matching nodes containing the node id - initial_graphs_matching_nodes += db_session.query(models.Node.graph_id, models.Node.node_id, models.Node.label, models.Node.modified, models.Node.user_id).filter(models.Node.node_id == search_word).filter(models.Node.graph_id == models.Graph.graph_id).filter(models.Node.user_id == models.Graph.user_id).filter(models.Graph.public == 1).all() - - graph_dict = dict() - - # Remove duplicates for all graphs that match have the same node id and label matching search term - for graph in initial_graphs_matching_nodes: - key = graph.graph_id + graph.user_id + graph.label + graph.node_id - if key in graph_dict: - continue - else: - graph_dict[key] = graph - - return graph_dict.values() - -def uploadCyjsFile(username, graphJSON, title): - ''' - Uploads a .cyjs file as a JSON via /upload. - - @param username: Owner of graph - @param graphJSON: CYJS of graph - @param tile: Title of graph - ''' - has_node_positions = True - try: - # Create JSON stucture for GraphSpace recognized JSON - parseJson = {"graph": {"edges": [], "nodes": []}, "metadata": {}} - - # Load JSON from string - csjs = json.loads(graphJSON) - - # If there is no elements that exist in the provided JSON - if 'elements' not in csjs: - return {"Error": "No elements property inside of file!"} - - # If there is no nodes that exist in the provided JSON - if 'nodes' not in csjs['elements']: - return {"Error": "File must contain nodes property in elements dictionary!"} - - # If there is no edges that exist in the provided JSON - if 'edges' not in csjs['elements']: - return {"Error": "File must contain edges property in elements dictionary!"} - - node_positions = [] - # Go through nodes and translate properties so CytoscapeJS may render - for node in csjs['elements']['nodes']: - - # Container for translated node - tempNode = {"data": {}} - - # Copy over ID - tempNode['data']['id'] = node['data']['id'] - - if 'position' in node and 'x' in node['position'] and 'y' in node['position']: - tempNode['data']['x'] = node['position']['x'] - tempNode['data']['y'] = node['position']['y'] - node_positions.append({ - 'x': node['position']['x'], - 'y': node['position']['y'], - 'id': node['data']['id'] - }) - else: - has_node_positions = False - - # Change color property to background color - if 'node_fillColor' in node['data'] and len(node['data']['node_fillColor']) > 0: - # tempNode['data']['background_color'] = rgb_to_hex(node['data']['node_fillColor']) - tempNode['data']['background_color'] = node['data']['node_fillColor'] - - # If user wants to display something in node, add 'content' - if 'name' in node['data']: - tempNode['data']['content'] = node['data']['name'] - - # No shape is provided as far as I know, so I pad in an ellipse - tempNode['data']['shape'] = "ellipse" - parseJson['graph']['nodes'].append(tempNode) - - # Go through all the edges - for edge in csjs['elements']['edges']: - - tempEdge = {"data": {}} - - # Copy over source and target - tempEdge['data']['source'] = edge['data']['source'] - tempEdge['data']['target'] = edge['data']['target'] - - # If there is a name property, it will be in a popup - if 'name' in edge['data']: - tempEdge['data']['popup'] = edge['data']['name'] - - # Add edges to json - parseJson['graph']['edges'].append(tempEdge) - - # If there is a title in the graph - if 'name' in csjs['data']: - parseJson['metadata']['name'] = csjs['data']['name'] - else: - parseJson['metadata']['name'] = "temp_graph" - - # No tags or description since CYJS doesn't give me any - parseJson['metadata']['tags'] = [] - parseJson['metadata']['description'] = "" - - title = title or parseJson['metadata']['name'] - - # Insert converted graph to GraphSpace and provide URL - # for logged in user - - if username != None: - result = insert_graph(username, title, json.dumps(parseJson)) - if result == None: - if has_node_positions: - save_layout(title, username, 'cyjs_layout', username, json.dumps(node_positions), False, False, default=True) - return {"Success": URL_PATH + "graphs/" + username + "/" + title} - else: - return {"Error": result} - else: - # Create a unique user and insert graph for that name - public_user_id = "Public_User_" + str(uuid.uuid4()) + '@temp.com' - public_user_id = public_user_id.replace('-', '_') - - first_request = create_public_user(public_user_id) - - if first_request == None: - import time - start = time.time() - result = insert_graph(public_user_id, title, json.dumps(parseJson)) - if result == None: - if has_node_positions: - save_layout(title, public_user_id, 'cyjs_layout', public_user_id, json.dumps(node_positions), False, False, default=True) - return {"Success": URL_PATH + "graphs/" + public_user_id + "/" + title} - else: - return {"Error": result} - else: - return {"Error": result} - except Exception as ex: - return {"Error": "Seems to be an error with " + ex.message + " property."} - -def uploadJSONFile(username, graphJSON, title): - ''' - Uploads JSON file to GraphSpace via /upload. - - @param username: Owner of graph - @param graphJSON: JSON of graph - @param title: Title of graph - - ''' - - try: - # Loads JSON format - parseJson = json.loads(graphJSON) - - # Creates metadata tag - if 'metadata' not in parseJson: - parseJson['metadata'] = {} - - # If name is not provided, name is data - if 'name' not in parseJson['metadata']: - parseJson['metadata']['name'] = "graph_" + str(datetime.now()) - - title = title or parseJson['metadata']['name'] - - # Insert converted graph to GraphSpace and provide URL - # for logged in user - if username != None: - result = insert_graph(username, title, json.dumps(parseJson)) - if result == None: - return {"Success": URL_PATH + "graphs/" + username + "/" + title} - else: - return {"Error": result} - else: - # Create a unique user and insert graph for that name - public_user_id = "Public_User_" + str(uuid.uuid4()) + '@temp.com' - public_user_id = public_user_id.replace('-', '_') - - first_request = create_public_user(public_user_id) - - if first_request == None: - result = insert_graph(public_user_id, title, json.dumps(parseJson)) - if result == None: - return {"Success": URL_PATH + "graphs/" + public_user_id + "/" + title} - else: - return {"Error": result} - else: - return {"Error": result} - except Exception as ex: - return {"Error": ex} - -def delete_30_day_old_anon_graphs(): - # Create database connection - db_session = data_connection.new_session() - - # If there are any graphs owned by a public user that are older than 30 days, delete them - try: - graph = db_session.query(models.Graph).filter(models.Graph.user_id.like("%Public_User_%")).filter(models.Graph.created >= date('now', '-30 day')) - - db_session.delete(graph) - db_session.commit() - db_session.close() - except NoResultFound: - db_session.close() - -def rgb_to_hex(rgb): - # Quick wrapper method to - # convert rgb values to hex values - rgbTuple = rgb.split(',') - rgbNum = [] - for tup in rgbTuple: - try: - rgbNum.append(int(tup)) - except ValueError: - rgbNum.append(tup); - - - rgbNum = tuple(rgbNum) - return '#%02x%02x%02x' % rgbNum - -def create_public_user(public_user_id): - ''' - Creates a public user (temporary) - - @param public_user_id: Id of user - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Create a public user and add to database - public_user = models.User(user_id = public_user_id, password = "public", admin = 0) - db_session.add(public_user) - db_session.commit() - db_session.close() - return None - except NoResultFound: - db_session.close() - - -def delete_public_user(): - ''' - Deletes all public users from database. - ''' - - # Create database connection - db_session = data_connection.new_session() - - try: - # Delete all public users - public_users = db_session.query(models.User).filter(models.User.password == "public").all() - - for user in public_user: - db_session.delete(user) - - db_session.commit() - db_session.close() - - except NoResultFound: - db_session.close() - return None - -def find_edge(uid, gid, edge_to_find, search_type): - ''' - Finds the id of the edge inside graph - Used for highlighting elements inside the graph - - :param uid: Owner of graph - :param gid: Name of graph that is being viewed - :param edge_to_find: Edge that is being searched for - :param search_type: Partial or full matching - :return ID: [ID of edge] - ''' - # Create database connection - db_session = data_connection.new_session() - - # Extract nodes from input - head_node = edge_to_find.split(':')[0] - tail_node = edge_to_find.split(':')[1] - - # List containing edges - edge_list = [] - - # Filter by search type - if search_type == "partial_search": - # If there is a head and tail node - if len(head_node) > 0 and len(tail_node) > 0: - - # Find node id's that are being searched for (source and target nodes) - head_nodes = find_node(uid, gid, head_node, 'partial_search') - tail_nodes = find_node(uid, gid, tail_node, 'partial_search') - - # Go through all permutations of head and tail node - # to account for undirected edges - for i in xrange(len(tail_nodes)): - for j in xrange(len(head_nodes)): - - try: - # Aggregate all matching edges (DO THIS TWO TIMES SO ORDER OF HEAD OR TAIL NODE DOESN'T MATTER... THIS IS TO RESOLVE UNDIRECTED EDGE SEARCHING) - matching_edges = db_session.query(models.Edge).filter(models.Edge.head_node_id == head_nodes[j]).filter(models.Edge.tail_node_id == tail_nodes[i]).filter(models.Edge.user_id == uid).filter(models.Edge.graph_id == gid).all() - edge_list += matching_edges - - # # Aggregate all matching edges (DO THIS TWO TIMES SO ORDER OF HEAD OR TAIL NODE DOESN'T MATTER... THIS IS TO RESOLVE UNDIRECTED EDGE SEARCHING) - # matching_edges = db_session.query(models.Edge).filter(models.Edge.tail_node_id == head_nodes[j]).filter(models.Edge.head_node_id == tail_nodes[i]).filter(models.Edge.user_id == uid).filter(models.Edge.graph_id == gid).all() - # edge_list += matching_edges - - except NoResultFound: - print "No matching edges" - - else: - - # Find node id's that are being searched for (source and target nodes) - head_nodes = find_node(uid, gid, head_node, 'full_search') - tail_nodes = find_node(uid, gid, tail_node, 'full_search') - - # Go through all permutations of head and tail node - # to account for undirected edges - for i in xrange(len(tail_nodes)): - for j in xrange(len(head_nodes)): - - # If both nodes exist, find label between them - if tail_node != None and head_node != None: - - try: - # Aggregate all matching edges (DO THIS TWO TIMES SO ORDER OF HEAD OR TAIL NODE DOESN'T MATTER... THIS IS TO RESOLVE UNDIRECTED EDGE SEARCHING) - matching_edges = db_session.query(models.Edge).filter(models.Edge.head_node_id == head_nodes[j]).filter(models.Edge.tail_node_id == tail_nodes[i]).filter(models.Edge.user_id == uid).filter(models.Edge.graph_id == gid).all() - edge_list += matching_edges - - # # Aggregate all matching edges (DO THIS TWO TIMES SO ORDER OF HEAD OR TAIL NODE DOESN'T MATTER... THIS IS TO RESOLVE UNDIRECTED EDGE SEARCHING) - # matching_edges = db_session.query(models.Edge).filter(models.Edge.tail_node_id == head_node).filter(models.Edge.head_node_id == tail_node).filter(models.Edge.user_id == uid).filter(models.Edge.graph_id == gid).all() - # edge_list += matching_edges - - except NoResultFound: - print "No matching edges" - - # Get all labels from edges - edge_labels = [] - for edge in edge_list: - edge_labels.append(edge.edge_id) - - return edge_labels - -def find_node(uid, gid, node_to_find, search_type): - ''' - Finds the id of the node inside graph - Used for highlighting elements inside the graph - - :param uid: Owner of graph - :param gid: Name of graph that is being viewed - :param search_type: partial or full matching - :param node_to_find: Node that is being searched for - :param search_type: Partial or full matching - :return ID: [ID of node] - ''' - - # Create database connection - db_session = data_connection.new_session() - - try: - id_list = [] - # Filter by search types - if search_type == "partial_search": - - # Get all matching labels - labels = db_session.query(models.Node.node_id).filter(models.Node.label.like("%" + node_to_find + "%")).filter(models.Node.user_id == uid).filter(models.Node.graph_id == gid).all() - - # Get all matching ids - node_ids = db_session.query(models.Node.node_id).filter(models.Node.node_id.like("%" + node_to_find + "%")).filter(models.Node.user_id == uid).filter(models.Node.graph_id == gid).all() - - for label in labels: - if label not in id_list: - id_list.append(label[0]) - - for node_id in node_ids: - if node_id not in id_list: - id_list.append(node_id[0]) - - else: - # Get all matching labels - labels = db_session.query(models.Node.node_id).filter(models.Node.label == node_to_find).filter(models.Node.user_id == uid).filter(models.Node.graph_id == gid).all() - - # Get all matching ids - node_ids = db_session.query(models.Node.node_id).filter(models.Node.node_id == node_to_find).filter(models.Node.user_id == uid).filter(models.Node.graph_id == gid).all() - - # if label != None and label not in id_list: - # id_list.append(label[0]) - - # if node_id != None and node_id not in id_list: - # id_list.append(node_id[0]) - - for label in labels: - if label not in id_list: - id_list.append(label[0]) - - for node_id in node_ids: - if node_id not in id_list: - id_list.append(node_id[0]) - - db_session.close() - return id_list - except NoResultFound: - db_session.close() - return [] - -def intersect(a, b): - return list(set(a) & set(b)) - -def add_unique_to_list(listname, data): - ''' - Adds all unique items to the specified list - Also checks to see if the length is > 0 for each item - inserted into the list - - :param listname: List to put unique elements in - :param data: Possible duplicate data to search through - :return listname: [Unique elements] - ''' - for element in data: - if element not in listname and len(element) > 0: - listname.append(element) - - return listname - -# -------------------------- REST API ------------------------------- - -def insert_graph(username, graphname, graph_json, created=None, modified=None, public=0, shared_with_groups=0, default_layout_id=None): - ''' - Inserts a uniquely named graph under a username. - - :param username: Email of user in GraphSpace - :param graphname: Name of graph to insert - :param graph_json: JSON of graph - :param created: When was graph created - :param public: Is graph public? - :param shared_with_groups: Is graph shared with any groups? - :param default_layout_id: Default layout of the graph - ''' - - # Check to see if graph already exists - graph_exists = get_graph(username, graphname) - - # If graph already exists for user, alert them - if graph_exists != None: - return 'Graph ' + graphname + ' already exists for ' + username + '!' - - # Create database connection - db_session = data_connection.new_session() - - validationErrors = validate_json(graph_json) - - if validationErrors != None: - return validationErrors - - # Get the current time - curTime = datetime.now() - - # Load JSON string into JSON structure - graphJson = json.loads(graph_json) - - # Needed for old graphs, converts CytoscapeWeb to CytoscapeJS standard - if 'data' in graphJson['graph']: - graphJson = json.loads(convert_json(graph_json)) - - # Attach ID's to each edge for traversing the element - graphJson = assign_edge_ids(graphJson) - - nodes = graphJson['graph']['nodes'] - - # If we're not passed in any time values, use the current time as timestamps - if modified == None and created == None: - modified = curTime - created = curTime - - # If we're given a creation time but no modified time, use current time - elif modified == None: - modified = curTime - - # If we're given a modified time but no creation time, use current time - elif created == None: - created = curTime - - # Construct new graph to add to database - new_graph = models.Graph(graph_id = graphname, user_id = username, json = json.dumps(graphJson, sort_keys=True, indent=4), created = created, modified = modified, public = public, shared_with_groups = shared_with_groups, default_layout_id = default_layout_id) - - db_session.add(new_graph) - db_session.commit() - - if 'tags' in graphJson['metadata']: - tags = graphJson['metadata']['tags'] - else: - tags = [] - - # Insert all tags for this graph into tags database - insert_data_for_graph(graphJson, graphname, username, tags, nodes, curTime, 0, db_session) - - db_session.close() - - # If everything works, return Nothing - return None - -def insert_data_for_graph(graphJson, graphname, username, tags, nodes, modified, public, db_session): - ''' - Inserts metadata about a graph into its respective tables. - - :param graphJson: JSON of graph - :param graphname: Name of graph - :username: Name of user - :param: Tags of graph - :param nodes: Nodes to insert into nodes table - :param modified: Modification date of tabe - :param public: Nodes to insert into nodes table - :param db_session: Database connection - ''' - # Add all tags for this graph into graph_tag and graph_to_tag tables - for tag in tags: - tag_exists = db_session.query(models.GraphTag).filter(models.GraphTag.tag_id == tag).first() - - # If the tag doesn't already exists in the database, add it - if tag_exists == None: - new_tag = models.GraphTag(tag_id = tag) - db_session.add(new_tag) - db_session.commit() - - # Add to Graph to Tag table so that we can retrieve all graphs with tag - new_graph_to_tag = models.GraphToTag(graph_id = graphname, user_id = username, tag_id = tag) - db_session.add(new_graph_to_tag) - db_session.commit() - - # Go through edges and parse them accordingly - edges = graphJson['graph']['edges'] - - # If there are edges with same source and directed - dupEdges = [] - - # Number to differentiate between two duplicate edges - rand = 0 - - # Stores mapping from node id to node label. This mapping is used to fetch node (head/tail) label while storing the edges. - node_label_dict = {} - - # Go through all nodes in JSON and add to node table - for node in nodes: - # Used for backwards-compatibility since some JSON have label - # but new CytoscapeJS uses the content property - if 'label' in node['data']: - node['data']['content'] = node['data']['label'] - del node['data']['label'] - - # If the node has any content inside of it, display that content, otherwise, just make it an empty string - if 'content' not in node['data']: - node['data']['content'] = "" - - # Add node to table - new_node = models.Node(node_id = node['data']['id'], label = node['data']['content'], user_id = username, graph_id = graphname, modified = modified) - - node_label_dict[str(node['data']['id'])] = str(node['data']['content']) - - db_session.add(new_node) - db_session.commit() - - for edge in edges: - # Is the edge directed? - is_directed = 1 - - # Make edge undirected if it doesn't have target_arrow_shape attribute - if 'target_arrow_shape' not in edge['data']: - edge['data']['target_arrow_shape'] = "none" - is_directed = 0 - - # To make sure int and floats are also accepted as source and target nodes of an edge - source_node_id = str(edge['data']['source']) - target_node_id = str(edge['data']['target']) - - if source_node_id in node_label_dict: - source_node_label = node_label_dict[source_node_id] - else: - source_node_label = "" - - if target_node_id in node_label_dict: - target_node_label = node_label_dict[target_node_id] - else: - target_node_label = "" - - # Keep track of all the duplicate edges - # If there are two duplicate edges, append a counter and store it as an ID - if source_node_id + '-' + target_node_id in dupEdges: - rand += 1 - if 'id' not in edge['data']: - edge['data']['id'] = source_node_id + '-' + target_node_id + rand - - - # If this is first time we've seen an edge, simply get its ID without the counter - else: - if 'id' not in edge['data']: - edge['data']['id'] = source_node_id + '-' + target_node_id - - dupEdges.append(source_node_id + '-' + target_node_id) - - # TRICKY NOTE: An edge's ID is used as the label property - # The reason is because edge uses an 'id' column as the primary key. - # The label was the column I decided to avoid completely reconstructing the database - # POSSIBLE SOLUTION: If edge is bidirectional, we insert two edges with inverse source and target nodes - - # We are storing head_node_label and tail_node_label to speed up the similar terms search query on edges. Looking up in two tables was taking up too much time. - # head_node_label and tail_node_label are part of index `edge_idx_head_label_tail_label`. This helped in reducing the query time. - new_edge = models.Edge(user_id = username, graph_id = graphname, head_node_id = source_node_id, head_node_label = source_node_label, tail_node_id = target_node_id, tail_node_label = target_node_label, edge_id = edge['data']['id'], directed = is_directed, id = None) - - db_session.add(new_edge) - db_session.commit() - -def update_graph(username, graphname, graph_json): - ''' - Updates the JSON for a graph. - - :param username: Email of user in GraphSpace - :param graphname: Name of graph to insert - :param graph_json: JSON of graph - ''' - - # Get graph - graph = get_graph(username, graphname) - - # If graph doesn't exist - if graph == None: - return "Can't update " + graphname + " because it does not exist for " + username - - # Get database connection - db_session = data_connection.new_session() - - # Delete from graph - db_session.delete(graph) - db_session.commit() - - try: - # Get all tags for the graph - gt_to_delete = db_session.query(models.GraphToTag).filter(models.GraphToTag.graph_id == graphname).filter(models.GraphToTag.user_id == username).delete() - - except NoResultFound: - print "No tags for graph" - - try: - # Delete from edge - edges_to_delete = db_session.query(models.Edge).filter(models.Edge.graph_id == graphname).filter(models.Edge.user_id == username).delete() - - except NoResultFound: - print "No edges in graph to delete" - - try: - # Delete from node - nodes_to_delete = db_session.query(models.Node).filter(models.Node.graph_id == graphname).filter(models.Node.user_id == username).delete() - - except NoResultFound: - print "No nodes in graph to delete" - - db_session.commit() - - curTime = datetime.now() - # Re-insert graph - result = insert_graph(username, graphname, graph_json, graph.created, curTime, graph.public, graph.shared_with_groups, graph.default_layout_id) - - # Update modified time for group to graph table - group_to_graph = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).filter(models.Graph.graph_id == graphname).filter(models.Graph.user_id == username).first() - - if group_to_graph != None: - group_to_graph.modified = curTime - db_session.commit() - - return result - -def get_graph_json(username, graphname): - ''' - Get the JSON of the graph to view. - - :param username: Email of user in GraphSpace - :param password: Password of the user - :return JSON: JSON of graph to view - ''' - # Get the graph - graph = get_graph(username, graphname) - - # If graph doesn't exist, return None - if graph == None: - return None - - return graph.json - -def delete_graph(username, graphname): - ''' - Deletes graph from database. - - :param username: Email of user in GraphSpace - :param password: Password of the user - ''' - - # Get graph - graph = get_graph(username, graphname) - - if graph == None: - return - - # Create database connection - db_session = data_connection.new_session() - - try: - # Delete graph - db_session.delete(graph) - - db_session.commit() - # Delete from graph_to_tag - gt = db_session.query(models.GraphToTag).filter(models.GraphToTag.user_id == username).filter(models.GraphToTag.graph_id == graphname).delete() - - # Delete from group_to_graph - gg = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.user_id == username).filter(models.GroupToGraph.graph_id == graphname).delete() - - # Delete from edge - edge = db_session.query(models.Edge).filter(models.Edge.graph_id == graphname).filter(models.Edge.user_id == username).delete() - - # Delete from node - node = db_session.query(models.Node).filter(models.Node.user_id == username).filter(models.Node.graph_id == graphname).delete() - - # Delete from layout - layout = db_session.query(models.Layout).filter(models.Layout.graph_id == graphname).filter(models.Layout.user_id == username).delete() - - db_session.commit() - - task = db_session.query(models.Task).filter(models.Task.graph_id == graphname).filter(models.Task.user_id == username).all() - - for t in task: - db_session.delete(t) - db_session.commit() - - db_session.close() - - except Exception as ex: - print ex - db_session.close() - return - -def get_all_graphs_for_user(username): - ''' - Gets all graphs for username - - :param username: Email of user in GraphSpace - :return Graphs: [graphs] - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Get all graphs user owns - user_graphs = db_session.query(models.Graph).filter(models.Graph.user_id == username).all() - - # Get all names of graphs that user owns - cleaned_user_graph_names = [] - - # Get rid of unicode - for graph in user_graphs: - cleaned_user_graph_names.append(graph.graph_id) - - db_session.close() - return cleaned_user_graph_names - except NoResultFound: - db_session.close() - return [] - -def get_graphs_in_group(group_id, group_owner): - ''' - Gets graphs in a group. - - @param group_id: Id of group - @param group_owner: Owner of group - ''' - - # Create database connection - db_session = data_connection.new_session() - - try: - # Gets all the graphs in the group - graphs_in_group = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == group_id).filter(models.GroupToGraph.group_owner == group_owner).all() - - db_session.close() - return graphs_in_group - except NoResultFound: - db_session.close() - return [] - -def get_groups_of_user(user_id): - ''' - Get all groups that the user owns - - :param user_id: Email of user of GraphSpace - :return Groups: [group information] - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Get all groups that user owns - owned_groups = db_session.query(models.Group).filter(models.Group.owner_id == user_id).all() - - # Get information about graphs in group and format it - complete_group_information = get_cleaned_group_data(owned_groups, db_session) - - db_session.close() - return complete_group_information - except NoResultFound: - db_session.close() - return None - -def get_cleaned_group_data(data, db_session): - ''' - Get all information about group (including number of graphs group has) - - :param data: Information about group - :param cur: Database cursor - :return Groups: [gorup information + graphs in group information] - ''' - - # Get information about how many graphs each group contains - complete_group_information = [] - - # For each group that is provided, append the amount of graphs that belong in the group - # and return it as a tuple - - # Used as the primary method for /groups page - for group in data: - cleaned_group = [] - cleaned_group.append(group.name) - cleaned_group.append(group.description) - cleaned_group.append(group.owner_id) - - graphs = db_session.query(models.Graph.graph_id).filter(models.GroupToGraph.group_id == group.group_id).filter(models.GroupToGraph.group_owner == group.owner_id).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).all() - - cleaned_group.append(len(graphs)) - - # cleaned_group.append(group.public) - # cleaned_group.append(group.shared_with_groups) - cleaned_group.append(group.group_id) - complete_group_information.append(tuple(cleaned_group)) - - return complete_group_information - -def get_all_groups_with_member(user_id, skip = None): - ''' - Get all groups that has the user as a member in that group. - - :param user_id: Member to be searched for in all groups - :return Groups: [Groups that user_id is a member of] - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - cleaned_groups = [] - # Get all groups that the user is a member of - groups_with_member = db_session.query(models.Group).filter(models.Group.group_id == models.GroupToUser.group_id).filter(models.Group.owner_id == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == user_id).all() - - if skip == None: - # Format group information - cleaned_groups = get_cleaned_group_data(groups_with_member, db_session) - else: - # Get all groups that the user is a member of - groups_with_member += db_session.query(models.Group).filter(models.Group.owner_id == user_id).all() - cleaned_groups = groups_with_member - - db_session.close() - return cleaned_groups - except NoResultFound: - db_session.close() - return None - -def change_description(username, groupId, groupOwner, desc): - ''' - Changes description of group. - - :param username: person who is requesting the change - :param groupId: ID of group to change description - :param groupOwner: Owner of the group - :param desc: Description to change to - :return Error: - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - if username != groupOwner: - return "You can only change description of group that you own!" - - # Get the group to change the description of - group = db_session.query(models.Group).filter(models.Group.owner_id == groupOwner).filter(models.Group.group_id == groupId).one() - - group.description = desc - - db_session.commit() - db_session.close() - return None - except Exception as ex: - db_session.close() - return ex - -def get_group_by_id(groupOwner, groupId): - ''' - Gets a group information by group id ( REST API option). - - :param groupOwner: Owner of the group - :param groupId: ID of group to be searched for - :return Group: [Information about group (see REST API in Help section)] - ''' - # Create database connection - db_session = data_connection.new_session() - - # Get the group - group = db_session.query(models.Group).filter(models.Group.owner_id == groupOwner).filter(models.Group.group_id == groupId).first() - - # If no group is found, return - if group == None: - return None - - cleaned_data = [] - - # Remove group owner's name from member's list to display in UI - initial_members = get_group_members(groupOwner, groupId) - members = [] - - # Get all member names - for member in initial_members: - if member.user_id != groupOwner: - members.append(member.user_id) - - # Combine group with members of group - cleaned_tuple = (group.description, members, group.owner_id, group.name, group.group_id) - - db_session.close() - return [cleaned_tuple] - -def get_group(group_owner, groupId): - ''' - Gets all information about a certain group (used for groups page exclusively). - - :param group_owner: Owner of group to get from server - :param groupId: ID of groupId - :return Group: [information of group] - ''' - # Create database connection - db_session = data_connection.new_session() - - # Get the group - group = db_session.query(models.Group).filter(models.Group.group_id == groupId).filter(models.Group.owner_id == group_owner).first() - - # If no group exists, return - if group == None: - db_session.close() - return None - - cleaned_data = {} - - # Set all properties that are used in the /groups page - cleaned_data['members'] = get_group_members(group_owner, groupId) - cleaned_data['owner'] = group.owner_id - cleaned_data['group_id'] = group.group_id - cleaned_data['description'] = group.description - - # Get all graph names for group - graphs = get_graphs_in_group(group.group_id, group.owner_id) - - graph_names = [] - for graph in graphs: - graph_names.append(graph.graph_id) - - cleaned_data['graphs'] = graphs - db_session.close() - return cleaned_data - -def get_group_members(groupOwner, groupId): - ''' - Get all members of a group. - - :param groupOwner: Group Owner - :param groupId: Group ID - :return Members: [Members of group] - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Get all members of the group - group_members = db_session.query(models.User).filter(models.User.user_id == models.GroupToUser.user_id).filter(models.GroupToUser.group_owner == groupOwner).filter(models.GroupToUser.group_id == groupId).all() - - # Also get owns of the group as well since owners are technically members of the group too - # group_members += db_session.query(models.User).filter(models.User.user_id == models.GroupToUser.user_id).filter(models.GroupToUser.group_owner == groupOwner).filter(models.GroupToUser.group_id == groupId).all() - - db_session.close() - return group_members - - except NoResultFound: - db_session.close() - return None - -def can_see_shared_graph(logged_in_user, graph_owner, graphname): - ''' - See if user is allowed to see a graph. - - :param logged_in_user: User that is currently logged in - :param graph_owner: Owner of graph being viewed - :param graphname: Name of graph being viewed - :return boolean: True if can see it, false otherwise - ''' - - # Get all groups that this graph is shared with - groups = get_all_groups_for_this_graph(graph_owner, graphname) - - # If there are any groups that share this graph - # check to see if the logged in user is a member in that group. - # If they are, then they are allowed to see the graph - if len(groups) > 0: - for group in groups: - - # If logged in user owns a group that the graph is shared with - if logged_in_user == group.owner_id: - return True - - # Get all members of the group - members = get_group_members(group.owner_id, group.group_id) - - # If the user is a member of a group that graph is shared with, - # the user may view this graph - for member in members: - if logged_in_user == member.user_id: - return True - - return None - -def remove_group(owner, group): - ''' - Removes a group from server. - - :param owner: Owner of group - :param group: Group ID - :return - ''' - - # Create database connection - db_session = data_connection.new_session() - - # Check to see if group exists - group = db_session.query(models.Group).filter(models.Group.group_id == cleanGroupName(groupId)).filter(models.Group.owner_id == username).first() - - # Group already exists in database - if group != None: - db_session.close() - return None - - # Create new group - new_group = models.Group(group_id = cleanGroupName(groupId), name = groupId, owner_id = username, description = "") - - # Add to database - db_session.add(new_group) - db_session.commit() - db_session.close() - return [groupId, cleanGroupName(groupId)] - -def cleanGroupName(groupName): - ''' - Cleans group name (gets rid of spaces and _ characters) - - :param groupName: Name of group - :return group: cleaned group name - ''' - groupName = groupName.replace(' ', '') - groupName = groupName.replace('-', '') - return groupName - -def groups_for_user(username): - ''' - Get all groups user belongs to or owns. - - :param username: Email of user - :return Groups: [groups] - ''' - # Create database connection - db_session = data_connection.new_session() - cleaned_group_data = [] - try: - # Get all groups that the user is a member of - member_of_groups = db_session.query(models.GroupToUser).filter(models.GroupToUser.user_id == username).all() - - # Appeend tuple that describes ID of group and the owner of the group - for group in member_of_groups: - cleaned_group_data.append({"groupId": group.group_id, "group_owner": group.group_owner}) - - except NoResultFound: - print "User is not a member of any groups" - - try: - # Get all groups that the user owns - owned_groups = db_session.query(models.Group).filter(models.Group.owner_id == username).all() - - # Appeend tuple that describes ID of group and the owner of the group - for group in owned_groups: - cleaned_group_data.append({"groupId": group.group_id, "group_owner": group.owner_id}) - - except NoResultFound: - print "User is not an owner of any groups" - - db_session.close() - return cleaned_group_data - -def search_result_for_graphs_in_group(uid, search_type, search_terms, db_session, groupOwner, groupId): - ''' - Search method for the graphs in group page. - Emulates search functionality in graphs page except for a particular group. - - @param uid: Logged in user - @param search_type: Type of search (partial_search or full_search) - @param search_terms: All terms being searched for - @param db_session: Database connection - @param groupOwner: Owner of group - @param groupId: ID of group - ''' - - # If it is a search type that is not recognized, return empty list - if search_type != 'partial_search' and search_type != 'full_search': - return [] - - # Make into list if it is not a lsit - if not isinstance(search_terms, list): - search_terms = search_terms.split(',') - - # If there are any search terms - if len(search_terms) > 0: - - # List to keep track of all matched graphs - initial_graphs_from_search = [] - - # Get connection to database - data_session = data_connection.new_session() - - # Go through each search term, aggregating - # all graphs that match the specific search term - for search_word in search_terms: - # matched_graphs contains a list of all graphs that match the specific search term - matched_graphs = [] - # First, we check to see if there are any graphs that have a graph name that matches the search term - matched_graphs += find_all_graphs_containing_search_word_in_group(uid, search_type, search_word, data_session, groupOwner, groupId) - - # ":" indicates that search_word may be an edge - if ':' in search_word: - # append all graphs that contain an edge which matches the search_word - matched_graphs += find_all_graphs_containing_edges_in_group(uid, search_type, search_word, data_session, groupOwner, groupId) - # otherwise append all graphs that contain a node which matches the search word - else: - matched_graphs += find_all_graphs_containing_nodes_in_group(uid, search_type, search_word, data_session, groupOwner, groupId) - - # Go through all matched graphs - # If there is a graph that appears multiple times in the list - # combine their result. - # Effectively, a graph may appear at most one time for each search word - matched_graphs = combine_similar_graphs(matched_graphs) - - # Add condensed tuples to list of graphs matched - initial_graphs_from_search += matched_graphs - - # Go through and count the list of occurrences of matched graph - graph_repititions = defaultdict(int) - - # Counting the number of occurences - for graph_tuple in initial_graphs_from_search: - key = graph_tuple[0] + graph_tuple[4] - graph_repititions[key] += 1 - - # Go through and aggregate all graph together - graph_mappings = defaultdict(list) - - # If the number of times a graph appears matches the number of search terms - # it is a graph we want (simulating the and operator for all search terms) - for graph_tuple in initial_graphs_from_search: - key = graph_tuple[0] + graph_tuple[4] - - graph_tuple = list(graph_tuple) - - # Placeholder for tags of the graph - graph_tuple.insert(1, "") - - # Graph matches all search terms - if graph_repititions[key] == len(search_terms): - - # If we haven't seen this graph yet - if key not in graph_mappings: - graph_mappings[key] = tuple(graph_tuple) - else: - # Combine result of previous tuple - old_tuple = list(graph_mappings[key]) - - # If there is already a matching node/edge id - if len(old_tuple[2]) > 0 and len(graph_tuple[2]) > 0: - old_tuple[2] += ", " + graph_tuple[2] - old_tuple[3] += ", " + graph_tuple[3] - # Otherwise, simply insert this graph tuples id - else: - old_tuple[2] += graph_tuple[2] - old_tuple[3] += graph_tuple[3] - - graph_mappings[key] = tuple(old_tuple) - - # Go through all the graphs and insert tags for the graphs that match all search terms - return graph_mappings.values() - else: - return [] - -def find_all_graphs_containing_edges_in_group(uid, search_type, search_word, db_session, groupId, groupOwner): - ''' - Finds all edges that match search terms that are shared with group. - Emulates search functionality in graphs page except for a particular group. - - @param uid: Logged in user - @param search_type: Type of search (partial_search or full_search) - @param search_word: Term to search for in edge - @param db_session: Database connection - @param groupOwner: Owner of group - @param groupId: ID of group - ''' - # List to keep track of all graphs that contain edges that match the search_word - initial_graphs_matching_edges = [] - - # Separate the edge into its two node ID's - # This is done because in the database, an edge ID is comprised of target:source nodes - node_ids = search_word.split(":") - - # Get head and tail node references - head_node = node_ids[0] - tail_node = node_ids[1] - - # List of all head node ids - head_nodes = [] - - # List of all tail node ids - tail_nodes = [] - - # Match all edges that contain the edges that exactly match the search_word - if search_type == "full_search": - - # Get all (head) nodes that contain a label matching search_word - head_nodes += db_session.query(models.Node.node_id).filter(models.Node.label == head_node).all() - - # Get all (tail) nodes that contain a label matching search_word - tail_nodes += db_session.query(models.Node.node_id).filter(models.Node.label == tail_node).all() - - # Get all (head) nodes that contain a node id matching search_word - head_nodes += db_session.query(models.Node.node_id).filter(models.Node.node_id == head_node).all() - - # Get all (tail) nodes that contain a node id matched search_word - tail_nodes += db_session.query(models.Node.node_id).filter(models.Node.node_id == tail_node).all() - - elif search_type == "partial_search": - - # Get all (head) nodes that contain a partially matching label - head_nodes += db_session.query(models.Node.node_id).filter(models.Node.label.like("%" + head_node + "%")).all() - - # Get all (tail) nodes that contain a label partially matching label - tail_nodes += db_session.query(models.Node.node_id).filter(models.Node.label.like("%" + tail_node + "%")).all() - - # Get all (head) nodes that contain a node id partially matching search_word - head_nodes += db_session.query(models.Node.node_id).filter(models.Node.node_id.like("%" + head_node + "%")).all() - - # Get all (head) nodes that contain a node id partially matching search_word - tail_nodes += db_session.query(models.Node.node_id).filter(models.Node.node_id.like("%" + tail_node + "%")).all() - - # Remove all the duplicates - head_nodes = list(set(head_nodes)) - tail_nodes = list(set(tail_nodes)) - - # Go through head and tail nodes to see if there are any graphs - # that match the given view type (my graphs, shared, public). - # In other words, return all graphs that having matching edges - # for the given view type. - - # TODO: ASK MURALI ABOUT BIDIRECTION EDGES - - # If there are both head and tail nodes - if len(head_nodes) > 0 and len(tail_nodes) > 0: - # Go through all permutations of these nodes - # compile graphs that match the given view_type (my graphs, shared, public) - for i in xrange(len(head_nodes)): - for j in xrange(len(tail_nodes)): - h_node = head_nodes[i][0] - t_node = tail_nodes[j][0] - - # We make two queries because we want to have tail:head and head:tail search (to resolve undirected edges searching) - initial_graphs_matching_edges += db_session.query(models.Edge).filter(models.Edge.head_node_id == h_node).filter(models.Edge.tail_node_id == t_node).filter(models.Edge.graph_id == models.GroupToGraph.graph_id).filter(models.Edge.user_id == uid).filter(models.GroupToGraph.user_id == models.Edge.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - initial_graphs_matching_edges += db_session.query(models.Edge).filter(models.Edge.head_node_id == t_node).filter(models.Edge.tail_node_id == h_node).filter(models.Edge.graph_id == models.GroupToGraph.graph_id).filter(models.Edge.user_id == uid).filter(models.GroupToGraph.user_id == models.Edge.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - - graph_dict = dict() - # Remove duplicates for all graphs that match have the same edge matching search term - for graph in initial_graphs_matching_edges: - key = graph.head_node_id + graph.graph_id + graph.user_id + graph.tail_node_id + graph.edge_id - if key in graph_dict: - continue - else: - graph_dict[key] = graph - - return graph_dict.values() - else: - return [] - -def find_all_graphs_containing_nodes_in_group(uid, search_type, search_word, db_session, groupId, groupOwner): - ''' - Finds all nodes that match search terms that are shared with group. - Emulates search functionality in graphs page except for a particular group. - - @param uid: Logged in user - @param search_type: Type of search (partial_search or full_search) - @param search_word: Term to search for in node - @param db_session: Database connection - @param groupOwner: Owner of group - @param groupId: ID of group - ''' - node_data = [] - - # If we only want partially matched nodes - if search_type == 'partial_search': - - # Get all nodes that have a partially matching label - node_data = db_session.query(models.Node).filter(models.Node.label.like("%" + search_word + "%")).filter(models.Node.graph_id == models.GroupToGraph.graph_id).filter(models.GroupToGraph.user_id == models.Node.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - - # Get all nodes that have a partially matching node id - node_data += db_session.query(models.Node).filter(models.Node.node_id.like("%" + search_word + "%")).filter(models.Node.graph_id == models.GroupToGraph.graph_id).filter(models.GroupToGraph.user_id == models.Node.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - else: - # Get all nodes that have an exact matching label - node_data = db_session.query(models.Node).filter(models.Node.label == search_word).filter(models.Node.graph_id == models.GroupToGraph.graph_id).filter(models.GroupToGraph.user_id == models.Node.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - - # Get all nodes that have an exact matching node id - node_data += db_session.query(models.Node).filter(models.Node.node_id == search_word).filter(models.Node.graph_id == models.GroupToGraph.graph_id).filter(models.GroupToGraph.user_id == models.Node.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - - graph_dict = dict() - - # Remove duplicates for all graphs that match have the same node id and label matching search term - for graph in node_data: - key = graph.graph_id + graph.user_id + graph.label + graph.node_id - if key in graph_dict: - continue - else: - graph_dict[key] = graph - - return graph_dict.values() - -def find_all_graphs_containing_search_word_in_group(uid, search_type, search_word, db_session, groupId, groupOwner): - ''' - Finds all graphs with names that match search terms that are shared with group. - Emulates search functionality in graphs page except for a particular group. - - @param uid: Logged in user - @param search_type: Type of search (partial_search or full_search) - @param search_word: Term to search for in graph name - @param db_session: Database connection - @param groupOwner: Owner of group - @param groupId: ID of group - ''' - matched_graphs = [] - # Return all graphs that have a graph name that partially matches the search word - if search_type == 'partial_search': - try: - #Get all graphs that have ID that partially match search term - matched_graphs = db_session.query(models.Graph).filter(models.Graph.graph_id.like("%" + search_word + "%")).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - - except NoResultFound: - print "No shared graphs matching search term" - - elif search_type == 'full_search': - try: - # Return all graphs that have a graph name that exactly matches the search word - matched_graphs = db_session.query(models.Graph).filter(models.Graph.graph_id == search_word).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - - except NoResultFound: - print "No shared graphs matching search term" - - - graph_dict = dict() - - # Remove duplicates for all graphs that match have the same graph matching search term - for graph in matched_graphs: - key = graph.graph_id + graph.user_id - if key in graph_dict: - continue - else: - graph_dict[key] = graph - - return graph_dict.values() - -def tag_result_for_graphs_in_group(groupOwner, groupId, tag_terms, db_session): - ''' - Finds all graphs with graphs that have matching tag. - - @param groupOwner: Owner of group - @param groupId: ID of group - @param tag_terms: Tag terms to search for - @param db_session: Database connection - ''' - intial_graphs_with_tags = [] - - if len(tag_terms) > 0: - for tag in tag_terms: - try: - # Find graphs that have tag being searched for - intial_graphs_with_tags += db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).filter(models.Graph.graph_id == models.GraphToTag.graph_id).filter(models.Graph.user_id == models.GraphToTag.user_id).filter(models.GraphToTag.tag_id == tag).filter(models.GroupToGraph.graph_id == models.Graph.graph_id).filter(models.GroupToGraph.user_id == models.Graph.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - - except NoResultFound: - print "No shared graphs with tag" - - # Go through and count the list of occurrences of matched graph - graph_repititions = defaultdict(int) - - # Counting the number of occurences - for graph in intial_graphs_with_tags: - graph_repititions[graph] += 1 - - # Go through and aggregate all graph together - graph_mappings = set() - - # If the number of times a graph appears matches the number of search terms - # it is a graph we want (simulating the and operator for all search terms) - for graph in intial_graphs_with_tags: - - # Graph matches all search terms - if graph_repititions[graph] == len(tag_terms): - graph_mappings.add(graph) - - # Go through all the graphs and insert tags for the graphs that match all search terms - return graph_mappings - else: - return [] - -def get_all_graphs_for_group(uid, groupOwner, groupId, request): - ''' - Get all graphs that belong to this group. - - :param groupOwner: Owner of group - :param groupId: Id of group - :param search_terms: Terms to be searched for - :param tag_terms: Tags to be searched for in graphs - :return Graphs: [graphs] - ''' - - # Get connection to databse - db_session = data_connection.new_session() - - # Set search type - search_type = None - - if 'partial_search' in request.GET: - search_type = 'partial_search' - elif 'full_search' in request.GET: - search_type = 'full_search' - - # Check to see if query has search terms, tag terms, or - # user wants to sort graphs - search_terms = request.GET.get(search_type) - tag_terms = request.GET.get('tags') or request.GET.get('tag') - order_by = request.GET.get('order') - - graph_data = [] - - if tag_terms and len(tag_terms) > 0: - cleaned_tags = tag_terms.split(',') - # Goes through each tag, making it a string - # so the url will contain those tags as a part - # of the query string - for tags in xrange(len(cleaned_tags)): - cleaned_tags[tags] = cleaned_tags[tags].strip() - # If user enters in a blank tag, delete it - if len(cleaned_tags[tags]) == 0: - del cleaned_tags[tags] - - if search_terms and len(search_terms) > 0: - - # Split up search terms by comma - cleaned_search_terms = search_terms.split(',') - - # Goes through each search term, making it a string - # so the url will contain those searches as a part - # of the query string - for i in xrange(len(cleaned_search_terms)): - cleaned_search_terms[i] = cleaned_search_terms[i].strip() - # Deleted no length search terms - if len(cleaned_search_terms[i]) == 0: - del cleaned_search_terms[i] - - # If both a tag term and search term are entered - if search_terms and tag_terms and len(search_terms) > 0 and len(tag_terms) > 0: - actual_graphs = [] - - # Get all graphs that contain all the search terms - search_result_graphs = search_result_for_graphs_in_group(uid, search_type, cleaned_search_terms, db_session, groupId, groupOwner) - - # Get all graphs that contain all the tag terms - tag_result_graphs = tag_result_for_graphs_in_group(groupOwner, groupId, cleaned_tags, db_session) - - tag_graphs = [x[0] for x in tag_result_graphs] - actual = [x[0] for x in actual_graphs] - - # If it is not already part of final graphs returned, add it in - for graph in search_result_graphs: - if graph[0] in tag_graphs and graph[0] not in actual: - actual_graphs.append(graph) - - graph_data = actual_graphs - - # If only search terms are entered - elif search_terms: - graph_data = search_result_for_graphs_in_group(uid, search_type, cleaned_search_terms, db_session, groupId, groupOwner) - - # If only tag terms are entered - elif tag_terms: - graph_data = tag_result_for_graphs_in_group(groupOwner, groupId, cleaned_tags, db_session) - else: - try: - graph_data = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.modified, models.GroupToGraph.user_id).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).all() - - except NoResultFound: - print 'no result found' - - # If user wants to sort the data - if order_by: - graph_data = order_information(order_by, search_terms, graph_data) - else: - graph_data = order_information("modified_descending", search_terms, graph_data) - - db_session.close() - return graph_data - -def task_exists(graph_id, user_id): - ''' - Checks to see if task exists for graph. - - @param graph_id: ID of graph - @param user_id: Owner of graph - - ''' - # Create database connection - db_session = data_connection.new_session() - - # Check to see if there is a task currently active for the current graph - exists = db_session.query(models.Task.created).filter(models.Task.graph_id == graph_id).filter(models.Task.user_id == user_id).first() - return exists - -def generateTimeStampAndSignature(secretKey, operation): - ''' - Generates common parameters as defined in (http://docs.aws.amazon.com/AWSMechTurk/latest/AWSMturkAPI/ApiReference_CommonParametersArticle.html) - - @param secretKey: Secret Key given by AWS when creating account - @param operation: Operation of call (http://docs.aws.amazon.com/AWSMechTurk/latest/AWSMturkAPI/ApiReference_OperationsArticle.html) - @return (Timestamp, Signature) following AWS semantics - ''' - - # Get current timestamp - cur_date = datetime.utcnow() - timestamp = datetime.strftime(cur_date, "%Y-%m-%dT%H:%M:%S") + "Z" - - # Create signature based on service, operation and timestamp - signature = "AWSMechanicalTurkRequester" + operation + timestamp - - # Encrypt with HMAC-SHA1 in base64, then URL encode - # (http://docs.aws.amazon.com/AWSMechTurk/latest/AWSMechanicalTurkRequester/MakingRequests_RequestAuthenticationArticle.html#CalcReqSig) - signature = hmac.new(secretKey, signature, sha1).digest().encode("base64").rstrip("\n") - signature = urllib.urlencode({"code": signature})[5:] - - return (timestamp, signature) - - -def launchTask(graph_id, user_id, layout_array, single=None, submitted=0): - ''' - Launches a task on Amazon Mechanical Turk. - - :param graph_id: ID of graph - :param user_id: Owner of graph - :param layout_array: Array of layouts to be saved when task is launched - :param single: If only single layout is being produced instead of an array of layouts - :param submitted: Number of times the layout has been submitted by other workers - :return Error or None if no error - ''' - - # Create database connection - db_session = data_connection.new_session() - - #TODO: ASK IF GROUP OWNER CAN ALSO LAUNCH TASK OR ONLY GRAPH OWNER CAN - - # Get the current time - curtime = datetime.now() - - # If only one layout is provided, nest it in list so rest of code still works - if single != None: - layout_array = [json.loads(layout_array[0])] - else: - layout_array = json.loads(layout_array[0]) - - # If the proper environment variables are set in gs-setup - if AWSACCESSKEYID != None and SECRETKEY != None: - - #Go through each layout and save it - for layout in layout_array: - # Create randomly named layout - new_layout = models.Layout(layout_id = None, layout_name = "Worker_layout_" + str(random.randint(0, 100000)), owner_id = "MTURK_Worker", graph_id = graph_id, user_id = user_id, json = json.dumps(layout), public = 0, shared_with_groups = 0, times_modified=0, original_json=None) - db_session.add(new_layout) - db_session.commit() - - # Get the common parameters for MTurk - timestamp, signature = generateTimeStampAndSignature(SECRETKEY, "CreateHIT") - - # Current as of 12/14/2016 - version = "2014-08-15" - operation = "CreateHIT" - - # Duration of both task and how long it is to be alive (for now same value) - duration = "3000" - - # Title of task and description of task - title = urllib.urlencode({"title": "Lay out a network"})[6:].replace("+", "%20") - description = urllib.urlencode({"description": "Move nodes and edges in a graph following guidelines"})[12:] - - # Generate link back to GS that worker will follow - link_to_graphspace = URL_PATH + "task/" + user_id + "/" + graph_id + "?layout=" + new_layout.layout_name + "&layout_owner=" + new_layout.owner_id - - # Follows Amazon Schematics (http://docs.aws.amazon.com/AWSMechTurk/latest/AWSMturkAPI/ApiReference_CreateHITOperation.html) - question_form_as_xml = '''GraphSpacetruePlease follow the link to lay this graph out to be visually pleasing. Afterwards, you will be presented a survey code to enter below in order to submit this HIT. Thank you for your participation.There are 3 guidelines to follow when laying out a graph. 1) Arrange nodes of the same color together. 2) Arrange rectangles at the bottom of the graph. 3) Arrange diamonds on top of the graph. There is a short tutorial to introduce the tools to aid you provided with the link. The following screenshots shows how a user may layout a graph according to the guidelines.This task is part of a study by Virginia Tech researchers investigating how people can make graph visualizations easier to understand, and the results may be published in scientific journals, conferences, and graduate student theses. You are invited to participate by accepting this task and completing the online consent form. If you participate, you will use our software to look at graphs and create new layouts using some guidelines. You can do as many tasks as you like. Participation is voluntary and confidential. You must be 18 or older to participate. You will be paid $0.50 for each task.imagepng''' + URL_PATH + '''image?name=originalThe game board, with "X" to move.imagepng''' + URL_PATH + '''image?name=midwayThe game board, with "X" to move.imagepng''' + URL_PATH + '''image?name=finalThe game board, with "X" to move.Link to task]]>Replace this with code obtained from GraphSpace.''' - - # must encode from XML to urlencoded format.. some of the letters didn't match up correctly so manually replacement was necessary - xml_encoded = urllib.urlencode({"xml": question_form_as_xml})[4:].replace("+", "%20").replace("%21", "!") - - # Generate MechTurkRequest - request = AWS_URL + '/?Service=AWSMechanicalTurkRequester&Operation=CreateHIT&AWSAccessKeyId=' + AWSACCESSKEYID + '&Version=' + version + '&Timestamp=' + timestamp + "&Title=" + title + "&Description=" + description + "&Reward.1.Amount=0.50&Reward.1.CurrencyCode=USD&AssignmentDurationInSeconds=" + duration + "&LifetimeInSeconds=259200" + "&Question=" + xml_encoded + '&Signature=' + signature + '&Keywords=network,layout,money,science,graph,nodes,edges,task,work,easy' - - response = requests.get(request, allow_redirects=False) - - print response.text - - # Parse XML - root = ET.fromstring(response.text) - - # Depending on XML response, handle task creation - try: - isValid = root[1][0][0].text - if isValid == "True": - hit_id=root[1][1].text - new_task = models.Task(task_id=None, submitted=submitted, graph_id=graph_id, user_id=user_id, task_owner=user_id, created=curtime, hit_id=hit_id, layout_id = new_layout.layout_id, task_type="LAYOUT_TASK", worker_id="MTURK_Worker") - db_session.add(new_task) - createTaskCode(db_session, hit_id) - db_session.commit() - db_session.close() - - except Exception as e: - print "Error is", e - return root[0][1][0][1].text - - db_session.close() - - else: - print "No AWS KEY Specified!" - -# def launchPrepaidTasks(): - -# crowd_layout_prepaid_tasks = [ -# ("dsingh5270@gmail.com", "Etoxazole_crowd", 185), -# ("dsingh5270@gmail.com", "Etoxazole_crowd", 186), -# ("dsingh5270@gmail.com", "Etoxazole_crowd", 187), -# ("dsingh5270@gmail.com", "Etoxazole_crowd", 188), -# ("dsingh5270@gmail.com", "Etoxazole_crowd", 189), -# ("dsingh5270@gmail.com", "Etoxazole_crowd", 201), -# ("dsingh5270@gmail.com", "Bisphenol_crowd", 190), -# ("dsingh5270@gmail.com", "Bisphenol_crowd", 191), -# ("dsingh5270@gmail.com", "Bisphenol_crowd", 192), -# ("dsingh5270@gmail.com", "Bisphenol_crowd", 193), -# ("dsingh5270@gmail.com", "Bisphenol_crowd", 194), -# ("dsingh5270@gmail.com", "Bisphenol_crowd", 200), -# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 180), -# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 181), -# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 182), -# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 183), -# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 184), -# ("dsingh5270@gmail.com", "Fenbuconazole_crowd", 199), -# ("dsingh5270@gmail.com", "Flusilazole_crowd", 175), -# ("dsingh5270@gmail.com", "Flusilazole_crowd", 176), -# ("dsingh5270@gmail.com", "Flusilazole_crowd", 177), -# ("dsingh5270@gmail.com", "Flusilazole_crowd", 178), -# ("dsingh5270@gmail.com", "Flusilazole_crowd", 179), -# ("dsingh5270@gmail.com", "Flusilazole_crowd", 198), -# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 170), -# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 171), -# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 172), -# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 173), -# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 174), -# ("dsingh5270@gmail.com", "Fludioxonil_crowd", 197), -# ("dsingh5270@gmail.com", "Triclosan_crowd", 165), -# ("dsingh5270@gmail.com", "Triclosan_crowd", 166), -# ("dsingh5270@gmail.com", "Triclosan_crowd", 167), -# ("dsingh5270@gmail.com", "Triclosan_crowd", 168), -# ("dsingh5270@gmail.com", "Triclosan_crowd", 169), -# ("dsingh5270@gmail.com", "Triclosan_crowd", 195) -# ] - -# researcher_layout_prepaid_tasks = [ -# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 43), -# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 44), -# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 45), -# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 46), -# ("dsingh5270@gmail.com", "88032-08-0temp-Triclosan-NCIPID-edges", 47), -# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 18), -# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 19), -# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 20), -# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 21), -# ("dsingh5270@gmail.com", "131341-86-1temp-Fludioxonil-NCIPID-edges", 22), -# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 33), -# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 34), -# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 35), -# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 36), -# ("dsingh5270@gmail.com", "96827-34-8temp-Flusilazole-NCIPID-edges", 37), -# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 23), -# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 24), -# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 25), -# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 26), -# ("dsingh5270@gmail.com", "114369-43-6temp-Fenbuconazole-NCIPID-edges", 27), -# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 8), -# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 9), -# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 10), -# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 11), -# ("dsingh5270@gmail.com", "153233-91-1temp-Etoxazole-NCIPID-edges", 12), -# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 53), -# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 54), -# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 55), -# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 56), -# ("dsingh5270@gmail.com", "27360-89-0-Bisphenol-A-NCIPID-edges", 57) -# ] - -# for task in crowd_layout_prepaid_tasks: -# launchApprovalTask(task[0], task[1], task[2]) - -# for task in researcher_layout_prepaid_tasks: -# launchApprovalTask(task[0], task[1], task[2]) - -# db_session = data_connection.new_session() -# for task in crowd_layout_prepaid_tasks: -# new_task = models.Task(task_id=None, task_owner=task[0], graph_id=task[1], user_id=task[0], created=datetime.now(), hit_id="EXPERT_WORKER", worker_id="EXPERT_WORKER", layout_id=task[2], submitted=0, task_type="APPROVE_TASK") -# db_session.add(new_task) - -# for task in researcher_layout_prepaid_tasks: -# new_task = models.Task(task_id=None, task_owner=task[0], graph_id=task[1], user_id=task[0], created=datetime.now(), hit_id="EXPERT_WORKER", worker_id="EXPERT_WORKER", layout_id=task[2], submitted=0, task_type="APPROVE_TASK") -# db_session.add(new_task) - -# db_session.commit() -# db_session.close() - -def getAllApproveTasks(): - db_session = data_connection.new_session() - approve_tasks = db_session.query(models.Task).filter(models.Task.worker_id == "EXPERT_WORKER").all() - db_session.close() - return approve_tasks - -def launchApprovalTask(uid, gid, layout_id, submitted=0): - ''' - Launches approval task for a layout. - - @param uid: Owner of graph - @param gid: Name of graph - @param layout_id: ID of layout - ''' - - # If the proper environment variables are set in gs-setup - if AWSACCESSKEYID != None and SECRETKEY != None: - - db_session = data_connection.new_session() - - # Get the layout - layout = db_session.query(models.Layout).filter(models.Layout.layout_id == layout_id).first() - - # If it doesn't exist, exit - if layout == None: - print "LAYOUT DOESNT EXIST ANYMORE" - return None - - # Current as of 12/14/2016 - version = "2014-08-15" - operation = "CreateHIT" - - # Duration of both task and how long it is to be alive (for now same value) - duration = "3000" - - # Get the common parameters - timestamp, signature = generateTimeStampAndSignature(SECRETKEY, "CreateHIT") - - # Title of task and description of task - title = urllib.urlencode({"title": "Tell us how well this network follows guidelines"})[6:].replace("+", "%20") - description = urllib.urlencode({"description": "Examine a network and rate how well it meets the specified guidelines."})[12:] - - link_to_graphspace = URL_PATH + "approve/" + uid + "/" + gid + "?layout=" + layout.layout_name + "&layout_owner=" + "MTURK_Worker" - - question_form_as_xml = ''' - - - GraphSpace - true - - This task should not take more than 1 minute to complete. Please click the following link and rate the networks. - - - image - png - - ''' + URL_PATH + '''image?name=approve_layout - Image of interface. - This task is part of a study by Virginia Tech researchers investigating how people can make graph visualizations easier to understand, and the results may be published in scientific journals, conferences, and graduate student theses. You are invited to participate by accepting this task and completing the online consent form. If you participate, you will use our software to look at graphs and give feedback on and review graph layouts submitted by crowd workers. You can do as many tasks as you like. Participation is voluntary and confidential. You must be 18 or older to participate. You will be paid $0.20 for each task. - - Link to task]]> - - - - - - - - Replace this with code obtained from GraphSpace. - - - - ''' - - # must encode from XML. gs to urlencoded format.. some of the letters didn't match up correctly so manually replacement was necessary - xml_encoded = urllib.urlencode({"xml": question_form_as_xml})[4:].replace("+", "%20").replace("%21", "!") - - # Generate MechTurkRequest - request = AWS_URL + '/?Service=AWSMechanicalTurkRequester&Operation=CreateHIT&AWSAccessKeyId=' + AWSACCESSKEYID + '&Version=' + version + '&Timestamp=' + timestamp + "&Title=" + title + "&Description=" + description + "&Reward.1.Amount=0.20&Reward.1.CurrencyCode=USD&AssignmentDurationInSeconds=" + duration + "&LifetimeInSeconds=259200" + "&Question=" + xml_encoded + '&Signature=' + signature + '&Keywords=network,layout,easy,money,graphs,quick,science,visual' + '&MaxAssignments=5' - - response = requests.get(request, allow_redirects=False) - - print response.text - - # Parse XML - root = ET.fromstring(response.text) - - # Depending on XML response, handle task creation - try: - isValid = root[1][0][0].text - if isValid == "True": - hit_id=root[1][1].text - new_task = models.Task(task_id=None, task_owner=uid, graph_id=gid, user_id=uid, created=datetime.now(), hit_id=hit_id, layout_id=layout_id, submitted=submitted, task_type="APPROVE_TASK", worker_id="MTURK_Worker") - db_session.add(new_task) - db_session.commit() - createTaskCode(db_session, hit_id) - db_session.close() - - except Exception as e: - print "Error is", e - return root[0][1][0][1].text - - db_session.close() - -def get_all_groups_for_user_with_sharing_info(graphowner, graphname): - ''' - Gets all groups that a user owns or is a member of, - and indicates whether the specified graph is shared with that group - - :param owner: Owner of graph - :param grpahname: Name of graph - :return group_info: [{group_name: , "graph_shared": boolean}] - ''' - group_info = [] - - # Get all groups that the user is a member of or owns - groups = get_groups_of_user(graphowner) + get_all_groups_with_member(graphowner) - - # Get connection to database - db_session = data_connection.new_session() - # Determine if a graph is shared with a specific group - for group in groups: - group_name = group[0] - group_id = group[4] - group_owner = group[2] - - # Check if graph is shared with this group - is_shared_with_group = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.graph_id == graphname).filter(models.GroupToGraph.user_id == graphowner).filter(models.GraphToTag.user_id == graphowner).filter(models.GroupToGraph.group_id == group_id).filter(models.GroupToGraph.group_owner == group_owner).first() - - # If it is not shared, then set "graph_shared" to False - if is_shared_with_group == None: - group_info.append({"group_name": group_name, "group_owner": group_owner, "group_id": group_id, "graph_shared": False}) - else: - group_info.append({"group_name": group_name, "group_owner": group_owner, "group_id": group_id, "graph_shared": True}) - - db_session.close() - return group_info - -def updateSharingInformationForGraph(owner, gid, groups_to_share_with, groups_not_to_share_with): - ''' - Shares specified graph with all groups to share with. Unshares specified graph with all groups to unshare with. - :param owner: Owner of graph - :param grpahname: Name of graph - :param groups_to_share_with: Groups to share with ** have form of [groupName_groupOwner,....] - :param groups_not_to_share_with: Groups not to share with ** have form of [groupName_groupOwner,....] - ''' - for group in groups_to_share_with: - groupInfo = group.split("12345__43121__") - - for group in groups_not_to_share_with: - groupInfo = group.split("12345__43121__") - unshare_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) - -def add_user_to_group(username, owner, group): - ''' - Adds a user to a group. - - :param username: Username to add to group - :param owner: Owner of the group - :param group: Group ID - :return - ''' - # Create database connection - db_session = data_connection.new_session() - - user = db_session.query(models.User).filter(models.User.user_id == username).first() - - if user == None: - db_session.close() - return "User does not exist!" - - # Is user a member of the group - isMember = db_session.query(models.GroupToUser.user_id).filter(models.GroupToUser.user_id == owner).filter(models.GroupToUser.group_id == group).first() - - # Is user an owner of the group - isOwner = db_session.query(models.Group.owner_id).filter(models.Group.owner_id == owner).filter(models.Group.group_id == group).first() - - message = "" - - # User must be an owner of a member of a group to add members to it - if isMember != None or isOwner != None: - new_group_member = models.GroupToUser(group_id = group, group_owner = owner, user_id = username) - db_session.add(new_group_member) - db_session.commit() - message = "Successfully added user " + username + " to " + group + "." - else: - message = "Become the owner or a member of this group first!" - - db_session.close() - return message - -def remove_user_from_group(username, owner, groupId): - ''' - Removes user from group. - - :param username: User to remove - :param owner: Owner of group - :param groupId: Group ID - :return - ''' - - # Create database connection - db_session = data_connection.new_session() - - # Check to see if user exists - user = db_session.query(models.User).filter(models.User.user_id == username).first() - - if user == None: - db_session.close() - return "User does not exist!" - - # Check to see if group exists - group = db_session.query(models.Group).filter(models.Group.group_id == groupId).filter(models.Group.owner_id == owner).first() - - if group == None: - db_session.close() - return "Group does not exist!" - - # Check to see if member in that group actually exists - group_member = db_session.query(models.GroupToUser).filter(models.GroupToUser.group_id == groupId).filter(models.GroupToUser.group_owner == owner).first() - - if group_member == None: - db_session.close() - return "Group member does not exist" - - db_session.delete(group_member) - db_session.commit() - db_session.close() - return "Successfully removed user " + username + " from " + groupId + "." - -def remove_user_through_ui(username, owner, group): - ''' - Removes user from group through UI. - - :param username: User to remove - :param owner: Owner of group - :param group: Group ID - :return - ''' - return remove_user_from_group(username, owner, group) - -def share_graph_with_group(owner, graph, groupId, groupOwner): - ''' - Shares a graph with group. - - :param owner: Owner of group - :param graph: Graph to share - :param groupId: Group ID - :param groupOwner: Group Owner - :return - ''' - - # Get graph - graph_exists = get_graph(owner, graph) - - if graph_exists == None: - return "Graph does not exist" - - # Check to see if the group exists - group_exists = get_group(groupOwner, groupId) - - if group_exists == None: - return "Group does not exist" - - # Create database connection - db_session = data_connection.new_session() - - group_name_exists = db_session.query(models.Group).filter(models.Group.group_id == groupId).first() - - if group_name_exists != None and group_name_exists.owner_id != groupOwner: - return "Group exists but the group owner provided doesn't own this group" - - # Is graph already shared - shared_graph = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).filter(models.GroupToGraph.graph_id == graph).filter(models.GroupToGraph.user_id == owner).first() - - # Graph is already shared - if shared_graph != None: - return None - - # Is a user a member of the group trying to share graph with - group_member = db_session.query(models.GroupToUser).filter(models.GroupToUser.user_id == owner).filter(models.GroupToUser.group_id == groupId).filter(models.GroupToUser.group_owner == groupOwner).first() - - # Is a user the owner of a group - group_owner = db_session.query(models.Group.owner_id).filter(models.Group.owner_id == groupOwner).filter(models.Group.group_id == groupId).first() - - # If they're an owner or a group member, they can add graph to the group - if group_owner != None or group_member != None: - new_shared_graph = models.GroupToGraph(group_id = groupId, group_owner = groupOwner, user_id = owner, graph_id = graph, modified = graph_exists.modified) - - db_session.add(new_shared_graph) - db_session.commit() - else: - return "You must be the owner or a member of this group in order to share graphs with it." - - db_session.close() - return None - -def unshare_graph_with_group(owner, graph, groupId, groupOwner): - ''' - Graph to unshare with group. - - :param owner: Owner of group - :param graph: Graph to unshare - :param groupId: Group ID - :param groupOwner: Group Owner - :return - ''' - - # Get graph - graph_exists = get_graph(owner, graph) - - if graph_exists == None: - return "Graph does not exist!" - - # Check to see if the group exists - group_exists = get_group(groupOwner, groupId) - - if group_exists == None: - return "Group does not exist" - - # Create database connection - db_session = data_connection.new_session() - - group_name_exists = db_session.query(models.Group).filter(models.Group.group_id == groupId).first() - - if group_name_exists != None and group_name_exists.owner_id != groupOwner: - return "Group exists but the group owner provided doesn't own this group" - - # Is this graph already shared with the group? - is_shared_with_group = db_session.query(models.GroupToGraph).filter(models.GroupToGraph.graph_id == graph).filter(models.GroupToGraph.user_id == owner).filter(models.GroupToGraph.group_id == groupId).filter(models.GroupToGraph.group_owner == groupOwner).first() - - # If graph is not shared with group - if is_shared_with_group == None: - db_session.close() - return "Can't unshare a graph that is not currently shared with the group" - - # Is a user a member of the group trying to share graph with - group_member = db_session.query(models.GroupToUser).filter(models.GroupToUser.user_id == owner).filter(models.GroupToUser.group_id == groupId).filter(models.GroupToUser.group_owner == groupOwner).first() - - # Is a user the owner of a group - group_owner = db_session.query(models.Group.owner_id).filter(models.Group.owner_id == groupOwner).filter(models.Group.group_id == groupId).first() - - if group_owner != None or group_member != None: - - # Unshare the graph - db_session.delete(is_shared_with_group) - db_session.commit() - else: - return "You must be the owner or a member of this group in order to unshare graphs with it." - - db_session.close() - return None - -# ---------------- END REST API ------------------------------ - -def view_graphs_of_type(view_type, username): - ''' - View graphs of this type e.g. shared, my graphs, public. - - :param view_type: Type of view (shared, public) - :param username: Name of user - :return Graphs: [graphs] - ''' - - # Create database connection - db_session = data_connection.new_session() - - graphs = [] - - # Select graphs depending on view_type - if view_type == "public": - # Get all public graphs - try: - graphs = db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).distinct(models.Graph.graph_id).filter(models.Graph.public == 1).all() - except NoResultFound: - print "No public graphs" - - elif view_type == "shared": - try: - graphs = db_session.query(models.GroupToGraph.graph_id, models.GroupToGraph.modified, models.GroupToGraph.user_id).distinct(models.GroupToGraph.graph_id, models.GroupToGraph.user_id, models.GroupToGraph.modified).filter(models.GroupToGraph.group_id == models.GroupToUser.group_id).filter(models.GroupToGraph.group_owner == models.GroupToUser.group_owner).filter(models.GroupToUser.user_id == username).all() - except NoResultFound: - print "No shared graphs" - else: - try: - # Get all my graphs - graphs = db_session.query(models.Graph.graph_id, models.Graph.modified, models.Graph.user_id).filter(models.Graph.user_id == username).all() - except NoResultFound: - print "No owned graphs" - - return graphs - - db_session.close() - return cleaned_graphs - -def is_public_graph(username, graph): - ''' - Checks to see if a given graph for a user is public. - - :param username: Email of user - :param graph: Graph of user - :return boolean: True if public graph - ''' - - # Get the graph - graph = get_graph(username, graph) - - # If no graph is found, return None - if graph == None: - return None - - # Return true if public, false otherwise - if graph.public == 1: - return True - else: - return False - -def get_all_groups_for_this_graph(uid, graph): - ''' - Gets all the groups that the graph is shared with. - - :param uid: User of the graph - :param graph: Name of graph - :return Groups: [groups] - ''' - - # Get database connection - db_session = data_connection.new_session() - - try: - # Get all groups that this graph is shared with - shared_groups = db_session.query(models.Group).filter(models.GroupToGraph.graph_id == graph).filter(models.GroupToGraph.user_id == uid).filter(models.GroupToGraph.group_id == models.Group.group_id).filter(models.GroupToGraph.group_owner == models.Group.owner_id).all() - db_session.close() - return shared_groups - except NoResultFound: - db_session.close() - return None - -def change_graph_visibility(isPublic, user_id, graphName): - ''' - Makes specified graph and all associated layouts public or private - - :param isPublic boolean that decides if graph is made public or private (0 if private, 1 if public) - :param user_id ID of owner of graph - :param graphName name of graph to make public - ''' - - # Create database connection - db_session = data_connection.new_session() - - # Get the graph - graph = db_session.query(models.Graph).filter(models.Graph.graph_id == graphName).filter(models.Graph.user_id == user_id).first() - - # If it doesn't exist - if graph == None: - db_session.close() - return "Graph with name " + graphName + " doesn't exist under " + user_id + '.' - - # Update property - graph.public = isPublic - db_session.commit() - - try: - # Change all layouts visibility for a graph - layouts = db_session.query(models.Layout).filter(models.Layout.graph_id == graphName).filter(models.Layout.user_id == user_id).filter(models.Layout.shared_with_groups == 1).all() - for layout in layouts: - layout.public = isPublic - db_session.commit() - - except NoResultFound: - print 'No shared layouts for this graph' - - db_session.close() - - -# Changes the name of a layout -def changeLayoutName(uid, gid, old_layout_name, new_layout_name, loggedIn): - ''' - Changes the name of the layout. - - :param uid: Owner of graph - :param gid: Name of graph - :param old_layout_name: Old name of layout - :param new_layout_name: New name of layout - :param loggedIn: User making those changes - ''' - # Create database connection - db_session = data_connection.new_session() - - # Get the layout - new_layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.owner_id == loggedIn).filter(models.Layout.layout_name == old_layout_name).first() - - # Check to see if there already is a layout with that name for this user - check_layout_name = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.owner_id == loggedIn).filter(models.Layout.layout_name == new_layout_name).first() - - if check_layout_name != None: - db_session.close() - return "Can't change layout name to " + new_layout_name + " because you already have a layout with that name for this graph." - - # Change the name - if new_layout != None: - new_layout.layout_name = new_layout_name - db_session.commit() - - db_session.close() - return None - -def makeLayoutPublic(uid, gid, public_layout, layout_owner): - ''' - Makes a layout public. - - :param uid: Owner of graph - :param gid: Name of graph - :param public_layout: Name of layout - :param layout_owner: Owner of layout - ''' - # Create database connection - db_session = data_connection.new_session() - - # Get layouts to make public - layout = db_session.query(models.Layout).filter(models.Layout.layout_name == public_layout).filter(models.Layout.user_id == uid).filter(models.Layout.graph_id == gid).filter(models.Layout.owner_id == layout_owner).first() - - # If layout exists, make it public - if layout != None: - if layout.public == 1: - layout.public = 0 - - # Get graph - graph = db_session.query(models.Graph).filter(models.Graph.graph_id == gid).filter(models.Graph.user_id == uid).first() - - # If layout isn't public, remove it as default id - if graph != None: - if graph.default_layout_id == layout.layout_id: - graph.default_layout_id = None - else: - layout.public = 1 - - db_session.commit() - - db_session.close() - -def update_layout(graph_id, graph_owner, layout_name, layout_owner, json, public, shared_with_groups, originalLayout): - ''' - Update layout of specific graph. - - :param graph_id: Name of the graph - :param graph_owner: Owner of the graph - :param layout_name: Name of layout to save - :param layout_owner: Owner of layout - :param json: JSON of the graph - :param public: Is layout public or not - :param shared_with_groups: Is layout shared with groups - ''' - # Create database connection - db_session = data_connection.new_session() - - # Checks to see if there is a layout for this specific graph and the same layout name which the person saving the layout already owns - layout = db_session.query(models.Layout).filter(models.Layout.graph_id == graph_id).filter(models.Layout.user_id == graph_owner).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).first() - - # If no such layout exists, add it - if layout != None: - layout.graph_id = graph_id - layout.user_id = graph_owner - layout.layout_name = layout_name - layout.layout_owner = layout_owner - layout.json = json - layout.public = public - layout.shared_with_groups = shared_with_groups - layout.times_modified += 1 - layout.original_json = originalLayout - db_session.commit() - - computeFeatures(graph_owner, graph_id, layout_name, layout_owner) - else: - return "Layout not found!" - - db_session.close() - -def save_layout(graph_id, graph_owner, layout_name, layout_owner, json, public, shared_with_groups, default=False): - ''' - Saves layout of specific graph. - - :param graph_id: Name of the graph - :param graph_owner: Owner of the graph - :param layout_name: Name of layout to save - :param layout_owner: Owner of layout - :param json: JSON of the graph - :param public: Is layout public or not - :param shared_with_groups: Is layout shared with groups - :param default: Is layout default for given graph_id - ''' - # Create database connection - db_session = data_connection.new_session() - - # Checks to see if there is a layout for this specific graph and the same layout name which the person saving the layout already owns - layout = db_session.query(models.Layout).filter(models.Layout.graph_id == graph_id).filter(models.Layout.user_id == graph_owner).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).first() - - # If no such layout exists, add it - if layout != None: - return "Layout with this name already exists for this graph! Please choose another name." - - # Add the new layout - new_layout = models.Layout(layout_id = None, layout_name = layout_name, owner_id = layout_owner, graph_id = graph_id, user_id = graph_owner, json = json, public = public, shared_with_groups = shared_with_groups, times_modified=0, original_json=None) - db_session.add(new_layout) - db_session.commit() - - if default: - db_session.refresh(new_layout) - a = db_session.query(models.Graph).filter(models.Graph.graph_id == graph_id).update({ - "default_layout_id": new_layout.layout_id - }) - db_session.commit() - - db_session.close() - -def deleteLayout(uid, gid, layoutToDelete, layout_owner): - ''' - Deletes layout from graph. - - :param uid: Owner of graph - :param gid: Name of graph - :param layoutToDelete: name of layout to delete - :param layout_owner: User that is deleting the graph - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Get the specific layout - layout = db_session.query(models.Layout).filter(models.Layout.layout_name == layoutToDelete).filter(models.Layout.user_id == uid).filter(models.Layout.graph_id == gid).filter(models.Layout.owner_id == layout_owner).first() - - if layout == None: - return "Layout does not exist!" - - # Get graph which may contain a layout - graph = db_session.query(models.Graph).filter(models.Graph.graph_id == gid).filter(models.Graph.user_id == uid).first() - - if graph == None: - return "Graph does not exist!" - - # If layout being deleted is graphs default layout, remove both - if graph.default_layout_id == layout.layout_id: - graph.default_layout_id = None - db_session.commit() - - db_session.delete(layout) - db_session.commit() - - db_session.close() - return None - except Exception as ex: - db_session.close() - return ex - -def get_layout_for_graph(layout_name, layout_owner, graph_id, graph_owner, loggedIn): - ''' - Retrieves specific layout for a certain graph. - - :param layout_name: Name of layout - :param layout_owner: Owner of layout - :param graph_id: Name of graph - :param graph_owner: Owner of graph - :param loggedIn: Logged in user - ''' - # Create database connection - db_session = data_connection.new_session() - - # If the person viewing the layout is not the graph owner or the graph is not public - if loggedIn != graph_owner and is_public_graph(graph_owner, graph_id) != True: - # Check to see if user is a member of any groups that graph is shared with - user_is_member = can_see_shared_graph(loggedIn, graph_owner, graph_id) - - # If user is not a member, don't display layout - if user_is_member == None: - return None - - # Get layout for graph if it exists - layout = db_session.query(models.Layout).filter(models.Layout.layout_name == layout_name).filter(models.Layout.graph_id == graph_id).filter(models.Layout.user_id == graph_owner).filter(models.Layout.owner_id == layout_owner).first() - - if layout == None: - db_session.close() - return None - else: - db_session.close() - return cytoscapePresetLayout(json.loads(layout.json)) - -def getLayoutById(layout_id): - # Create database connection - db_session = data_connection.new_session() - layout = db_session.query(models.Layout).filter(models.Layout.layout_id == layout_id).first() - db_session.close() - return layout - -def cytoscapePresetLayout(csWebJson): - ''' - Converts CytoscapeWeb preset layouts to be - the standards of CytoscapeJS JSON. See http://js.cytoscape.org/#layouts/preset - for more details. - - :param csWebJson: A CytoscapeWeb compatible layout json containing coordinates of the nodes - :return csJson: A CytoscapeJS compatible layout json containing coordinates of the nodes - ''' - csJson = {} - - # csWebJSON format: [{x: x coordinate of node, y: y coordinate of node, id: id of node},...] - # csJson format: [id of node: {x: x coordinate of node, y: y coordinate of node},...] - - for node_position in csWebJson: - - csJson[str(node_position['id'])] = { - 'x': node_position['x'], - 'y': node_position['y'] - }; - - if 'background_color' in node_position: - csJson[str(node_position['id'])]["background_color"] = node_position['background_color'] - - if 'shape' in node_position: - csJson[str(node_position['id'])]['shape'] = node_position['shape'] - - return json.dumps(csJson) - -def get_all_layouts_for_graph(uid, gid): - ''' - Get all layouts for graph. - - :param uid: Owner of graph - :param gid: Name of graph - :return Layouts: [layouts of graph] - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Get layouts for graph - layouts = db_session.query(models.Layout).filter(models.Layout.owner_id == owner).filter(models.Layout.graph_id == gid).all() - - # Get rid of unicode - cleaned_layouts = [] - for layout in layouts: - cleaned_layouts.append(layout.layout_name) - - db_session.close() - return layout - - except NoResultFound: - db_session.close() - return None - -def share_layout_with_all_groups_of_user(owner, gid, layoutId, layout_owner): - ''' - Shares a layout with all the groups that owner of a graph is a part of. - - :param owner: Owner of graph - :param gid: Name of graph - :param layoutId: Layout of the graph - :param layout_owner: layout_owner of Layout - ''' - # Create database connection - db_session = data_connection.new_session() - - # If layout was the default graph layout, then we have to clear that entry - graph = db_session.query(models.Graph).filter(models.Graph.user_id == owner).filter(models.Graph.graph_id == gid).first() - - if graph == None: - return None - - # Get layout if it exists - layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.layout_name == layoutId).filter(models.Layout.user_id == owner).filter(models.Layout.owner_id == layout_owner).first() - - if layout == None: - return None - - # If the current layout is not shared with the group, share it - if layout.shared_with_groups == 0: - layout.shared_with_groups = 1 - else: - # If it is shared, then unshare it - layout.shared_with_groups = 0 - layout.public = 0 - - # If layout to be unshared is a default layout - # remove it as a default layout - if graph.default_layout_id == layout.layout_id: - graph.default_layout_id = None - db_session.commit() - - print graph.default_layout_id - db_session.commit() - db_session.close() - return None - -# Gets my layouts for a graph -def get_my_layouts_for_graph(uid, gid, loggedIn): - ''' - Get my layouts for this graph. - - :param uid: Owner of graph - :param gid: Name of graph - :param loggedIn: Current user of graphspace - :return Layouts: [my layouts of graph] - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Get all layouts for graph that user created - layouts = db_session.query(models.Layout).filter(models.Layout.owner_id == loggedIn).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.shared_with_groups == 0).filter(models.Layout.public == 0).all() - - db_session.close() - return layouts - - except NoResultFound: - db_session.close() - return None - -def get_shared_layouts_for_graph(uid, gid, loggedIn): - ''' - Get shared layouts for this graph. - - :param uid: Owner of graph - :param gid: Name of graph - :param loggedIn: Current user of graphspace - :return Layouts: [shared layouts of graph] - ''' - - # Create database connection - db_session = data_connection.new_session() - - try: - layout_names = [] - # Get all groups this graph is shared with - all_groups_for_graph = get_all_groups_for_this_graph(uid, gid) - - # Get all groups that the user is a member of - all_groups_for_user = get_all_groups_with_member(loggedIn, skip = True) - - group_dict = dict() - - # Get all groups shared with this graph, removing all duplicates - for group in all_groups_for_graph: - key = group.group_id + group.owner_id - - if key not in group_dict: - group_dict[key] = group - - # Get all groups that the user can see - for group in all_groups_for_user: - key = group.group_id + group.owner_id - - if key in group_dict: - - # If the current user is a member of any groups that have current graph shared in - # for group in all_groups_for_graph: - layout_names = db_session.query(models.Layout).filter(models.Layout.user_id == uid).filter(models.Layout.graph_id == gid).filter(models.Layout.shared_with_groups == 1).all() - - db_session.close() - return layout_names - except NoResultFound: - db_session.close() - return [] - -def get_my_shared_layouts_for_graph(uid, gid, loggedIn): - ''' - Get shared layouts of the graph owner for this graph. - - :param uid: Owner of graph - :param gid: Name of graph - :param loggedIn: Current user of graphspace - :return Layouts: [shared layouts of graph] - ''' - - # Create database connection - db_session = data_connection.new_session() - - try: - # In the database, we define unlisted as the parameter to determine if a certain - # layout is shared within all groups that the graph is shared with and - # public to determine whether everyone is allowed access to the layout. - # If the graph is public, all shared layouts should be public as well, therefore - # we collect all shared and public layouts. - # Note: This is done as a second-measure step and it shouldn't ever matter - # because all layouts are set to public when the graph is set to public - shared_layouts = db_session.query(models.Layout).distinct(models.Layout.layout_name).filter(models.Layout.user_id == uid).filter(models.Layout.owner_id == loggedIn).filter(models.Layout.graph_id == gid).filter(or_(models.Layout.shared_with_groups == 1, models.Layout.public == 1)).all() - - db_session.close() - return shared_layouts - except NoResultFound: - db_session.close() - return None - -def get_public_layouts_for_graph(uid, gid): - ''' - Get public layouts for this graph. - - :param uid: Owner of graph - :param gid: Name of graph - :return Layouts: [public layouts of graph] - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Get all the public layouts for a specific graph - public_layouts = db_session.query(models.Layout).filter(models.Layout.user_id == uid).filter(models.Layout.graph_id == gid).filter(models.Layout.public == 1).all() - - db_session.close() - return public_layouts - except NoResultFound: - db_session.close() - return [] - -def get_all_graphs_for_tags(tags): - ''' - Get all graphs that match the tags. - - :param tags: [List of tags to match] - :return Graphs: [graphs that contain these tags] - ''' - - # Split tags into a list - if tags: - tag_terms = tags.split(',') - for i in xrange(len(tag_terms)): - tag_terms[i] = tag_terms[i].strip() - - # Create database connection - db_session = data_connection.new_session() - - try: - graph_list = [] - actual_graphs_for_tags_list = [] - - # Go through each individual tag - for tag in tag_terms: - # Get all graphs that contain the specific tag we are searching for - graph_list += db_session.query(models.Graph.graph_id).distinct(models.Graph.graph_id).filter(models.GraphToTag.tag_id == tag).filter(models.GraphToTag.graph_id == models.Graph.graph_id).all() - - # Get number of times the graph names appear. - # If they appear the same number of times as the lenght of the tag terms - # it implicitly means that the graph has all of the tags that are being searched for. - accurate_tags = Counter(graphs_for_tag_list) - for graph in graphs_for_tag_list: - if accurate_tags[graph] == len(tag_terms): - actual_graphs_for_tags.append(graph[0]) - - db_session.close() - return actual_graphs_for_tags - - except NoResultFound: - db_session.close() - return None - else: - return None - -def get_all_tags_for_user(username): - ''' - Return all tags that a user has for their graphs. - - :param username: Email of user in GraphSpace - :return Tags: [tags] - ''' - # Get database connection - db_session = data_connection.new_session() - - try: - # Get tags that the user has and return them - tag_list = db_session.query(models.GraphToTag.tag_id).distinct(models.GraphToTag.tag_id).filter(models.GraphToTag.user_id == username).all() - - cleaned_tag_list = [] - - # Get string from unicode so that I can parse it easier - for tag in tag_list: - cleaned_tag_list.append(str(tag[0])) - - db_session.close() - return cleaned_tag_list - except NoResultFound: - db_session.close() - return None - -def get_all_tags_for_graph(graphname, username): - ''' - Returns all of the tags for a specific graph. - - :param graphname: Name of graph to search for - :param username: Email of user in GraphSpace - :return Tags: [tags of graph] - ''' - # Get database connection - db_session = data_connection.new_session() - - try: - # Get graph to see if it exists - graph_exists = db_session.query(models.Graph).filter(models.Graph.graph_id == graphname).filter(models.Graph.user_id == username).first() - - if graph_exists == None: - return [] - - # Retrieves all tags that match a given graph - tag_list = db_session.query(models.GraphToTag.tag_id).distinct(models.GraphToTag.tag_id).filter(models.GraphToTag.user_id == username).filter(models.GraphToTag.graph_id == graphname).all() - - cleaned_tag_list = [] - - # Get string from unicode so that I can parse it easier - for tag in tag_list: - cleaned_tag_list.append(str(tag[0])) - - db_session.close() - return cleaned_tag_list - except NoResultFound: - db_session.close() - return [] - -def change_graph_visibility_for_tag(isPublic, tagname, username): - ''' - Makes all graphs under a tag owned by username public. - - :param isPublic: If graphs are to be made public (0 for private, 1 for public) - :param tagname: Name of tag to search for - :param username: Email of user in GraphSpace - :return - ''' - # Get database connection - db_session = data_connection.new_session() - - try: - # Get all the graphs that user OWNS which contain the matched tags - # Note: Two people using same tag don't have to worry about their - # graphs changing visiblity because we only change the visibility - # of the graph the person is making the request owns - - # Go through all these graphs and change their public column. - # This means that they are visible or private depending on the boolean bit - # associated in their public column (See Graph table) - graph_list = db_session.query(models.Graph).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).filter(models.Graph.user_id == username).filter(models.Graph.graph_id == models.GraphToTag.graph_id).all() - - for graph in graph_list: - graph.public = isPublic - - # Go through all these nodes for graphs and change their public column. - # This means that they are visible or private depending on the boolean bit - # associated in their public column (See Graph table) - # NOTE: I had this originally, but is this even necessary? - node_list = db_session.query(models.Node).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).filter(models.Node.user_id == username).filter(models.Node.graph_id == models.GraphToTag.graph_id).all() - - # Change the visibility of all the layouts that are associated with a graph - layout_list = db_session.query(models.Layout).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).filter(models.Layout.user_id == username).filter(models.Layout.graph_id == models.GraphToTag.graph_id).all() - - for layout_graph in layout_list: - layout_graph.public = isPublic - - db_session.commit() - db_session.close() - except NoResultFound: - print "No graphs that match those tags for the user" - db_session.close() - return None - -def get_visibility_of_graph(user_id, graph_id): - ''' - Gets the publicity of a graph. - - @param user_id: Owner of graph - @param graph_id: Name of graph - ''' - db_session = data_connection.new_session() - public = db_session.query(models.Graph.public).filter(models.Graph.user_id == user_id).filter(models.Graph.graph_id == graph_id).first() - db_session.close() - - # If the graph we query for doesn't exist, return empty bracket - if public == None: - return [] - return public[0] - -def delete_all_graphs_for_tag(tagname, username): - ''' - Deletes all graphs under a tag owned by username. - - :param tagname: Name of tag to search for - :param username: Email of user in GraphSpace - :return - ''' - # Create connection to database - db_session = data_connection.new_session() - - try: - # Get all the graphs that the user owns which match the tag - graph_list = db_session.query(models.Graph).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).filter(models.Graph.graph_id == models.GraphToTag.graph_id).filter(models.Graph.user_id == models.GraphToTag.user_id).all() - - # Delete all these graphs from the graphs table - for graph in graph_list: - db_session.delete(graph) - - # Get all the rows in graph_to_tag that the user owns - delete_tags = db_session.query(models.GraphToTag).filter(models.GraphToTag.tag_id == tagname).filter(models.GraphToTag.user_id == username).all() - - # Delete all those rows from graph_to_tag database - for tag_to_delete in delete_tags: - db_session.delete(tag_to_delete) - - db_session.commit() - db_session.close() - return "Done" - except Exception as ex: - print ex - db_session.close() - return None - -def getGraphInfo(uid, gid): - ''' - Returns the json, visibility, and Id of the graph. - - :param uid: Owner of graph - :param gid: Graph Id - - :return json, visibility, graph_id - ''' - - # Create connection with database - db_session = data_connection.new_session() - - try: - # Retrieves json, public (visibility), and graph id of graph - data = db_session.query(models.Graph.json, models.Graph.public, models.Graph.graph_id).filter(models.Graph.graph_id == gid).filter(models.Graph.user_id == uid).one() - data = list(data) - data[0] = verify_json(data[0]) - - db_session.close() - return data - except Exception as ex: - print uid, gid - print "Error in Graph Info: " + str(ex) - db_session.close() - return None - -def retrieveJSON(uid, gid): - ''' - Retrieves JSON of graph. - - :param uid: Graph owner - :param gid: Graph Id - - :return JSON - ''' - # Create connection with database - db_session = data_connection.new_session() - - try: - # Returns json if it exists, otherwise nothing - data = db_session.query(models.Graph.json).filter(models.Graph.user_id == uid).filter(models.Graph.graph_id == gid).one() - db_session.close() - return data - except Exception as ex: - print "No JSON found for " + gid - print "Error " + ex - db_session.close() - return None - -def insert_user(user_id, password, admin): - ''' - Inserts user into database if they do not exist. - - :param user_id: Email of user - :param password: Password of user - :param admin: is user an admin? - - :return None if user already exists - ''' - # Create database connection - db_session = data_connection.new_session() - - try: - # Creates a new user with given information - new_user = models.User(user_id=user_id, password = password, admin = admin) - db_session.add(new_user) - db_session.commit() - db_session.close() - except Exception as ex: - # If user already exists, don't create another user with same email - print ex - db_session.close() - return None - -def evalQuality(numChanges, timeSpent, numEvents): - ''' - Evaluates the quality of the work given 3 features. - - @param numChanges: Number of movements of graph elements - @param timeSpent: Amount of time spent on the task - @param numEvents: Number of UI element interactions - - @return True iff quality is sufficient - ''' - nb = NB() - - nb.train_classifier() - - return nb.classify(numChanges, timeSpent, numEvents) - -def computeFeatures(uid, gid, layout_name, layout_owner): - ''' - Computes all features for a layout worked on by worker. - - @param uid: Owner of graph - @param gid: Name of graph - @param layout_name: Name of layout - @param layout_owner: Owner of layout - ''' - - db_session = data_connection.new_session() - - layout = db_session.query(models.Layout).filter(models.Layout.layout_name == layout_name).filter(models.Layout.owner_id == layout_owner).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).first() - - if layout == None: - return None - - - # Compute distance moved for each node - distance_vector = [] - - # Pairwise distance between each node - pairwise_vector = [] - - if layout.original_json != None: - origJson = json.loads(layout.original_json) - newJson = json.loads(layout.json) - - for orig_key in origJson: - orig_x = origJson[orig_key]["x"] - orig_y = origJson[orig_key]["y"] - - for obj in newJson: - new_x = obj["x"] - new_y = obj["y"] - - if orig_key == obj["id"]: - distance_vector.append(distance(orig_x, orig_y, new_x, new_y)) - - else: - pairwise_vector.append(distance(orig_x, orig_y, new_x, new_y)) - - db_session.close() - - return [distance_vector, pairwise_vector] - -def distance(x1, y1, x2, y2): - return math.sqrt((x2 - x1)**2 + (y2 - y1)**2) - -def retrieveTaskCode(uid, gid, worked_layout, numChanges, timeSpent, events, hit_id): - ''' - Retrieves task code. - ''' - - db_session = data_connection.new_session() - - # Get the layout from the database - layout = db_session.query(models.Layout).filter(models.Layout.graph_id == gid).filter(models.Layout.user_id == uid).filter(models.Layout.layout_name == worked_layout).filter(models.Layout.owner_id == "MTURK_Worker").first() - - # If layout doesn't exist, exit out - if layout == None: - return None - - # Get features of the task - features = computeFeatures(uid, gid, worked_layout, "MTURK_Worker") - - # Store them in database - new_feature_vector = models.Feature(id=None, user_id = uid, graph_id = gid, layout_id = layout.layout_id, created=datetime.now(), distance_vector=json.dumps(features[0]), pairwise_vector=json.dumps(features[1]), num_changes=numChanges, time_spent=timeSpent, events=events) - db_session.add(new_feature_vector) - db_session.commit() - - # Basic error checking to see if worker deserves pay - if numChanges < 50 and timeSpent < 100 and len(events) < 50: - return "Not enough work done to complete task!" - - # Create database connection - db_session = data_connection.new_session() - - # Get the task associated for this graph - task = db_session.query(models.Task).filter(models.Task.hit_id == hit_id).filter(models.Task.task_type == "LAYOUT_TASK").first() - submit = task.submitted - - # If task doesn't exist, exit out - if task == None: - return None - - # Once task is complete, delete it from the database - db_session.delete(task) - db_session.commit() - - # Update the modified count for the layout - layout.times_modified += 1 - db_session.commit() - - # Launch another task on MTURK if the layout hasn't been modified at least 5 times - # if layout.times_modified < 5: - # launchTask(gid, uid, [layout.json], single=True, submitted=submit + 1) - - task_code = db_session.query(models.TaskCode.code).filter(models.TaskCode.hit_id == hit_id).first() - db_session.close() - - # launch approval tasks - launchApprovalTask(uid, gid, layout.layout_id) - - return task_code - -def createTaskCode(db_session, hitId): - - # Generate task code - taskCode = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(20)) - new_code = models.TaskCode(code=taskCode, created=datetime.now(), hit_id = hitId) - db_session.add(new_code) - db_session.commit() - -def getCrowdEnabledGroup(): - ''' - All users in this special group may launch tasks to GraphCrowd. - ''' - - db_session = data_connection.new_session() - - try: - #THIS IS A HARD-CODED GROUP TO GAIN ACCESS TO MTURK - allowed_users = db_session.query(models.User.user_id).filter(models.GroupToUser.user_id == models.User.user_id).filter(models.GroupToUser.group_id == "Crowd_Group").filter(models.GroupToUser.group_owner == "tmmurali@acm.org").all() - allowed_users_clean = [] - - # Only members of this group or owner of the group have access to Ask the Crowd as well as can see the Crowd option - for user in allowed_users: - allowed_users_clean.append(str(user[0])) - - # Let group owner also view the buttons - group_owner = db_session.query(models.User.user_id).filter(models.Group.group_id == "Crowd_Group").filter(models.Group.owner_id == "tmmurali@acm.org").filter(models.Group.owner_id == models.User.user_id).first() - if group_owner != None: - allowed_users_clean.append(group_owner[0]) - - return allowed_users_clean - except NoResultFound: - db_session.close() - return [] - db_session.close() - -def usernameMismatchError(): - ''' - Returns response telling user that their usernames (from the URL and username field in POST request) - do not match - ''' - return throwError(400, "Usernames do not match!") - -def userNotFoundError(): - ''' - Returns response telling user that their username and password combination is not found. - ''' - return throwError(401, "Username/Password is not recognized!") - -def throwError(statusCode, error): - ''' - Returns response to any error. - ''' - return constructResponse(statusCode, None, error) - -def sendMessage(statusCode, message): - ''' - Returns response to sucessful request. - ''' - return constructResponse(statusCode, message, None) - -def constructResponse(statusCode, message, error): - ''' - Constructs a response to send to the user. - - :param statusCode: Status coe of the request - :param message: Message to send to the user - :param error: Error to display to the user - :return - ''' - response = {"StatusCode": statusCode} - - if message != None: - response['Message'] = message - else: - response['Error'] = error - - return response diff --git a/build/lib/graphs/util/db_conn.py b/build/lib/graphs/util/db_conn.py deleted file mode 100644 index ce5c2617..00000000 --- a/build/lib/graphs/util/db_conn.py +++ /dev/null @@ -1,53 +0,0 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -import sqlalchemy -from django.conf import settings - -# database locations -_originaldb = settings.DATABASE_LOCATION - -class Database(object): - ''' - Create a database object to query from. - ''' - def __init__(self, db_type): - self.db = db_type - self.connection = None - - if self.db == 'prod': - self.engine = create_engine(_originaldb, echo=False) - else: - self.engine = create_engine('sqlite:///:memory:', echo=False) - - Session = sessionmaker(bind=self.engine) - self.session = Session() - - if self.db == 'prod': - self.meta = sqlalchemy.schema.MetaData() - self.meta.reflect(bind=self.engine) - else: - self.meta = None - - def new_session(self): - ''' - Create a new session in this database. This is needed to avoid 1 seesion per thread - error raised if you don't create a new session for every new page load or query request. - ''' - self.session.close() - Session = sessionmaker(bind=self.engine) - self.session = Session() - return self.session - - def connect(self): - ''' - Establish connection to the database engine. - ''' - self.connection = self.engine.connect() - return self.connection - - def close(self): - ''' - Close the connection to the database engine. - ''' - if self.connection is not None: - self.connection.close() \ No newline at end of file diff --git a/build/lib/graphs/util/db_init.py b/build/lib/graphs/util/db_init.py deleted file mode 100644 index 0bb87560..00000000 --- a/build/lib/graphs/util/db_init.py +++ /dev/null @@ -1,19 +0,0 @@ -''' - Module to initialize database access. -''' - -from graphs.util.db_conn import Database - -#connect to database -db = Database('prod') - -#get tables from database -graph = db.meta.tables['graph'] -node = db.meta.tables['node'] -edge = db.meta.tables['edge'] -user = db.meta.tables['user'] -group = db.meta.tables['group'] -group_to_graph = db.meta.tables['group_to_graph'] -group_to_user = db.meta.tables['group_to_user'] -graph_to_tag = db.meta.tables['graph_to_tag'] -password_reset = db.meta.tables['password_reset'] \ No newline at end of file diff --git a/build/lib/graphs/util/json_validator.py b/build/lib/graphs/util/json_validator.py deleted file mode 100644 index b6034c92..00000000 --- a/build/lib/graphs/util/json_validator.py +++ /dev/null @@ -1,390 +0,0 @@ -import json -import re - -# See http://js.cytoscape.org/#style/node-body -ALLOWED_NODE_SHAPES = ['rectangle', 'roundrectangle', 'ellipse', 'triangle', - 'pentagon', 'hexagon', 'heptagon', 'octagon', 'star', - 'diamond', 'vee', 'rhomboid'] - -ALLOWED_NODE_BORDER_STYLES = ['solid', 'dotted', 'dashed', 'double'] - -ALLOWED_NODE_BACKGROUND_REPEAT = ['no-repeat', 'repeat-x', 'repeat-y', 'repeat'] - -ALLOWED_NODE_TEXT_TRANSFORM = ['none', 'uppercase', 'lowercase'] - -ALLOWED_NODE_TEXT_WRAP = ['none', 'wrap'] - -ALLOWED_TEXT_BACKROUND_SHAPE = ['rectangle', 'roundrectangle'] - -ALLOWED_TEXT_HALIGN = ['left', 'center', 'right'] - -ALLOWED_TEXT_VALIGN = ['top', 'center', 'bottom'] - -## See http://js.cytoscape.org/#style/labels -ALLOWED_TEXT_WRAP = ['wrap','none'] - -## See http://js.cytoscape.org/#style/edge-arrow -ALLOWED_ARROW_SHAPES = ['tee', 'triangle', 'triangle-tee', 'triangle-backcurve', - 'square', 'circle', 'diamond', 'none'] - -## See http://js.cytoscape.org/#style/edge-line -ALLOWED_EDGE_STYLES = ['solid', 'dotted','dashed'] - -ALLOWED_ARROW_FILL = ['filled', 'hollow'] - -NODE_COLOR_ATTRIBUTES = ['background_color', 'border_color', 'color', - 'text_outline_color', 'text_shadow_color', - 'text_border_color'] - -EDGE_COLOR_ATTRIBUTES = ['line_color', 'source_arrow_color', - 'mid_source_arrow_color', 'target_arrow_color', - 'mid_target_arrow_color'] - - -def verify_json(graph_json): - graph_json = json.loads(graph_json) - - for node in graph_json["graph"]["nodes"]: - node = node["data"] - - if "shape" in node: - shape = node["shape"].lower() - else: - shape = "ellipse" - - if shape not in ALLOWED_NODE_SHAPES: - shape = "ellipse" - - node["shape"] = shape - - return json.dumps(graph_json) - -def validate_json(graphJson): - """ - Validates JSON to see if all properties are consistent with API. - - @param graphJson: JSON of graph - """ - - cleaned_json = json.loads(graphJson) - - if 'data' in cleaned_json: - cleaned_json = convert_json(cleaned_json) - - if "graph" not in cleaned_json: - return "JSON of graph must have 'graph' property" - - if "nodes" not in cleaned_json["graph"]: - return "JSON of graph must have 'nodes' property" - - if not isinstance(cleaned_json["graph"]["nodes"], list): - return "Nodes property must contain an array" - - if "edges" not in cleaned_json["graph"]: - return "JSON of graph must have 'edges' property" - - if not isinstance(cleaned_json["graph"]["edges"], list): - return "Edges property must contain an array" - - # Validate all node properties - nodes = cleaned_json["graph"]["nodes"] - error = validate_node_properties(nodes) - - if error != None: - return error - - # Validate all edge properties - error = validate_edge_properties(cleaned_json["graph"]["edges"], nodes) - - if error != None: - return error - -def validate_edge_properties(edges, nodes): - """ - Validates all edge properties. - - @param edges: Array of edge objects (http://js.cytoscape.org) - """ - - error = "" - edge_id = None - node_list = [node["data"]["id"] for node in nodes] - # Go through all edges to verify if edges contain valid properties - # recognized by CytoscapeJS - for edge in edges: - edge = edge["data"] - - # Check if source and target node of an edge exist in JSON node list - if edge["source"] not in node_list or edge["target"] not in node_list: - return "For all edges source and target nodes should exist in node list" - - # If edge has no source and target nodes, throw error since they are required - if "source" not in edge or "target" not in edge: - return "All edges must have at least a source and target property. Please verify that all edges meet this requirement." - - # Check if source and target nodes are strings, integers or floats - if not (isinstance(edge["source"], (basestring, int, float)) and isinstance(edge["target"], (basestring, int, float))): - return "Source and target nodes of the edge must be strings, integers or floats" - - edge_id = "with source: " + str(edge["source"]) + "and target: " + str(edge["target"]) - - # If edge is directed, it must have a target_arrow_shape - if "directed" in edge and edge["directed"] == "true": - if "target_arrow_shape" not in edge: - return "Edge", edge_id, "must have a target_arrow_shape property if directed is set to true" - - if "source_arrow_shape" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["source_arrow_shape"], ALLOWED_ARROW_SHAPES) - - if "mid_source_arrow_shape" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["source_arrow_shape"], ALLOWED_ARROW_SHAPES) - - if "target_arrow_shape" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["target_arrow_shape"], ALLOWED_ARROW_SHAPES) - - if "mid_target_arrow_shape" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["mid_target_arrow_shape"], ALLOWED_ARROW_SHAPES) - - if "line_style" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["line_style"], ALLOWED_EDGE_STYLES) - - if "source_arrow_fill" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["source_arrow_fill"], ALLOWED_ARROW_FILL) - - if "mid_source_arrow_fill" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["mid_source_arrow_fill"], ALLOWED_ARROW_FILL) - - if "target_arrow_fill" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["target_arrow_fill"], ALLOWED_ARROW_FILL) - - if "mid_target_arrow_fill" in edge: - error += find_property_in_array("Edge", edge_id, edge, edge["mid_target_arrow_fill"], ALLOWED_ARROW_FILL) - - for attr in EDGE_COLOR_ATTRIBUTES: - if attr in edge: - error += check_color_hex(edge[attr]) - - if len(error) > 0: - return error - else: - return None - -def validate_node_properties(nodes): - """ - Validates all node properties. - - :param G: NetworkX object. - """ - - unique_ids = set() - - error = "" - - # Go through all nodes to verify if the nodes contain valid properties - # recognized by CytoscapeJS - for node in nodes: - node = node["data"] - # Check the data type of node, should be int, float or string - if not isinstance(node["id"], (basestring, int, float)): - return "All nodes must be strings, integers or floats" - - # Check to see if ID is in node - if "id" not in node: - return "All nodes must have a unique ID. Please verify that all nodes meet this requirement." - - if node["id"] not in unique_ids: - unique_ids.add(node["id"]) - else: - return "There are multiple nodes with ID: " + str(node["id"]) + ". Please make sure all node IDs are unique." - - # Checks shape of nodes to make sure it contains only legal shapes - if "shape" in node: - error += find_property_in_array("Node", node["id"], "shape", node["shape"], ALLOWED_NODE_SHAPES) - - # If node contains a border-style property, check to make sure it is - # a legal value - if "border_style" in node: - error += find_property_in_array("Node", node["id"], "border_style", node["border_style"], ALLOWED_NODE_BORDER_STYLES) - - # If node contains a background_black property, check to make sure - # they have values [-1, 1] - if "border_blacken" in node: - if node["border_blacken"] >= -1 and node["border_blacken"] <= -1: - error += "Node: " + str(node["id"]) + " contains illegal border_blacken value. Must be between [-1, 1]." - - if "background_repeat" in node: - error += find_property_in_array("Node", node["id"], "background_repeat", node["background_repeat"], ALLOWED_NODE_BACKGROUND_REPEAT) - - if "text_transform" in node: - error += find_property_in_array("Node", node["id"], "text_transform", node["text_transform"], ALLOWED_NODE_TEXT_TRANSFORM) - - if "text_wrap" in node: - error += find_property_in_array("Node", node["id"], "text_wrap", node["text_wrap"], ALLOWED_NODE_TEXT_WRAP) - - if "text_background_shape" in node: - error += find_property_in_array("Node", node["id"], "text_background_shape", node["text_background_shape"], ALLOWED_NODE_SHAPES) - - if "text_halign" in node: - error += find_property_in_array("Node", node["id"], "text_halign", node["text_halign"], ALLOWED_TEXT_HALIGN) - - if "text_valign" in node: - error += find_property_in_array("Node", node["id"], "text_valign", node["text_valign"], ALLOWED_TEXT_VALIGN) - - for attr in NODE_COLOR_ATTRIBUTES: - if attr in node: - error += check_color_hex(node[attr]) - - if len(error) > 0: - return error - else: - return None - - -def check_color_hex(color_code): - """ - Check the validity of the hexadecimal code of various node and edge color - related attributes. - - This function returns an error if the hexadecimal code is not of the format - '#XXX' or '#XXXXXX', i.e. hexadecimal color code is not valid. - - :param color_code: color code - """ - # if color name is given instead of hex code, no need to check its validity - if not color_code.startswith('#'): - return "" - valid = re.search(r'^#(?:[0-9a-fA-F]{3}){1,2}$', color_code) - if valid is None: - return color_code + ' is not a valid hex color code.' - else: - return "" - - -def find_property_in_array(elementType, key, prop, value, array): - """ - Goes through array to see if property is contained in the array. - - :param elementType: Node or an Edge - :param key: Key to search for in network - :param value: Value of key - :param prop: Name to search for in array - :param array: Array to search for property in - """ - if value not in array: - array_list = ",".join(array) - return elementType + " " + key + " contains illegal value for property: " + prop + ". Value given for this property was: " + value + ". Accepted values for property: " + prop + " are: [" + array_list + "]" - else: - return "" - -def assign_edge_ids(json_string): - ''' - Modifies all ID's of edges to be the names of the nodes that they are attached to. - - :param json_string: JSON of graph - :return json_string: JSON of graph having unique ID's for all edges - ''' - - ids = [] - # Creates ID's for all of the edges by creating utilizing the source and target nodes - # The edge ID would have the form: source-target - for edge in json_string['graph']['edges']: - # To make sure int and floats are also accepted as source and target nodes of an edge - source_node = str(edge['data']['source']) - target_node = str(edge['data']['target']) - edge['data']['id'] = source_node + '-' + target_node - - # If the ID has not yet been seen (is unique), simply store the ID - # of that edge as source-target - if edge['data']['id'] not in ids: - ids.append(edge['data']['id']) - else: - # Otherwise if there are multiple edges with the same ID, - # append a number to the end of the ID so we can distinguish - # multiple edges having the same source and target. - # This needs to be done because HTML DOM needs unique IDs. - counter = 0 - while edge['data']['id'] in ids: - counter += 1 - edge['data']['id'] = edge['data']['id'] + str(counter) - ids.append(edge['data']['id']) - - # Return JSON having all edges containing unique ID's - return json_string - -# This file is a wrapper to communicate with sqlite3 database -# that does not need authentication for connection. - -# It may be viewed as the controller to the database - -def convert_json(original_json): - ''' - Converts original_json that's used in Cytoscape Web - such that it is compatible with the new Cytoscape.js - - See: http://cytoscape.github.io/cytoscape.js/ - - Original json structure used for Cytoscape Web: - { - "metadata": { - - }, - - "graph": { - "data": { - "nodes": [ - { "id": "node1", "label": "n1", ... }, - { "id": "node2", "label": "n2", ... }, - ... - ], - "edges": [ - { "id": "edge1", "label": "e1", ... }, - { "id": "edge2", "label": "e2", ... }, - ... - ] - } - } - } - - New json structure: - { - "metadata": { - - }, - - "graph": { - "nodes": [ - {"data": {"id": "node1", "label": "n1", ...}}, - {"data": {"id": "node2", "label": "n2", ...}}, - ... - ], - "edges": [ - {"data": {"id": "edge1", "label": "e1", ...}}, - {"data": {"id": "edge2", "label": "e2", ...}}, - ... - ] - } - } - ''' - - #parse old json data - old_json = json.loads(original_json) - old_nodes = old_json['graph']['data']['nodes'] - old_edges = old_json['graph']['data']['edges'] - - new_nodes, new_edges = [], [] - - #format node and edge data - for node in old_nodes: - new_nodes.append({"data": node}) - - for edge in old_edges: - new_edges.append({"data": edge}) - - #build the new json - new_json = {} - new_json['metadata'] = old_json['metadata'] - new_json['graph'] = {} - new_json['graph']['nodes'] = new_nodes - new_json['graph']['edges'] = new_edges - - return json.dumps(new_json, indent=4) diff --git a/build/lib/graphs/util/paginator.py b/build/lib/graphs/util/paginator.py deleted file mode 100644 index 56affbc8..00000000 --- a/build/lib/graphs/util/paginator.py +++ /dev/null @@ -1,72 +0,0 @@ -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from graphs.util import db - -def pager(request, content, page_size=25, adjacent_pages=3): - ''' - Adds pagination context variables for use in displaying first, adjacent and - last page links in addition to those created by the object_list generic - view. - - context - context to be displayed on the page - content - content to be paginated - - based on the following article: - www.tummy.com/articles/django-pagination/ - ''' - - context = {} - - paginator = Paginator(content, page_size) - - page = request.GET.get('page') - - try: - current_page = paginator.page(page) - except PageNotAnInteger: - #If page is not an integer, deliver first page. - current_page = paginator.page(1) - except EmptyPage: - #If page is out of range, deliver lastpage of results. - current_page = paginator.page(paginator.num_pages) - - startPage = max(current_page.number, 1) - - endPage = current_page.number + adjacent_pages + 1 - - if endPage > paginator.num_pages: - endPage = paginator.num_pages + 1 - - page_numbers = [n for n in range(startPage, endPage)] - - # context of the paginator to be displayed on the webpage - context['paginator'] = paginator - if page_numbers == 1: - context['has_next'] = None - context['has_previous'] = None - else: - context['has_next'] = current_page.has_next() - context['has_previous'] = current_page.has_previous() - - context['page_numbers'] = page_numbers - context['show_last'] = paginator.num_pages not in page_numbers - context['current_page'] = current_page - if endPage == paginator.num_pages + 1: - if paginator.num_pages < 4: - context['page_numbers'] = range(1, endPage) - else: - context['page_numbers'] = range(paginator.num_pages - adjacent_pages, endPage) - context['pages_range'] = range(paginator.num_pages - adjacent_pages, endPage) - else: - context['pages_range'] = range(current_page.number, paginator.num_pages + 1) - - try: - context['next_page_number'] = current_page.next_page_number() - except EmptyPage: - pass - - try: - context['previous_page_number'] = current_page.previous_page_number() - except EmptyPage: - pass - - return context \ No newline at end of file diff --git a/build/lib/graphs/views.py b/build/lib/graphs/views.py deleted file mode 100644 index 01ff87ea..00000000 --- a/build/lib/graphs/views.py +++ /dev/null @@ -1,2462 +0,0 @@ -from django.shortcuts import render, redirect -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.http import HttpResponse, HttpResponseRedirect, Http404 -from django.views import generic -from django.templatetags.static import static - -from django.shortcuts import render_to_response - -from graphs.util.paginator import pager -from graphs.util import db -from graphs.auth.login import login -from forms import LoginForm, SearchForm, RegisterForm -from django.conf import settings - -import json -import bcrypt -import os -import operator - -from operator import itemgetter -from itertools import groupby -from graphs.forms import LoginForm, RegisterForm - -URL_PATH = settings.URL_PATH - -##### VIEWS ##### - -def image(request): - name = request.GET.get('name', '') - - if len(name) > 0: - return HttpResponseRedirect(URL_PATH + 'static/images/' + name + '.png') - else: - return HttpResponse(json.dumps(db.throwError(404, "Image not found!")), content_type="application/json") - -def saveFeedback(request): - - if request.POST: - feedback = request.POST["feedback"] - graph_id = request.POST["graph_id"] - user_id = request.POST["user_id"] - layout_owner = request.POST["layout_owner"] - layout_name = request.POST["layout_name"] - - error = db.saveFeedback(feedback, graph_id, user_id, layout_owner, layout_name) - - if error != None: - return HttpResponse(json.dumps(db.throwError(500, error)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Feedback saved!")), content_type="application/json") - -def getFeedback(request): - - if request.POST: - graph_id = request.POST["graph_id"] - user_id = request.POST["user_id"] - layout_owner = request.POST["layout_owner"] - layout_name = request.POST["layout_name"] - - results = db.getFeedback(graph_id, user_id, layout_owner, layout_name) - - if len(results) > 0: - return HttpResponse(json.dumps(db.sendMessage(200, results)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(500, "No feedback entered for this task!")), content_type="application/json") - -def index(request): - ''' - Render the main page - - :param request: HTTP GET Request - ''' - # If there is a POST request made to the main page (graphspace.org/index or graphspace.org/), - # that means that the user is trying to log on to GraphSpace. - # If they try to log on, we first check to see if their password needs to be reset (for whatever reason). - # The password_reset table contains all the users whose passwords need to be updated. - # Once the user has updated their password, their name is removed from the password_reset table - - if request.method == 'POST' and db.need_to_reset_password(request.POST['user_id']) != None: - context = {} - - # Forcibly clearing an existing user session (essentially logging user out) - request.session['uid'] = None - - # Email the user the link to reset their password - result = db.sendForgotEmail(request.POST['user_id']) - - # Any and all errors are thrown via "Error" key in context. This will - # be displayed to the user on the front end through a message. - context['Error'] = "Need to reset your password! An email has been sent to " + request.POST['user_id'] + ' with instructions to reset your password!' - return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); - - # Action to login the user to GraphSpace - context = login(request) - - if context['Error'] == None: - return render(request, 'graphs/index.html', context) - elif db.need_to_reset_password(request.POST['user_id']) != None: - context = {} - context['Error'] = "Invalid password. Perhaps you need to reset your password!" - # Any and all errors are thrown via "Error" key in context. This will - # be displayed to the user on the front end through a message. - return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); - else: - # If there is a problem, throw error and the reason why there was a problem - return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); - -def logout(request): - ''' - Log the user out and display logout page. - - :param request: HTTP GET Request - - ''' - - # Clears all context - context = {} - - # Deletes the "Uid" key from the session - # currently being tracked by Django. - try: - del request.session['uid'] - except KeyError: - # TODO: should something be done here? - pass - - # redirect to the main page after logout. - return HttpResponseRedirect('/index/') - -def download(request): - ''' - Download the graph as an image. - Used for when user requests to download PNG of graph. - - :param HTTP GET Request - - ''' - - # Only respond if it is a POST request. - # It will contain the image to be downloaded by the user - if request.POST: - if request.POST['image']: - response = HttpResponse(request.POST['image'], content_type='application/octet-stream') - response['Content-Disposition'] = 'attachment; filename="foo.png"' - return response - - else: - # redirect to the main page - return HttpResponseRedirect('/index/') - -def graphs(request): - ''' - Render the My Graphs page - - :param request: HTTP GET Request - ''' - - return _graphs_page(request, 'my graphs') - -def shared_graphs(request): - ''' - Render the graphs/shared/ page showing all graphs that are shared with a user - - :param request: HTTP GET Request - ''' - - return _graphs_page(request, 'shared') - -def public_graphs(request): - ''' - Render the graphs/public/ page showing all graphs that are public - - :param request: HTTP GET Request - ''' - - return _graphs_page(request, 'public') - -def _graphs_page(request, view_type): - ''' - wrapper view for the following pages: - graphs/ - graphs/shared/ - graphs/public/ - - :param request: HTTP GET Request - :param view_type: Type of view for graph (Ex: my graphs, shared, public) - ''' - # context of the view to be passed in for rendering - context = {} - - # List of graphs that will be returned by the request - graph_list = None - - # handle login - context = login(request) - - # Send view_type to front end to tell the user (through button color) where they are - # The view_type refers to which category of graphs are being viewed (public, shared, my graphs) - context['view_type'] = view_type - - # If there is an error, display the error - if context['Error']: - return render(request, 'graphs/error.html', context) - - # Checks to see if a user is currently logged on - uid = request.session['uid'] - - # Placeholder to keep track of - # whether we are partially searching or - # exact searching - search_type = None - - # Partial search may be thought of as "contains" matching - # Exact search may be though of as "identical" matching - if 'partial_search' in request.GET: - search_type = 'partial_search' - elif 'full_search' in request.GET: - search_type = 'full_search' - - # Set all information abouut graphs to the front-end - # Information of graphs consists of all data for an individual graph - # as well as any search queries and tag queries being performed - context = db.get_graphs_for_view_type(context, view_type, uid, request) - - # Holds the amount of times a tag appears for a graph - all_tags = {} - - # Goes through all the graphs that are currently on a page - if context['graph_list'] != None: - pager_context = pager(request, context['graph_list']) - if type(pager_context) is dict: - context.update(pager_context) - for i in xrange(len(context['current_page'].object_list)): - graph = list(context['current_page'][i]) - # Get all the tags associated with current graphs and populate the - # tags accordion - graph_tags = [] - - if request.GET.get(search_type): - user_id = graph[5] - graph_id = graph[0] - graph_tags = db.get_all_tags_for_graph(graph_id, user_id) - graph[1] = graph_tags - graph.append(db.get_visibility_of_graph(user_id, graph_id)) - else: - user_id = graph[2] - graph_id = graph[0] - graph_tags = db.get_all_tags_for_graph(graph_id, user_id) - graph.insert(1, graph_tags) - graph.append(db.get_visibility_of_graph(user_id, graph_id)) - - context['current_page'].object_list[i] = graph - - # reset the search form - context['search_form'] = SearchForm(placeholder='Search...') - - # Checks to see if there are any tags that the user wants to search for - request_tags = request.GET.get('tags') or request.GET.get('tag') or None - - # If there are no graphs returned by the query, then display message on - # how to add graphs - if len(context['graph_list']) == 0: - context = constructGraphMessage(context, view_type, request.GET.get(search_type), request_tags) - - recent_graphs = context['graph_list'] - - recent_graphs.sort(key=lambda r: r[2], reverse=True) - - if len(recent_graphs) > 250: - recent_graphs = recent_graphs[:250] - - graph_tags = [] - - for graph in recent_graphs: - - if request.GET.get(search_type): - graph_tags = db.get_all_tags_for_graph(graph[0], graph[5]) - else: - graph_tags = db.get_all_tags_for_graph(graph[0], graph[2]) - - for tag in graph_tags: - if len(tag) > 0: - if tag in all_tags: - all_tags[tag] += 1 - else: - all_tags[tag] = 1 - - sorted_tags = sorted(all_tags.items(), key=operator.itemgetter(1), reverse = True)[:10] - - all_tags_refined = [i[0] for i in sorted_tags] - - # Populates tags search bar with most used tags of last 250 graphs - context['all_tags'] = all_tags_refined #list(set(all_tags))[:10] - - # indicator to include css/js footer for side menu support etc. - context['footer'] = True - - return render(request, 'graphs/graphs.html', context) - -def upload_graph_through_ui(request): - - if request.method == 'POST': - login_form = LoginForm() - register_form = RegisterForm() - - upload_json = True - - title_of_graph = None - - if 'title' in request.POST: - title_of_graph = request.POST['title'] - - if str(request.FILES['graphname'])[-4:] != "json": - upload_json = None - - if request.POST['email'] == 'Public User': - # assign random id generator - if upload_json: - result = db.uploadJSONFile(None, request.FILES['graphname'].read(), title_of_graph) - else: - result = db.uploadCyjsFile(None, request.FILES['graphname'].read(), title_of_graph) - - if 'Error' not in result: - context = {'login_form': login_form, 'register_form': register_form, 'Success': result['Success']} - else: - context = {'login_form': login_form, 'register_form': register_form, 'Error': result['Error']} - return render(request, 'graphs/upload_graph.html', context) - else: - - if upload_json: - result = db.uploadJSONFile(request.POST['email'], request.FILES['graphname'].read(), title_of_graph) - else: - result = db.uploadCyjsFile(request.POST['email'], request.FILES['graphname'].read(), title_of_graph) - - if 'Error' not in result: - context = {'login_form': login_form, 'uid': request.POST['email'], 'register_form': register_form, 'Success': result['Success']} - else: - context = {'login_form': login_form, 'uid': request.POST['email'], 'register_form': register_form, 'Error': result['Error']} - - return render(request, 'graphs/upload_graph.html', context) - else: - context = login(request) - return render(request, 'graphs/upload_graph.html', context) - -def save_layout(request, uid, gid): - ''' - Saves a layout for a graph. - - :param HTTP POST Request - - ''' - graph_owner = uid - if request.POST: - if uid == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to save a layout!")), content_type="application/json") - - result = db.save_layout(gid, graph_owner, request.POST['layout_name'], request.POST['loggedIn'], request.POST['points'], request.POST['public'], request.POST['unlisted']) - if result == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Layout saved!")), content_type="application/json") - - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def update_layout(request, uid, gid): - ''' - Updates a layout for a graph. - - :param HTTP POST Request - - ''' - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - error = db.update_layout(gid, uid, request.POST['layout_name'], request.POST['loggedIn'], request.POST['points'], request.POST['public'], request.POST['unlisted'], request.POST['originalLayout']) - if error == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Layout updated!")), content_type="application/json") - - return HttpResponse(json.dumps(db.throwError(400, error)), content_type="application/json"); - -def design_graph(request, uid, gid): - ''' - View a graph with CytoscapeJS along with tool pallete - to help researcher layout of a graph. - - :param request: HTTP GET Request - :param uid: Owner of the graph to view - :param gid: Graph id of the graph to view - ''' - - # Context contains all the elements we want to render on the web - # page. We fill in the various elements of context before calling - # the render() function. - #handle login - # context = login(request) - context = { - "uid": request.session['uid'], - "Error": None - } - - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - #TODO: Create trigger to delete older tasks (3 days) - - # if the graph is public, or if a user is a member - # of the group where this graph is shared - # or if he owns this graph, then allow him to view it - # otherwise do not allow it - if db.is_public_graph(uid, gid) or 'Public_User_' in uid: - graph_to_view = db.get_all_info_for_graph(uid, gid) - elif request.session['uid'] == None: - context['Error'] = "You are not authorized to view this graph, create an account and contact graph's owner for permission to see this graph." - return render(request, 'graphs/error.html', context) - else: - # If the user is member of group where this graph is shared - user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) - - # if user is owner of graph or a member of group that shares graph - if request.session['uid'] == uid or user_is_member == True: - graph_info = db.getGraphInfo(uid, gid) - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it." - return render(request, 'graphs/error.html', context) - else: - context['Error'] = "You are not authorized to view this graph, please contact graph's owner for permission." - return render(request, 'graphs/error.html', context) - - # Get correct layout for the graph to view - context = db.set_layout_context(request, context, uid, gid) - - if context['Error']: - return render(request, 'graphs/error.html', context) - - # Convert JSON for CytoscapeJS, if needed - context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) - context['draw_graph'] = True - - # TODO: This will eventually get deleted - json_data = json.loads(context['graph']) - - # id of the owner of this graph - context['owner'] = uid - - # graph id - context['graph_id'] = gid - - # Don't display the task_view - context["task_view"] = False - context["approve_view"] = False - context["researcher_view"] = False - context["designer_view"] = True - - return render(request, 'graphs/view_graph.html', context) - -def view_graph(request, uid, gid): - ''' - View a graph with CytoscapeJS. - - :param request: HTTP GET Request - :param uid: Owner of the graph to view - :param gid: Graph id of the graph to view - ''' - # Context contains all the elements we want to render on the web - # page. We fill in the various elements of context before calling - # the render() function. - #handle login - context = login(request) - - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - #TODO: Create trigger to delete older tasks (3 days) - - # if the graph is public, or if a user is a member - # of the group where this graph is shared - # or if he owns this graph, then allow him to view it - # otherwise do not allow it - if db.is_public_graph(uid, gid) or 'Public_User_' in uid: - graph_to_view = db.get_all_info_for_graph(uid, gid) - elif request.session['uid'] == None: - context['Error'] = "You are not authorized to view this graph, create an account and contact graph's owner for permission to see this graph." - return render(request, 'graphs/error.html', context) - else: - # If the user is member of group where this graph is shared - user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) - - # if user is owner of graph or a member of group that shares graph - if request.session['uid'] == uid or user_is_member == True: - graph_info = db.getGraphInfo(uid, gid) - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it." - return render(request, 'graphs/error.html', context) - else: - context['Error'] = "You are not authorized to view this graph, please contact graph's owner for permission." - return render(request, 'graphs/error.html', context) - - # Get correct layout for the graph to view - context = db.set_layout_context(request, context, uid, gid) - - if context['Error']: - return render(request, 'graphs/error.html', context) - - # Convert JSON for CytoscapeJS, if needed - context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) - context['draw_graph'] = True - - # Get all the groups that are shared for this graph - shared_groups = db.get_all_groups_for_this_graph(uid, graph_to_view[2]) - - format_shared_groups = [] - for shared_group in shared_groups: - format_shared_groups.append((shared_group.group_id, shared_group.owner_id)) - - context['shared_groups'] = format_shared_groups - - if graph_to_view[1] == 1: - context['shared'] = 'Publicly Shared' - else: - context['shared'] = 'Privately Shared' - - # TODO: This will eventually get deleted - json_data = json.loads(context['graph']) - #add sidebar information to the context for display - if 'description' in json_data['metadata']: - context['description'] = json_data['metadata']['description'] + "" - else: - context['description'] = "" - - # id of the owner of this graph - context['owner'] = uid - - # If the metadata has either a name or a title (backward-compatible) - # display it on the top of the graph - if 'name' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['name'] - elif 'title' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['title'] - else: - context['graph_name'] = '' - - # graph id - context['graph_id'] = gid - - # Don't display the task_view - context["task_view"] = False - context["approve_view"] = False - context["researcher_view"] = True - - # HARDCODED GROUP.. IF USER IS IN THIS GROUP, THEN ONLY THEN CAN THEY LAUNCH TASKS ON MTURK - context["crowd_group"] = db.getCrowdEnabledGroup() - - if len(json_data['graph']['edges']) > 0 and 'k' in json_data['graph']['edges'][0]['data']: - context['filters'] = True - - # redirect if the user wishes to view the json data - if request.method == "GET" and 'view_json' in request.GET: - return HttpResponseRedirect("/json/%s/%s" % (uid, gid)) - - return render(request, 'graphs/view_graph.html', context) - -def view_task(request, uid, gid): - ''' - View that workers will see for a launched task. - - :param request: HTTP GET Request - :param uid: email of the user that owns this graph - :param gid: name of graph that the user owns - ''' - - # db.getAssignmentsForGraph(uid, gid) - if 'uid' in request.session: - context = login(request) - context["task_view"] = True - - else: - login_form = LoginForm() - register_form = RegisterForm() - context = {'login_form': login_form, 'register_form': register_form, "Error": None, "task_view": True} - - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - graph_info = db.getGraphInfo(uid, gid) - - - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Task does not exist anymore!." - return render(request, 'graphs/error.html', context) - - layout_name = request.GET.get('layout', '') - layout_owner = request.GET.get('layout_owner', '') - - # Get correct layout for the graph to view - context = db.set_task_layout_context(request, context, uid, gid, layout_name, layout_owner) - - if context['Error']: - return render(request, 'graphs/error.html', context) - - # Convert JSON for CytoscapeJS, if needed - context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) - - context['draw_graph'] = True - - # TODO: This will eventually get deleted - - json_data = json.loads(context['graph']) - - #add sidebar information to the context for display - if 'description' in json_data['metadata']: - context['description'] = json_data['metadata']['description'] + "" - else: - context['description'] = "" - - # id of the owner of this graph - context['owner'] = uid - - if 'name' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['name'] - else: - context['graph_name'] = '' - - # graph id - context['graph_id'] = gid - - # owner - context["owner"] = uid - - context["researcher_view"] = False - context["approve_view"] = False - - return render(request, 'graphs/view_graph.html', context) - -def approve_task_expert(request): - if 'uid' in request.session: - context = login(request) - else: - context = {} - - tasks = db.getAllApproveTasks() - all_tasks = len(tasks) - for task in tasks: - if task.submitted == 0: - - uid = task.user_id - gid = task.graph_id - - graph_info = db.getGraphInfo(uid, gid) - - layout = db.getLayoutById(task.layout_id) - - context = db.set_task_layout_context(request, context, uid, gid, layout.layout_name, layout.owner_id, approve=True, expert=True) - - context['graph'] = db.retrieve_cytoscape_json(graph_info[0]) - context['remaining'] = all_tasks - context['draw_graph'] = True - - context["researcher_view"] = False - context["approve_view"] = True - - json_data = json.loads(context['graph']) - - #add sidebar information to the context for display - if 'description' in json_data['metadata']: - context['description'] = json_data['metadata']['description'] + "" - else: - context['description'] = "" - - # id of the owner of this graph - context['owner'] = uid - - if 'name' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['name'] - else: - context['graph_name'] = '' - - # graph id - context['graph_id'] = gid - - # owner - context["owner"] = uid - - return render(request, 'graphs/view_graph_expert.html', context) - - context['Error'] = "It appears as if there are no more graphs to lay out. Thank you for your time!" - return render(request, 'graphs/error.html', context) - -def approve_task(request, uid, gid): - ''' - Approve or reject a task. - - :param request: HTTP GET Request - :param uid: email of the user that owns this graph - :param gid: name of graph that the user owns - ''' - - if 'uid' in request.session: - context = login(request) - - else: - login_form = LoginForm() - register_form = RegisterForm() - context = {'login_form': login_form, 'register_form': register_form, "Error": None, "task_view": True} - - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - graph_info = db.getGraphInfo(uid, gid) - - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Task does not exist anymore!." - return render(request, 'graphs/error.html', context) - - layout_name = request.GET.get('layout', '') - layout_owner = request.GET.get('layout_owner', '') - - context = db.set_task_layout_context(request, context, uid, gid, layout_name, layout_owner, approve=True) - - if context['Error']: - return render(request, 'graphs/error.html', context) - - # Convert JSON for CytoscapeJS, if needed - context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) - - context['draw_graph'] = True - - context["researcher_view"] = False - context["approve_view"] = True - - # TODO: This will eventually get deleted - - json_data = json.loads(context['graph']) - - #add sidebar information to the context for display - if 'description' in json_data['metadata']: - context['description'] = json_data['metadata']['description'] + "" - else: - context['description'] = "" - - # id of the owner of this graph - context['owner'] = uid - - if 'name' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['name'] - else: - context['graph_name'] = '' - - # graph id - context['graph_id'] = gid - - # owner - context["owner"] = uid - - return render(request, 'graphs/view_graph.html', context) - -def submitEvaluation(request): - ''' - Submits Evaluation for a task. - ''' - - if request.POST: - - gid = request.POST["graph_id"] - uid = request.POST["user_id"] - layout_name = request.POST["layout_name"] - layout_owner = request.POST["layout_owner"] - triangle_rating = request.POST["triangle_rating"] - rectangle_rating = request.POST["rectangle_rating"] - shape_rating = request.POST["shape_rating"] - color_rating = request.POST["color_rating"] - hit_id = request.POST["hit_id"] - - task_code = db.submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, shape_rating, color_rating, hit_id) - - if task_code != None: - return HttpResponse(json.dumps(db.sendMessage(201, task_code)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(500, "Evaluation Submission Unsucessful!")), content_type="application/json") - else: - return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) - -def submitExpertEvaluation(request): - ''' - Submits Expert Evaluation for a task. - ''' - - if request.POST: - - gid = request.POST["graph_id"] - uid = request.POST["user_id"] - layout_name = request.POST["layout_name"] - layout_owner = request.POST["layout_owner"] - triangle_rating = request.POST["triangle_rating"] - rectangle_rating = request.POST["rectangle_rating"] - shape_rating = request.POST["shape_rating"] - color_rating = request.POST["color_rating"] - hit_id = request.POST["hit_id"] - - task_code = db.submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, shape_rating, color_rating, hit_id, expert=True) - - if task_code != None: - return HttpResponse(json.dumps(db.sendMessage(201, task_code)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(500, "Evaluation Submission Unsucessful!")), content_type="application/json") - else: - return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) - -def retrieveTaskCode(request): - ''' - Retrieves code for a task when worker has completed task. - - ''' - - if request.POST: - - gid = request.POST["graph_id"] - uid = request.POST["user_id"] - worked_layout = request.POST["layout_name"] - numChanges = request.POST["numChanges"] - timeSpent = request.POST["timeSpent"] - events = request.POST["events"] - hit_id = request.POST["hit_id"] - - if not gid or not uid: - return HttpResponse(json.dumps(db.throwError(201, "Must include both graph_id and user_id in POST request.")), content_type="application/json") - - surveyCode = db.retrieveTaskCode(uid, gid, worked_layout, numChanges, timeSpent, events, hit_id) - - if surveyCode == None: - surveyCode = "Task does not exist anymore!" - return HttpResponse(json.dumps(db.sendMessage(201, surveyCode)), content_type="application/json") - - else: - return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) - -def view_json(request, uid, gid): - ''' - View json structure of a graph. - - :param request: HTTP GET Request - :param uid: email of the user that owns this graph - :param gid: name of graph that the user owns - ''' - #handle login - context = login(request) - - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - # if the graph is public, or if a user is a member - # of the group where this graph is shared - # or if he owns this graph, then allow him to view it's JSON - # otherwise do not allow it - if db.is_public_graph(uid, gid) or 'Public_User_' in uid: - graph_to_view = db.get_all_info_for_graph(uid, gid) - elif request.session['uid'] == None: - context['Error'] = "You are not authorized to view JSON for this graph, create an account and contact graph's owner for permission to see this." - return render(request, 'graphs/error.html', context) - else: - # If the user is member of group where this graph is shared - user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) - - # if user is owner of graph or a member of group that shares graph - if request.session['uid'] == uid or user_is_member == True: - graph_info = db.getGraphInfo(uid, gid) - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it's JSON." - return render(request, 'graphs/error.html', context) - else: - context['Error'] = "You are not authorized to view JSON for this graph, please contact graph's owner for permission." - return render(request, 'graphs/error.html', context) - - - graph_to_view = db.get_graph_json(uid, gid) - - if graph_to_view == None: - context['Error'] = "Graph not found, please make sure you have the correct URL." - return render(request, 'graphs/error.html', context) - - # Get correct json for CytoscapeJS - context['json'] = db.retrieve_cytoscape_json(graph_to_view) - - # id of the owner of this graph - context['owner'] = uid - - # graph id - context['graph_id'] = gid - - # If it is http request, render it in the specific page, otherwise just return the JSON - if request: - return render(request, 'graphs/view_json.html', context) - else: - return HttpResponse(context['json']) - -def groups(request): - ''' - Render the Owner Of page, showing groups that are owned by the user. - - :param request: HTTP GET Request - - ''' - return _groups_page(request, 'owner of') - -def groups_member(request): - ''' - Render the Member Of page, showing the groups that the user belong to . - - :param request: HTTP GET Request - - ''' - return _groups_page(request, 'member') - -def all_groups(request): - ''' - Render the All Groups page, showing all groups in the database.Admin feature [NOT CURRENTLY SUPPORTED]. - - :param request: HTTP GET Request - - ''' - return _groups_page(request, 'all') - -def _groups_page(request, view_type): - ''' - Wrapper view for the following pages: - groups/ - groups/member/ - groups/public/ - groups/all/ - - :param request: HTTP GET Request - :param view_type: Type of view for the group (Example: owner of, member, public, all) - - ''' - #context of the view to be passed in for rendering - context = {} - group_list = None - - #handle login - context = login(request) - - #Send view_type to front end to tell the user (through button color) where they are - context['view_type'] = view_type - - #check for authentication - uid = request.session['uid'] - if uid is not None: - # Get groups that the user is a member of - if view_type == 'member': - group_list = db.get_all_groups_with_member(context['uid']) + db.get_groups_of_user(context['uid']) - - # if admin, then they can view this - elif view_type == 'all': - if db.is_admin(uid) == 1: - group_list = db.get_all_groups_in_server() - else: - context['Error'] = "You are not authorized to see this group's contents! Please contact group's owner to add you to the group!" - return render(request, 'graphs/error.html', context) - - #groups of logged in user(my groups) - else: - # List all groups that uid either owns. - group_list = db.get_groups_of_user(context['uid']) - - #Order all tuples if user wants to order their results - order_term = request.GET.get('order') - - if order_term: - if request.GET.get('order') == 'group_ascending': - group_list = sorted(group_list, key=lambda graph: graph[0]) - elif order_term == 'group_descending': - group_list = sorted(group_list, key=lambda graph: graph[0], reverse=True) - elif order_term == 'owner_ascending': - group_list = sorted(group_list, key=lambda graph: graph[2]) - elif order_term == 'owner_descending': - group_list = sorted(group_list, key=lambda graph: graph[2], reverse=True) - - else: - group_list = sorted(group_list, key=lambda graph: graph[0]) - - #add the group list to context to display on the page. - if len(group_list) != 0: - context['group_list'] = group_list - else: - context['group_list'] = None - - pager_context = pager(request, group_list) - - if type(pager_context) is dict: - context.update(pager_context) - - context['my_groups'] = len(db.get_groups_of_user(context['uid'])) - context['member_groups'] = len(db.get_all_groups_with_member(context['uid'])) + context['my_groups'] - - if view_type == 'owner of' and context['my_groups'] == 0: - context['message'] = "It appears that you are not an owner of any group. Please create a group in order to own a group." - elif view_type == 'member' and context['member_groups'] == 0 : - context['message'] = "It appears as if you are not a member of any group. Please join a group in order for them to appear here." - else: - context['message'] = "It appears as if there are currently no groups on GraphSpace." - return render(request, 'graphs/groups.html', context) - - #No public groups anymore - else: - context['Error'] = "You need to be logged in and also be a member of this group in order to see this group's contents!" - return render(request, 'graphs/error.html', context) - -def graphs_in_group(request, group_owner, group_id): - ''' - Groups/group_name page, where group_name is the name of the - group to view the graphs that belong to the group. - - This is the view displayed when the user clicks a group listed - on the /groups page. - - Group names that are not allowed: 'all', 'member' and 'public'. - they are preoccupied. - - :param request: HTTP GET Request - :param group_id: Name of group to get - - ''' - - #handle login - context = login(request) - - # add search form - search_form = SearchForm() - context['search_form'] = search_form - - # if the group name is not one of the designated names, display graphs - # that belong to the group - if "uid" in context: - if group_id != 'all' or group_id != 'member': - - group_dict = db.groups_for_user(context['uid']) - - if not any(g_dict['groupId'] == group_id for g_dict in group_dict): - context['Error'] = "You need to be a member of a group to see its contents! Please contact group's owner to add you to the group!" - return render(request, 'graphs/error.html', context) - - # Get all graph information that belong to this group - graph_data = db.get_all_graphs_for_group(context['uid'], group_owner, group_id, request) - - search_type = None - context['search_result'] = False - - if 'partial_search' in request.GET: - search_type = 'partial_search' - elif 'full_search' in request.GET: - search_type = 'full_search' - - if search_type != None: - context['search_result'] = True - context['search_type'] = search_type - context['search_word'] = "" - - cleaned_search_terms = request.GET.get(search_type).split(',') - for i in xrange(len(cleaned_search_terms)): - cleaned_search_terms[i] = cleaned_search_terms[i].strip() - # Deleted no length search terms - if len(cleaned_search_terms[i]) == 0: - del cleaned_search_terms[i] - - for i in xrange(len(cleaned_search_terms)): - context['search_word'] += cleaned_search_terms[i] + ',' - - if len(context['search_word']) > 0: - context['search_word'] = context['search_word'][:len(context['search_word']) - 1] - - # include the graph data to the context - if len(graph_data) != 0: - context['graph_list'] = graph_data - else: - context['graph_list'] = None - if context['search_result'] == True: - context['message'] = "It appears as if there are no graphs in this group that match your search query!" - else: - context['message'] = "It appears as if there are no graphs in this group yet." - - group_information = db.get_group_by_id(group_owner, group_id) - - # pass the group_id to the context for display - context['group_id'] = group_information[0][4] - - context['group_name'] = group_information[0][3] - - context['group_owner'] = group_information[0][2] - - context['group_description'] = group_information[0][0] - - context['group_members'] = group_information[0][1] - - all_tags = [] - - # Goes through all the graphs that are currently on a page - pager_context = pager(request, graph_data) - if type(pager_context) is dict: - context.update(pager_context) - for i in xrange(len(context['current_page'].object_list)): - graph = list(context['current_page'][i]) - - graph_tags = [] - if request.GET.get(search_type): - user_id = graph[5] - graph_id = graph[0] - graph_tags = db.get_all_tags_for_graph(graph_id, user_id) - graph[1] = graph_tags - graph.append(db.get_visibility_of_graph(user_id, graph_id)) - else: - user_id = graph[2] - graph_id = graph[0] - graph_tags = db.get_all_tags_for_graph(graph_id, user_id) - graph.insert(1, graph_tags) - graph.append(db.get_visibility_of_graph(user_id, graph_id)) - all_tags += graph_tags - - context['current_page'].object_list[i] = graph - - context['all_tags'] = list(set(all_tags)) - # indicator to include css/js footer for side menu support etc. - context['footer'] = True - - return render(request, 'graphs/graphs_in_group.html', context) - # if the group name is one of the designated names, display - # appropriate vies for each - else: - if group_id == 'member': - return groups_member(request) - else: - return public_groups(request) - else: - context['Error'] = "Please log in to view groups page" - return render(request, 'graphs/error.html', context) - - -def features(request): - ''' - View features page. - - :param request: HTTP GET Request - - ''' - #handle login - context = login(request) - - return render(request, 'graphs/features.html', context) - -def help(request): - ''' - Render the following pages: - - help/ - help/getting_started - - :param request: HTTP GET Request - - ''' - - #handle login - context = login(request) - - return render(request, 'graphs/help_users.html', context) - -def help_programmers(request): - ''' - Render the help/tutorials page. - - :param request: HTTP GET Request - - ''' - - #handle login - context = login(request) - - return render(request, 'graphs/help_programmers.html', context) - -def help_graphs(request): - ''' - Render the help/graphs page. - - :param request: HTTP GET Request - - ''' - - #handle login - context = login(request) - - return render(request, 'graphs/help_graphs.html', context) - -def help_restapi(request): - ''' - Render the help/restapi page. - - :param request: HTTP GET Request - - ''' - - #handle login - context = login(request) - - return render(request, 'graphs/help_restapi.html', context) - -def help_jsonref(request): - ''' - Render the help/jsonref page. - - :param request: HTTP GET Request - - ''' - - #handle login - context = login(request) - - return render(request, 'graphs/help_jsonref.html', context) - -def help_about(request): - ''' - Render the help/about page. - - :param request: HTTP GET Request - - ''' - - #handle login - context = login(request) - - return render(request, 'graphs/help_about.html', context) - -def help_tutorial(request): - ''' - Render the help/tutorial page. - - :param request: HTTP GET Request - - ''' - - #handle login - context = login(request) - - return render(request, 'graphs/help_tutorial.html', context) - -def register(request): - ''' - Register a new user. - - :param request: HTTP POST Request containing: - - {"user_id": , "password": } - - ''' - - #if the form has been submitted - if request.method == 'POST' and 'user_id' in request.POST and 'password' in request.POST: - # RegisterForm is bound to POST data - register_form = RegisterForm(request.POST) - # form validation - if register_form.is_valid(): - # create new account by inserting the new account - # record to the database - user_id = register_form.cleaned_data['user_id'] - - if user_id == None: - return HttpResponse(json.dumps(db.throwError(400, "Email already exists!")), content_type="application/json"); - - # hash the password using bcrypt library - hashed_pw = bcrypt.hashpw( - register_form.cleaned_data['password'], - bcrypt.gensalt()) - admin = 0 - - db.insert_user(user_id, hashed_pw, admin) - - # should display success message. not there yet. - return HttpResponseRedirect('/index/') - else: - register_form = RegisterForm() # An unbound form - - context['register_form'] = register_form - context['footer'] = True - - return render(request, 'graphs/register.html', context) - -def retrieveIDs(request): - ''' - Retrieves ID's of the nodes. - Used when highlighting elements of the graph. - - :param request: HTTP POST Request containing - - {uid: , gid: , values: [labels/id's of edges/nodes return id for]} - - :return JSON: {"IDS": [ids of nodes/edges in database]} - - ''' - - #Grab id's of the nodes to highlight given the label of the nodes - if request.POST: - element_values = request.POST['values'].split(',') - elementDictionary = {} - for element in element_values: - elementDictionary[element] = [] - # Find an edge - if ':' in element: - elementDictionary[element] += db.find_edge(request.POST['uid'], request.POST['gid'], element.strip(), request.POST['search_type']) - else: - elementDictionary[element] += db.find_node(request.POST['uid'], request.POST['gid'], element.strip(), request.POST['search_type']) - - return HttpResponse(json.dumps(elementDictionary)) - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def sendResetEmail(request): - ''' - Sends an email to the requester. - - :param request: HTTP POST Request containing: - - {"forgot_email": } - - :returns JSON: {"Error|Success": "Email does not exist! | "Email has been sent!"} - - ''' - if request.POST: - db.add_user_to_password_reset(request.POST['forgot_email']) - emailId = db.sendForgotEmail(request.POST['forgot_email']) - - # If email is not found, throw an error - if emailId == None: - return HttpResponse(json.dumps(db.throwError(404, "Email does not exist!")), content_type="application/json") - - return HttpResponse(json.dumps(db.sendMessage(200, "Email has been sent!")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def resetLink(request): - ''' - Directs the user to a link that - allows them to change their password. - - :param HTTP GET Request - :return JSON: {"email": | "Error": "Unrecognized ID"} - - ''' - if request.method == 'GET': - login_form = LoginForm() - register_form = RegisterForm() - code = request.GET.get('id') - email = db.retrieveResetInfo(code) - context = {'login_form': login_form, 'register_form': register_form} - - if email == None: - context['Error'] = "This password reset link is outdated. Please try resetting your password again." - return render(request, 'graphs/error.html', context) - - context['email'] = email - context['url'] = URL_PATH - return render(request, 'graphs/reset.html', context) - else: - return HttpResponse(json.dumps(db.throwError(500, "This route only accepts GET requests.")), content_type="application/json") - -def resetPassword(request): - ''' - Resets the password of the user. - - :param request: HTTP POST Request containing - - {"email": , "password": "password"} - - :return JSON: {"Error|Success": "Password Update not successful! | Password updated for !"} - - ''' - if request.method == "POST": - resetInfo = db.resetPassword(request.POST['email'], request.POST['password'], request.POST['code']) - - if resetInfo == None: - return HttpResponse(json.dumps(db.throwError(500, "Password Update not successful!")), content_type="application/json"); - - return HttpResponse(json.dumps(db.sendMessage(200, "Password updated for " + request.POST['email'])), content_type="application/json"); - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def launchTask(request): - ''' - Launches a task on Amazon Mechanical Turk. - - :param request: HTTP POST Request containing - - {"graph_id": , "user_id": } - - :return JSON: {"Error|Success": Error | Task Launched on Amazon Mechanical Turk!"} - ''' - # Only 1 task per graph as long as there is a HIT active (3 days) - error = db.launchTask(request.POST["graph_id"], request.POST["user_id"], request.POST.getlist('layout_array')) - - if error != None: - return HttpResponse(json.dumps(db.throwError(500, error)), content_type="application/json") - - return HttpResponse(json.dumps(db.sendMessage(201, "Task Launched on Amazon Mechanical Turk!")), content_type="application/json"); - -def changeLayoutName(request): - ''' - Changes the name of the layout - - :param request: Incoming HTTP POST Request containing: - - {"uid": ,"gid": , "old_layout_name": , "new_layout_name": "} - - :return JSON: {"Success": } - ''' - if request.method == 'POST': - loggedIn = request.session.get('uid') - uid = request.POST['uid'] - gid = request.POST['gid'] - old_layout_name = request.POST['old_layout_name'] - new_layout_name = request.POST['new_layout_name'] - - if loggedIn == None: - return HttpResponse(json.dumps({"StatusCode": 500, "Message": "Must be logged in to make those requests", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name + "&layout_owner=" + loggedIn}), content_type="application/json") - - error = db.changeLayoutName(uid, gid, old_layout_name, new_layout_name, loggedIn) - if error == None: - return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout name changed!", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name + "&layout_owner=" + loggedIn}), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, error)), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def deleteLayout(request): - ''' - Deletes layout of a graph - - :param request: Incoming HTTP POST Request containing: - - {"owner": ,"gid": , "layout": , "user_id": "} - - :return JSON: {"Success": } - ''' - if request.method == 'POST': - uid = request.session.get('uid') - gid = request.POST['gid'] - - if uid == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to delete a layout!")), content_type="application/json") - - layoutToDelete = request.POST['layout'] - layout_owner = request.POST['layout_owner'] - - result = db.deleteLayout(uid, gid, layoutToDelete, layout_owner) - - if result == None: - return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout deleted!", "url": URL_PATH + 'graphs/' + uid + '/' + gid}), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def makeLayoutPublic(request): - ''' - Makes a layout of graph public - - :param request: Incoming HTTP POST Request containing: - - {"owner": ,"gid": , "layout": , "user_id": "} - - :return JSON: {"Success": } - ''' - if request.method == 'POST': - uid = request.POST['uid'] - gid = request.POST['gid'] - layoutToMakePpublic = request.POST['layout'] - loggedIn = request.POST['user_id'] - - current_user = request.session.get('uid') - - # If user is not logged on, they can't do anything - if current_user == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make share layouts!")), content_type="application/json") - - # If user is the owner of the graph or if they are the layout owner, can they share a layout - if current_user != uid and db.get_layout_for_graph(layoutId, layout_owner, gid, uid, current_user) == None: - return HttpResponse(json.dumps(db.throwError(500, "Not authorized to share layouts!")), content_type="application/json") - - if uid == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make a layout public!")), content_type="application/json") - - db.makeLayoutPublic(uid, gid, layoutToMakePpublic, loggedIn) - return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout made public!", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name}), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def getGroupsForGraph(request): - ''' - Returns all the groups that are associated with request. - - :param request:Incoming HTTP POST Request containing: - - {"gid": } - - :return JSON: {"Groups": [list of groups]} - ''' - if request.method == 'POST': - owner = request.session.get('uid') - gid = request.POST['gid'] - - if owner == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to see groups for this graph!")), content_type="application/json") - - return HttpResponse(json.dumps({"StatusCode": 200, "Group_Information": db.get_all_groups_for_user_with_sharing_info(owner, gid)}), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def shareGraphWithGroups(request): - ''' - Shares graph with specified groups. - Unshares graph with specified groups. - - :param request:Incoming HTTP POST Request containing: - {"gid": , "owner": , "groups_to_share_with": [group_ids], "groups_not_to_share_with": [group_ids]} - :return TBD - ''' - if request.method == 'POST': - owner = request.POST['owner'] - gid = request.POST['gid'] - groups_to_share_with = request.POST.getlist('groups_to_share_with[]') - groups_not_to_share_with = request.POST.getlist('groups_not_to_share_with[]') - - for group in groups_to_share_with: - groupInfo = group.split("12345__43121__") - db.share_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) - - for group in groups_not_to_share_with: - groupInfo = group.split("12345__43121__") - db.unshare_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) - - return HttpResponse("Done") - - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def create_group(request, groupname): - ''' - Allows group creation from the GUI. - - :param request: Incoming HTTP POST Request containing: - - {"owner": , "groupname": < name of group>, "username": User who submitted the request} - - :return JSON: {"Upload": , "Group Name | Error": } - ''' - - # If request is a POST request, add it to the server - if request.method == 'POST': - group_created = db.create_group(request.POST['username'], groupname) - - # If there isn't already a group name that exists with the same name under account - # add it to account - if group_created != None: - return HttpResponse(json.dumps({"StatusCode": 201, "Message": "Group created!", "Group Name": group_created[0], "Group Id": group_created[1]}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "Group name already exists for this account"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def deleteGraph(request): - ''' - Allows deletion of graph. - - :param request: Incoming HTTP POST Request containing: - - {"uid": , "gid": < name of graph>} - - :return JSON: {"Delete": } - ''' - if request.method == 'POST': - gid = request.POST['gid'] - uid = request.session.get('uid') - - # Check if the user is authenticated - if uid == None: - return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to delete this graph"), indent=4, separators=(',', ': ')), content_type="application/json") - - # if the user owns the graph only then allow him to delete it - graph_info = db.getGraphInfo(uid, gid) - if graph_info == None: - return HttpResponse(json.dumps(db.throwError(404, "You do not own any such Graph."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - - jsonData = db.get_graph_json(uid, gid) - if jsonData != None: - db.delete_graph(uid, gid) - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully deleted " + gid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(404, "You do not own any such Graph."), indent=4, separators=(',', ': ')), content_type="application/json") - -def delete_group_through_ui(request): - ''' - Allows group creation from the GUI. - - :param request: Incoming HTTP POST Request containing: - - {"groupOwner": , "groupName": < name of group>, "username": User who submitted the request} - - :return JSON: {"Delete": } - ''' - - # If request is a POST request, add it to the server - if request.method == 'POST': - if request.POST['username'] == request.POST['groupOwner']: - db.remove_group(request.POST['groupOwner'], request.POST['groupName']) - return HttpResponse(json.dumps(db.sendMessage(200, request.POST['groupName'] + " deleted for " + request.POST['groupOwner'])), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def unsubscribe_from_group(request): - ''' - Allows group creation from the GUI. - - :param request: Incoming HTTP POST Request containing: - - {"groupOwner": , "groupName": < name of group>, "username": User who submitted the request} - - :return JSON: {"Unsubscribe | Error": } - ''' - - # If request is a POST request, add it to the server - if request.method == 'POST': - result = db.remove_user_through_ui(request.POST['username'], request.POST['groupOwner'], request.POST['groupName']) - if result != None: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "You are no longer following " + request.POST['groupName'] + " owned by " + request.POST['groupOwner'])), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def change_description_through_ui(request): - ''' - Allows user to change description of group through UI. - - :param request: Incoming HTTP POST Request containing: - - {"groupOwner": , "groupId": < ID of group>, "username": User who submitted the request, "description": } - - :return JSON: {"Changed | Error": } - ''' - - # If request is a POST request, add it to the server - if request.method == 'POST': - result = db.change_description(request.POST['username'], request.POST['groupId'], request.POST['groupOwner'], request.POST['description']) - if result != None: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Changed description")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def add_member_through_ui(request): - ''' - Allows user to add members to a group through UI. - - :param request: Incoming HTTP POST Request containing: - - {"groupOwner": , "groupId": < ID of group>, "member": "member to add"} - - :return JSON: {"Message": } - ''' - - # If request is a POST request, add it to the server - if request.method == 'POST': - result = db.add_user_to_group(request.POST['member'], request.POST['groupOwner'], request.POST['groupId']) - return HttpResponse(json.dumps(db.sendMessage(200, result)), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def remove_member_through_ui(request): - ''' - Allows user to remove members from a group through UI. - - :param request: Incoming HTTP POST Request containing: - - {"groupOwner": , "groupId": < ID of group>, "member": "member to remove"} - - :return JSON: {"Message": } - ''' - - # If request is a POST request, add it to the server - if request.method == 'POST': - result = db.remove_user_from_group(request.POST['member'], request.POST['groupOwner'], request.POST['groupId']) - return HttpResponse(json.dumps(db.sendMessage(200, result)), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def getGroupsWithLayout(request): - ''' - Gets all groups that have the particular graph shared in the group. - - :param request: Incoming HTTP POST Request containing: - - {"loggedIn": [current user], "owner": < Owner of graph >, "gid": "Id of graph"} - - :return JSON: {"Groups":[Groups]} - ''' - if request.method == 'POST': - result = db.is_layout_shared(request.POST['layout'], request.POST['loggedIn'], request.POST['owner'], request.POST['gid']) - return HttpResponse(json.dumps({"StatusCode": 200, "Group_Information": result}), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def setDefaultLayout(request): - if request.method == 'POST': - result = db.setDefaultLayout(request.POST['layoutId'], request.POST['gid'], request.POST['uid']) - if result != None: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Set " + request.POST['layoutId'] + " as default")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def removeDefaultLayout(request): - if request.method == 'POST': - result = db.removeDefaultLayout(request.POST['layoutId'], request.POST['gid'], request.POST['uid']) - if result != None: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Removed " + request.POST['layoutId'] + " as default")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def renderImage(request): - # This is a temporary route so Allison's graphs show up - return HttpResponseRedirect(URL_PATH + 'static/images/legend.png'); - -def shareLayoutWithGroups(request): - ''' - Toggles shares/unshare graph with specified groups. - - :param request:Incoming HTTP POST Request containing: - {"gid": , "owner": , "groups_to_share_with": [group_ids], "groups_not_to_share_with": [group_ids]} - :return TBD - ''' - if request.method == 'POST': - layout_owner = request.POST['loggedIn'] - gid = request.POST['gid'] - uid = request.POST['uid'] - layoutId = request.POST['layoutId'] - current_user = request.session.get('uid') - - # If user is not logged on, they can't do anything - if current_user == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make share layouts!")), content_type="application/json") - - # If user is the owner of the graph or if they are the layout owner, can they share a layout - if current_user != uid and db.get_layout_for_graph(layoutId, layout_owner, gid, uid, current_user) == None: - return HttpResponse(json.dumps(db.throwError(500, "Not authorized to share layouts!")), content_type="application/json") - - if db.can_see_shared_graph(current_user, uid, gid) == None: - return HttpResponse(json.dumps(db.throwError(500, "Not allowed to do this operation!")), content_type="application/json") - - if len(db.get_all_groups_for_this_graph(uid, gid)) == 0: - return HttpResponse(json.dumps(db.throwError(400, "No groups to share with. Either share this graph with a group first or make this graph public!")), content_type="application/json") - else: - if db.is_public_graph(uid, gid): - db.makeLayoutPublic(uid, gid, layoutId, layout_owner) - else: - db.share_layout_with_all_groups_of_user(uid, gid, layoutId, layout_owner) - - return HttpResponse(json.dumps(db.sendMessage(200, "Okay")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -##### END VIEWS ##### - -##### REST API ##### - -def graph_exists(request, user_id, graphname): - ''' - Checks to see if a graph exists - - @param request: HTTP POST Request - @param user_id: Owner of graph - @param: graph_name: Name of graph - ''' - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - graph_exists = db.graph_exists(user_id, graphname) - - if graph_exists == False: - return HttpResponse(json.dumps(db.throwError(404, "User " + user_id + " owns no graph with id " + graphname + "!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "User " + user_id + " owns a graph with id " + graphname + "!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def upload_graph(request, user_id, graphname): - ''' - Uploads a graph for a user - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :param user_id: Id of the user - :param graphname: Name of the graph - - :return response: JSON Response: {"Success|Error": } - - ''' - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - graph_errors = db.insert_graph(user_id, graphname, request.FILES['graphname'].read()) - - if graph_errors != None: - return HttpResponse(json.dumps(db.throwError(400, graph_errors), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(201, "Added " + graphname + " for " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def update_graph(request, user_id, graphname): - ''' - Updates an already existing graph. - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :param user_id: Id of the user - :param graphname: Name of the graph - - :return response: JSON Response: {"Success|Error": } - ''' - - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - graph_errors = db.update_graph(user_id, graphname, request.FILES['graphname'].read()) - if graph_errors != None: - return HttpResponse(json.dumps(db.throwError(404, graph_errors), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(201, "Updated " + graphname + " for " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def retrieve_graph(request, user_id, graphname): - ''' - Retrieves the json of a specified graph - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :param user_id: Id of the user - :param graphname: Name of the graph - - :return response: JSON Response: {"Graph|Error": } - ''' - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - jsonData = db.get_graph_json(user_id, graphname) - if jsonData != None: - return HttpResponse(jsonData) - else: - return HttpResponse(json.dumps(db.throwError(404, "No Such Graph Exists!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def remove_graph(request, user_id, graphname): - ''' - Removes a graph from the server - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :param user_id: Id of the user - :param graphname: Name of the graph - - :return response: JSON Response: {"Success|Error": } - - ''' - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - jsonData = db.get_graph_json(user_id, graphname) - if jsonData != None: - db.delete_graph(user_id, graphname) - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully deleted " + graphname + " owned by " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(404, "No Such Graph Exists."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def view_all_graphs_for_user(request, user_id): - ''' - View all graphs for a user - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :return response: JSON Response: {"Graphs|Error": } - ''' - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - data = db.get_all_graphs_for_user(user_id) - return HttpResponse(json.dumps({"StatusCode": 200, "Graphs": data}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def make_graph_public(request, user_id, graphname): - ''' - Makes specified graph and all of its layouts public - - :param request: Incoming HTTP POST Request containing: - {"username": ,"password": } - :param graphname: name of graph to make public - :return response: JSON Response: {"Success|Error": } - ''' - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - data = db.change_graph_visibility(1, request.POST['username'], graphname) - - if data == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully made " + graphname + " owned by " + user_id + " public."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(404, data), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def make_graph_private(request, user_id, graphname): - ''' - Makes specified graph and all of its layouts public - - :param request: Incoming HTTP POST Request containing: - {"username": ,"password": } - :param graphname: name of graph to make public - :return response: JSON Response: {"Success|Error": } - ''' - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - data = db.change_graph_visibility(0, request.POST['username'], graphname) - if data == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully made " + graphname + " owned by " + user_id + " private."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(404, data), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def get_groups(request): - ''' - Get all groups that are on this server - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :return response: JSON Response: {"Groups|Error": } - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - data = db.get_all_groups_with_member(request.POST['username']) - return HttpResponse(json.dumps({"StatusCode": 200, "Groups": data}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def get_group(request, group_owner, groupname): - ''' - Get information about this group - - :param request: Incoming HTTP POST Request containing: {"username": ,"password": } - :param group_owner: Owner of group to get from server - :param groupname: ID of group to get from server - :return response: JSON Response: {"Groups|Error": } - - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - data = db.get_group(group_owner, groupname) - if data == None: - return HttpResponse(json.dumps(db.throwError(404, "Group does not exist for this user!"), indent=4, separators=(',', ': ')), content_type="application/json") - - return HttpResponse(json.dumps({"StatusCode": 200, "Groups": data}, indent=4, separators=(',', ': ')), content_type="application/json"); - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def delete_group(request, group_owner, groupname): - ''' - Deletes a group from the server. - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - :param groupname: Name of group to delete from server - - :return response: JSON Response: {"Success|Failure": } - - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if group_owner == request.POST['username']: - data = db.remove_group(request.POST['username'], groupname) - if data != None: - return HttpResponse(json.dumps(db.throwError(404, "Group not found!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, data), indent=4, separators=(',', ': ')), content_type="application/json"); - else: - return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def add_group(request, group_owner, groupname): - ''' - Adds a group to the server. If groupname already exists under a user account, then it will fail, otherwise a group name is created under the user's account. - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :param group: Name of group to add to server - :return response: JSON Response: {Upload: "Success|Failure", "Group Name|Error": group | error} - ''' - - # If request is a POST request, add it to the server - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if group_owner == request.POST['username']: - data = create_group(request, groupname) - return HttpResponse(data) - else: - return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def get_group_for_user(request, user_id): - ''' - Gets all groups that a user is a part of. - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :param user_id: Email of the user to get the groups for - :return JSON Response: {"User": , "Groups": } - - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - group = db.groups_for_user(user_id) - return HttpResponse(json.dumps({"StatusCode": 200, "Groups": group}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def add_user_to_group(request, group_owner, groupname, user_id): - ''' - Adds specified user to a group. - - :param request: Incoming HTTP POST Request containing: - - {"username": ,"password": } - - :param groupname: Name of group to add user to - :param user_id: Email of user to add to the group - :return JSON Response: {"Response": } - ''' - - if request.method == 'POST': - - # Check to see if the user/password is acceptable - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if group_owner == request.POST['username']: - # Adds user to group - data = db.add_user_to_group(user_id, request.POST['username'], groupname) - - # If nothing is returned, that means that something went wrong - if data == None: - return HttpResponse(json.dumps(db.throwError(400, "Group doesn't exist or user has already been added!"), indent=4, separators=(',', ': ')), content_type="application/json") - - return HttpResponse(json.dumps(db.sendMessage(200, data), indent=4, separators=(',', ': ')), content_type="application/json") - - else: - return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def remove_user_from_group(request, group_owner, groupname, user_id): - ''' - Removes user from group - - :param HTTP POST Request containing - {"username": , "password": } - :param groupname: Name of group to remove user from - :param user_id: Email of user to remove - - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - if group_owner == request.POST['username']: - group = db.remove_user_from_group(user_id, request.POST['username'], groupname) - return HttpResponse(json.dumps(db.sendMessage(200, group), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def share_graph(request, graphname, group_owner, groupname): - ''' - Share a graph with group. - - :param HTTP POST Request containing - {"username": , "password": } - :param graphname: Name of graph to unshare_graph - :param groupname: Name of group to unshare graph with - - :return JSON: {"Response": } - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - result = db.share_graph_with_group(request.POST['username'], graphname, groupname, group_owner) - if result == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Graph successfully shared with group!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(400, result), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def unshare_graph(request, graphname, group_owner, groupname): - ''' - Unshare a graph from a group. - - :param HTTP POST Request containing - {"username": , "password": } - :param graphname: Name of graph to unshare_graph - :param groupname: Name of group to unshare graph with - - :return JSON: {"Response": } - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - result = db.unshare_graph_with_group(request.POST['username'], graphname, groupname, group_owner) - if result == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Graph successfully unshared with group!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(400, result), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def get_tags_for_user(request, username): - ''' - Get all tags that a user has under their name - :param HTTP POST Request containing - {"username": , "password": } - :param username: Name of user to get tags from - - :return JSON: {"Response": } - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - result = db.get_all_tags_for_user(username) - return HttpResponse(json.dumps({"StatusCode": 200, "Tags": result}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def get_all_tags_for_graph(request, username, graphname): - ''' - Get all tags that a user has under their graph - :param HTTP POST Request containing - {"username": , "password": } - :param graphname: Name of graph to get tags from - :param username: Name of user to get graph of - - :return JSON: {"Response": } - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - result = db.get_all_tags_for_graph(graphname, username) - if result == None: - return HttpResponse(json.dumps(db.sendMessage(404, "Graph does not exist!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps({"StatusCode": 200, "Tags": result}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def get_all_graphs_for_tags(request, tag): - ''' - Get all graphs associated with these tags - :param HTTP POST Request containing - {"username": , "password": } - :param tag: Name of tag to get graphs of - - :return JSON: {"Response": } - ''' - - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - result = db.get_all_graphs_for_tags(tag) - return HttpResponse(json.dumps({"StatusCode": 200, "Graphs": result}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def make_all_graphs_for_tag_public(request, username, tagname): - ''' - Makes all graphs with this tag public - :param HTTP POST Request containing - {"username": , "password": } - :param username: Owner of graphs to change - :param tag: Name of tag to get graphs of - - :return JSON: {"Response": } - ''' - - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if username == request.POST['username']: - error = db.change_graph_visibility_for_tag(1, tagname, username) - if error == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been made public"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, error), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def make_all_graphs_for_tag_private(request, username, tagname): - ''' - Makes all graphs with this tag private - :param HTTP POST Request containing - {"username": , "password": } - :param username: Owner of graphs to change - :param tag: Name of tag to get graphs of - - :return JSON: {"Response": } - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if username == request.POST['username']: - error = db.change_graph_visibility_for_tag(0, tagname, username) - if error == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been made private"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, error), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -def delete_all_graphs_for_tag(request, username, tagname): - ''' - Makes all graphs with this tag private - :param HTTP POST Request containing - {"username": , "password": } - :param username: Owner of graphs to change - :param tag: Name of tag to get graphs of - - :return JSON: {"Response": } - ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if username == request.POST['username']: - db.delete_all_graphs_for_tag(tagname, username) - return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been deleted"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) - -# Private Utility methods used throughout views.py - -def handler_404(request): - if request.method == 'POST': - return HttpResponse(json.dumps(db.throwError(404, "REST API endpoint does not exist!")), content_type="application/json") - else: - return render(request,'404.html') - -def handler_500(): - if request.method == 'POST': - return HttpResponse(json.dumps(db.throwError(500, "An error was encountered during this request. REST API call not successful.")), content_type="application/json") - else: - return render(request,'500.html') - -def constructGraphMessage(context, view_type, search, tags): - if view_type == 'shared': - if search == None and tags == None: - context['message'] = "It appears that there are no groups that have shared their graphs." - elif search != None and tags == None: - context['message'] = "It appears that there are no groups that have shared their graphs with the given search criteria." - elif tags != None and search == None: - context['message'] = "It appears that there are no groups that have shared their graphs with the given tag criteria." - else: - context['message'] = "It appears that there are no groups that have shared their graphs with the given search and tag criteria." - - elif view_type == 'public': - if search == None and tags == None: - context['message'] = "It appears that there are no public graphs available. Please create an account and join a group or upload your own graphs through the REST API or web interface." - elif search != None and tags == None: - context['message'] = "It appears that there are no public graphs available that match the search criteria. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search criteria." - elif tags != None and search == None: - context['message'] = "It appears that there are no public graphs available that match the tag criteria. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given tag criteria." - else: - context['message'] = "It appears that there are no public graphs available that match the search and tag criteria. Please create an account and join a group or upload your own graphs with the given search and tag criteria." - - elif view_type == 'all': - if search == None and tags == None: - context['message'] = "It appears that there are no graphs available." - elif search != None and tags == None: - context['message'] = "It appears that there are no graphs available that match the search criteria." - elif tags != None and search == None: - context['message'] = "It appears that there are no graphs available that match the tag criteria." - else: - context['message'] = "It appears that there are no graphs available that match the search and tag criteria." - else: - if search == None and tags == None: - context['message'] = "It appears that you currently have no graphs uploaded. Please create an account and join a group or upload your own graphs through the REST API or web interface." - elif search != None and tags == None: - context['message'] = "It appears that you currently have no graphs uploaded that match the search terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search criteria in order to see them here." - elif tags != None and search == None: - context['message'] = "It appears that you currently have no graphs uploaded that match the tag terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given tag criteria in order to see them here." - else: - context['message'] = "It appears that you currently have no graphs uploaded that match the serach and tag terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search and tag criteria in order to see them here." - - return context diff --git a/build/lib/graphspace/__init__.py b/build/lib/graphspace/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/build/lib/graphspace/settings/__init__.py b/build/lib/graphspace/settings/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/build/lib/graphspace/settings/base.py b/build/lib/graphspace/settings/base.py deleted file mode 100644 index be28b343..00000000 --- a/build/lib/graphspace/settings/base.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -Django settings for graphspace project. - -For more information on this file, see -https://docs.djangoproject.com/en/1.6/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.6/ref/settings/ -""" - -import os -BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -ALLOWED_HOSTS = ['*'] - - -# GLOBAL VALUES FOR DATABASE -DB_FULL_PATH = os.path.join(BASE_DIR, 'graphspace.db') -DATABASE_LOCATION = 'sqlite:///' + DB_FULL_PATH - -# Application definition - -INSTALLED_APPS = ( - 'analytical', - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'graphs' -) - -MIDDLEWARE_CLASSES = ( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware' -) - -ROOT_URLCONF = 'graphspace.urls' - -WSGI_APPLICATION = 'graphspace.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/1.6/ref/settings/#databases - -# DATABASES = { -# 'default': { -# 'ENGINE': 'django.db.backends.postgresql_psycopg2', -# 'NAME': 'graphspace', -# 'USER': 'adb', -# 'PASSWORD': '', -# 'HOST': 'localhost', -# 'PORT': '5432' -# } -# } - -## Old Sqlite Implementation ### -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'graphspace.db') - } -} - -# Internationalization -# https://docs.djangoproject.com/en/1.6/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -# Changed from 'UTC'. -TIME_ZONE = 'EST' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = True - -# Email setup -EMAIL_USE_TLS = True -EMAIL_PORT = 587 - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.6/howto/static-files/ - -STATIC_URL = '/static/' - -STATICFILES_DIRS = ( - os.path.join(BASE_DIR, "static"), -) - -TEMPLATE_DIRS = [os.path.join(BASE_DIR, 'templates')] - -# for authentication. Since we need to use SQL Alchemy for the ORM, we cannot use the authentication backend automatically provided by Django when using the Django ORM. -AUTHENTICATION_BACKENDS = ('graphs.auth.AuthBackend.AuthBackend',) - -# Following the recommendation of the Django tutorial at -PASSWORD_HASHERS = ( - 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', - 'django.contrib.auth.hashers.BCryptPasswordHasher', - 'django.contrib.auth.hashers.PBKDF2PasswordHasher', - 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', - 'django.contrib.auth.hashers.SHA1PasswordHasher', - 'django.contrib.auth.hashers.MD5PasswordHasher', - 'django.contrib.auth.hashers.CryptPasswordHasher', -) diff --git a/build/lib/graphspace/settings/local.py b/build/lib/graphspace/settings/local.py deleted file mode 100644 index 11a6fb74..00000000 --- a/build/lib/graphspace/settings/local.py +++ /dev/null @@ -1,32 +0,0 @@ -from graphspace.settings.base import * - -# variables for setting up account through which GraphSpace emails -EMAIL_HOST = 'NONE' -EMAIL_HOST_USER = 'NONE' -EMAIL_HOST_PASSWORD = 'NONE' - -# If error is thrown, display the error in the browser (ONLY FOR LOCAL MACHINES) -DEBUG = True -TEMPLATE_DEBUG = True - -# URL through which to access graphspace -URL_PATH = "http://localhost:8000/" - -# If tracking is enabled for GraphSpace in Google Analytics -GOOGLE_ANALYTICS_PROPERTY_ID = 'UA-00000000-0' - -# Keys given by creating a requestor account on Amazon Mechanical Turk (https://www.mturk.com/mturk/welcome) -AWSACCESSKEYID = 'None' -SECRETKEY = 'None' - -# Path to GraphSPace -PATH = "/Path_to_GraphSpace" - -# SHOULD NEVER CHANGE THIS VALUE -SECRET_KEY = 'this-is-a-secret-key-for-local-settings-only' - -# If needing to test on production mturk account (real money) -# AWS_URL = 'https://mechanicalturk.amazonaws.com' - -# Sandbox (development) MTURK (fake money used) -AWS_URL = 'https://mechanicalturk.sandbox.amazonaws.com' diff --git a/build/lib/graphspace/settings/testing.py b/build/lib/graphspace/settings/testing.py deleted file mode 100644 index 11a6fb74..00000000 --- a/build/lib/graphspace/settings/testing.py +++ /dev/null @@ -1,32 +0,0 @@ -from graphspace.settings.base import * - -# variables for setting up account through which GraphSpace emails -EMAIL_HOST = 'NONE' -EMAIL_HOST_USER = 'NONE' -EMAIL_HOST_PASSWORD = 'NONE' - -# If error is thrown, display the error in the browser (ONLY FOR LOCAL MACHINES) -DEBUG = True -TEMPLATE_DEBUG = True - -# URL through which to access graphspace -URL_PATH = "http://localhost:8000/" - -# If tracking is enabled for GraphSpace in Google Analytics -GOOGLE_ANALYTICS_PROPERTY_ID = 'UA-00000000-0' - -# Keys given by creating a requestor account on Amazon Mechanical Turk (https://www.mturk.com/mturk/welcome) -AWSACCESSKEYID = 'None' -SECRETKEY = 'None' - -# Path to GraphSPace -PATH = "/Path_to_GraphSpace" - -# SHOULD NEVER CHANGE THIS VALUE -SECRET_KEY = 'this-is-a-secret-key-for-local-settings-only' - -# If needing to test on production mturk account (real money) -# AWS_URL = 'https://mechanicalturk.amazonaws.com' - -# Sandbox (development) MTURK (fake money used) -AWS_URL = 'https://mechanicalturk.sandbox.amazonaws.com' diff --git a/build/lib/graphspace/urls.py b/build/lib/graphspace/urls.py deleted file mode 100644 index eb933e9c..00000000 --- a/build/lib/graphspace/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.conf.urls import patterns, include, url -from django.contrib import admin -admin.autodiscover() - -urlpatterns = patterns('', - # Examples: - # url(r'^$', 'graphspace.views.home', name='home'), - # url(r'^blog/', include('blog.urls')), - - #url(r'^admin/', include(admin.site.urls)), - url(r'^', include('graphs.urls'), name='base'), - #url(r'^graphs/$', include('graphs.urls')), -) - -handler404 = 'graphs.views.handler_404' -handler500 = 'graphs.views.handler_500' \ No newline at end of file diff --git a/build/lib/graphspace/wsgi.py b/build/lib/graphspace/wsgi.py deleted file mode 100644 index ce82ea93..00000000 --- a/build/lib/graphspace/wsgi.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -WSGI config for graphspace project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ -""" - -import os -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "graphspace.settings.production") - -from django.core.wsgi import get_wsgi_application -application = get_wsgi_application() diff --git a/dist/GraphSpace-1.1.0-py2.7.egg b/dist/GraphSpace-1.1.0-py2.7.egg deleted file mode 100644 index 03c939df23a2c9bc1bdfef9d8fc1feb39b7c2df4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 150522 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

    _2&ct~v|x5&JltwJF3 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�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 NLvVngQ-NpA`hUgvMR))J From 24ea0e2fec1469056581b9a84a3db7627ba939fa Mon Sep 17 00:00:00 2001 From: Aditya Bharadwaj Date: Tue, 16 Aug 2016 13:36:36 -0400 Subject: [PATCH 17/23] refactoring get all notifications flow-still incomplete --- graphs/templates/graphs/notifications.html | 10 +- graphs/urls.py | 1 - graphs/util/db.py | 30 +- graphs/util/db_test.py | 4 +- graphs/views.py | 3455 +++++++++++--------- 5 files changed, 1887 insertions(+), 1613 deletions(-) diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 6b190686..c5470583 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -21,20 +21,20 @@

    Notifications

    - {% for group in groups_for_user %} + {% for group_id in notifications.keys() %} - + {% endfor %}
    {{ group.groupId }}{{ group_id }}
    - {% if notifications != None %} - {% if check_new_notifications == True %} + {% if grouped_notifications != None %} + {% if num_notifications > 0 %}
    - {% for group, events in notifications.items %} + {% for group, events in grouped_notifications.items %} {% if events != None %} diff --git a/graphs/urls.py b/graphs/urls.py index c1537398..8f00b7b4 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -24,7 +24,6 @@ # notifications page url(r'^(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/notifications/$', views.notifications, name='notifications'), - url(r'^(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/notifications/(?P.+)/$', views.notifications, name='notifications'), # view graph page. This contains regular expression to catch url in the form of the following: # /graphs/email_address/graph_id/ diff --git a/graphs/util/db.py b/graphs/util/db.py index 8150a265..770189da 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -4800,23 +4800,29 @@ def delete_share_graph_event(event_id, member_id): return {'Error': 'No share graph event found.'} -def get_share_graph_event_by_member_id(member_id): - ''' - Return a dictionary of share graph events for a user keyed by group id. - If all the share graph events for a particular group are inactive, i.e. read - then the group is keyed by None in the dictionary. +def get_share_graph_events_by_member_id(member_id, only_active=1): + """ + Return a list of share graph events for a user for a given userid of a member of any group and given status of the notification. + + If no results are found the method will raise NoResultFound exception. @param member_id: id of the user - ''' + @param is_active: 1 if we want only the list of unread share graph events else 0 to get all share graph events + @return: List of share graph events + + """ # Create database connection db_session = data_connection.new_session() - try: + if only_active: + events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.is_active == 1).all() + else: events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).all() - db_session.close() - return events - except NoResultFound: - db_session.close() - return {'Error': 'No share graph event found.'} + db_session.close() + return events + + # db_session.close() + # except NoResultFound: + # return {'Error': 'No share graph event found.'} def get_share_graph_event_by_id(event_id, member_id): diff --git a/graphs/util/db_test.py b/graphs/util/db_test.py index 14362255..1f0d0c03 100644 --- a/graphs/util/db_test.py +++ b/graphs/util/db_test.py @@ -1,8 +1,8 @@ from graphs.models import share_graph_event from django.test import TestCase from db import (get_all_share_graph_event, get_share_graph_event_by_id, - get_share_graph_event_by_member_id, delete_share_graph_event, - update_share_graph_event, add_share_graph_event) + get_share_graph_events_by_member_id, delete_share_graph_event, + update_share_graph_event, add_share_graph_event) class share_graph_event_test(TestCase): diff --git a/graphs/views.py b/graphs/views.py index 8a6f03ca..92afc13a 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -1,3 +1,5 @@ +from sqlalchemy.orm.exc import NoResultFound + from django.shortcuts import render, redirect from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.http import HttpResponse, HttpResponseRedirect, Http404 @@ -23,112 +25,118 @@ URL_PATH = settings.URL_PATH + ##### VIEWS ##### def image(request): - name = request.GET.get('name', '') + name = request.GET.get('name', '') + + if len(name) > 0: + return HttpResponseRedirect(URL_PATH + 'static/images/' + name + '.png') + else: + return HttpResponse(json.dumps(db.throwError(404, "Image not found!")), content_type="application/json") - if len(name) > 0: - return HttpResponseRedirect(URL_PATH + 'static/images/' + name + '.png') - else: - return HttpResponse(json.dumps(db.throwError(404, "Image not found!")), content_type="application/json") def saveFeedback(request): + if request.POST: + feedback = request.POST["feedback"] + graph_id = request.POST["graph_id"] + user_id = request.POST["user_id"] + layout_owner = request.POST["layout_owner"] + layout_name = request.POST["layout_name"] - if request.POST: - feedback = request.POST["feedback"] - graph_id = request.POST["graph_id"] - user_id = request.POST["user_id"] - layout_owner = request.POST["layout_owner"] - layout_name = request.POST["layout_name"] + error = db.saveFeedback(feedback, graph_id, user_id, layout_owner, layout_name) - error = db.saveFeedback(feedback, graph_id, user_id, layout_owner, layout_name) + if error != None: + return HttpResponse(json.dumps(db.throwError(500, error)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Feedback saved!")), content_type="application/json") - if error != None: - return HttpResponse(json.dumps(db.throwError(500, error)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Feedback saved!")), content_type="application/json") def getFeedback(request): + if request.POST: + graph_id = request.POST["graph_id"] + user_id = request.POST["user_id"] + layout_owner = request.POST["layout_owner"] + layout_name = request.POST["layout_name"] - if request.POST: - graph_id = request.POST["graph_id"] - user_id = request.POST["user_id"] - layout_owner = request.POST["layout_owner"] - layout_name = request.POST["layout_name"] + results = db.getFeedback(graph_id, user_id, layout_owner, layout_name) - results = db.getFeedback(graph_id, user_id, layout_owner, layout_name) + if len(results) > 0: + return HttpResponse(json.dumps(db.sendMessage(200, results)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(500, "No feedback entered for this task!")), + content_type="application/json") - if len(results) > 0: - return HttpResponse(json.dumps(db.sendMessage(200, results)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(500, "No feedback entered for this task!")), content_type="application/json") def index(request): - ''' + ''' Render the main page :param request: HTTP GET Request ''' - # If there is a POST request made to the main page (graphspace.org/index or graphspace.org/), - # that means that the user is trying to log on to GraphSpace. - # If they try to log on, we first check to see if their password needs to be reset (for whatever reason). - # The password_reset table contains all the users whose passwords need to be updated. - # Once the user has updated their password, their name is removed from the password_reset table + # If there is a POST request made to the main page (graphspace.org/index or graphspace.org/), + # that means that the user is trying to log on to GraphSpace. + # If they try to log on, we first check to see if their password needs to be reset (for whatever reason). + # The password_reset table contains all the users whose passwords need to be updated. + # Once the user has updated their password, their name is removed from the password_reset table + + if request.method == 'POST' and db.need_to_reset_password(request.POST['user_id']) != None: + context = {} + + # Forcibly clearing an existing user session (essentially logging user out) + request.session['uid'] = None + + # Email the user the link to reset their password + result = db.sendForgotEmail(request.POST['user_id']) + + # Any and all errors are thrown via "Error" key in context. This will + # be displayed to the user on the front end through a message. + context['Error'] = "Need to reset your password! An email has been sent to " + request.POST[ + 'user_id'] + ' with instructions to reset your password!' + return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); + + # Action to login the user to GraphSpace + context = login(request) + + if context['Error'] == None: + return render(request, 'graphs/index.html', context) + elif db.need_to_reset_password(request.POST['user_id']) != None: + context = {} + context['Error'] = "Invalid password. Perhaps you need to reset your password!" + # Any and all errors are thrown via "Error" key in context. This will + # be displayed to the user on the front end through a message. + return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); + else: + # If there is a problem, throw error and the reason why there was a problem + return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); - if request.method == 'POST' and db.need_to_reset_password(request.POST['user_id']) != None: - context = {} - - # Forcibly clearing an existing user session (essentially logging user out) - request.session['uid'] = None - - # Email the user the link to reset their password - result = db.sendForgotEmail(request.POST['user_id']) - - # Any and all errors are thrown via "Error" key in context. This will - # be displayed to the user on the front end through a message. - context['Error'] = "Need to reset your password! An email has been sent to " + request.POST['user_id'] + ' with instructions to reset your password!' - return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); - - # Action to login the user to GraphSpace - context = login(request) - - if context['Error'] == None: - return render(request, 'graphs/index.html', context) - elif db.need_to_reset_password(request.POST['user_id']) != None: - context = {} - context['Error'] = "Invalid password. Perhaps you need to reset your password!" - # Any and all errors are thrown via "Error" key in context. This will - # be displayed to the user on the front end through a message. - return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); - else: - # If there is a problem, throw error and the reason why there was a problem - return HttpResponse(json.dumps(db.throwError(400, context['Error'])), content_type="application/json"); def logout(request): - ''' + ''' Log the user out and display logout page. :param request: HTTP GET Request ''' - # Clears all context - context = {} - - # Deletes the "Uid" key from the session - # currently being tracked by Django. - try: - del request.session['uid'] - except KeyError: - # TODO: should something be done here? - pass + # Clears all context + context = {} + + # Deletes the "Uid" key from the session + # currently being tracked by Django. + try: + del request.session['uid'] + except KeyError: + # TODO: should something be done here? + pass + + # redirect to the main page after logout. + return HttpResponseRedirect('/index/') - # redirect to the main page after logout. - return HttpResponseRedirect('/index/') def download(request): - ''' + ''' Download the graph as an image. Used for when user requests to download PNG of graph. @@ -136,47 +144,51 @@ def download(request): ''' - # Only respond if it is a POST request. - # It will contain the image to be downloaded by the user - if request.POST: - if request.POST['image']: - response = HttpResponse(request.POST['image'], content_type='application/octet-stream') - response['Content-Disposition'] = 'attachment; filename="foo.png"' - return response + # Only respond if it is a POST request. + # It will contain the image to be downloaded by the user + if request.POST: + if request.POST['image']: + response = HttpResponse(request.POST['image'], content_type='application/octet-stream') + response['Content-Disposition'] = 'attachment; filename="foo.png"' + return response + + else: + # redirect to the main page + return HttpResponseRedirect('/index/') - else: - # redirect to the main page - return HttpResponseRedirect('/index/') def graphs(request): - ''' + ''' Render the My Graphs page :param request: HTTP GET Request ''' - return _graphs_page(request, 'my graphs') - + return _graphs_page(request, 'my graphs') + + def shared_graphs(request): - ''' + ''' Render the graphs/shared/ page showing all graphs that are shared with a user :param request: HTTP GET Request ''' - - return _graphs_page(request, 'shared') + + return _graphs_page(request, 'shared') + def public_graphs(request): - ''' + ''' Render the graphs/public/ page showing all graphs that are public :param request: HTTP GET Request ''' - return _graphs_page(request, 'public') + return _graphs_page(request, 'public') + def _graphs_page(request, view_type): - ''' + ''' wrapper view for the following pages: graphs/ graphs/shared/ @@ -185,293 +197,307 @@ def _graphs_page(request, view_type): :param request: HTTP GET Request :param view_type: Type of view for graph (Ex: my graphs, shared, public) ''' - # context of the view to be passed in for rendering - context = {} - - # List of graphs that will be returned by the request - graph_list = None + # context of the view to be passed in for rendering + context = {} + + # List of graphs that will be returned by the request + graph_list = None - # handle login - context = login(request) + # handle login + context = login(request) - # Send view_type to front end to tell the user (through button color) where they are - # The view_type refers to which category of graphs are being viewed (public, shared, my graphs) - context['view_type'] = view_type + # Send view_type to front end to tell the user (through button color) where they are + # The view_type refers to which category of graphs are being viewed (public, shared, my graphs) + context['view_type'] = view_type - # If there is an error, display the error - if context['Error']: - return render(request, 'graphs/error.html', context) + # If there is an error, display the error + if context['Error']: + return render(request, 'graphs/error.html', context) - # Checks to see if a user is currently logged on - uid = request.session['uid'] + # Checks to see if a user is currently logged on + uid = request.session['uid'] - # Placeholder to keep track of - # whether we are partially searching or - # exact searching - search_type = None + # Placeholder to keep track of + # whether we are partially searching or + # exact searching + search_type = None - # Partial search may be thought of as "contains" matching - # Exact search may be though of as "identical" matching - if 'partial_search' in request.GET: - search_type = 'partial_search' - elif 'full_search' in request.GET: - search_type = 'full_search' + # Partial search may be thought of as "contains" matching + # Exact search may be though of as "identical" matching + if 'partial_search' in request.GET: + search_type = 'partial_search' + elif 'full_search' in request.GET: + search_type = 'full_search' - # Set all information abouut graphs to the front-end - # Information of graphs consists of all data for an individual graph - # as well as any search queries and tag queries being performed - context = db.get_graphs_for_view_type(context, view_type, uid, request) + # Set all information abouut graphs to the front-end + # Information of graphs consists of all data for an individual graph + # as well as any search queries and tag queries being performed + context = db.get_graphs_for_view_type(context, view_type, uid, request) - # Holds the amount of times a tag appears for a graph - all_tags = {} + # Holds the amount of times a tag appears for a graph + all_tags = {} - # Goes through all the graphs that are currently on a page - if context['graph_list'] != None: - pager_context = pager(request, context['graph_list']) - if type(pager_context) is dict: - context.update(pager_context) - for i in xrange(len(context['current_page'].object_list)): - graph = list(context['current_page'][i]) - # Get all the tags associated with current graphs and populate the - # tags accordion - graph_tags = [] + # Goes through all the graphs that are currently on a page + if context['graph_list'] != None: + pager_context = pager(request, context['graph_list']) + if type(pager_context) is dict: + context.update(pager_context) + for i in xrange(len(context['current_page'].object_list)): + graph = list(context['current_page'][i]) + # Get all the tags associated with current graphs and populate the + # tags accordion + graph_tags = [] - if request.GET.get(search_type): - user_id = graph[5] - graph_id = graph[0] - graph_tags = db.get_all_tags_for_graph(graph_id, user_id) - graph[1] = graph_tags - graph.append(db.get_visibility_of_graph(user_id, graph_id)) - else: - user_id = graph[2] - graph_id = graph[0] - graph_tags = db.get_all_tags_for_graph(graph_id, user_id) - graph.insert(1, graph_tags) - graph.append(db.get_visibility_of_graph(user_id, graph_id)) + if request.GET.get(search_type): + user_id = graph[5] + graph_id = graph[0] + graph_tags = db.get_all_tags_for_graph(graph_id, user_id) + graph[1] = graph_tags + graph.append(db.get_visibility_of_graph(user_id, graph_id)) + else: + user_id = graph[2] + graph_id = graph[0] + graph_tags = db.get_all_tags_for_graph(graph_id, user_id) + graph.insert(1, graph_tags) + graph.append(db.get_visibility_of_graph(user_id, graph_id)) - context['current_page'].object_list[i] = graph + context['current_page'].object_list[i] = graph - # reset the search form - context['search_form'] = SearchForm(placeholder='Search...') + # reset the search form + context['search_form'] = SearchForm(placeholder='Search...') - # Checks to see if there are any tags that the user wants to search for - request_tags = request.GET.get('tags') or request.GET.get('tag') or None + # Checks to see if there are any tags that the user wants to search for + request_tags = request.GET.get('tags') or request.GET.get('tag') or None - # If there are no graphs returned by the query, then display message on - # how to add graphs - if len(context['graph_list']) == 0: - context = constructGraphMessage(context, view_type, request.GET.get(search_type), request_tags) + # If there are no graphs returned by the query, then display message on + # how to add graphs + if len(context['graph_list']) == 0: + context = constructGraphMessage(context, view_type, request.GET.get(search_type), request_tags) - recent_graphs = context['graph_list'] + recent_graphs = context['graph_list'] - recent_graphs.sort(key=lambda r: r[2], reverse=True) + recent_graphs.sort(key=lambda r: r[2], reverse=True) - if len(recent_graphs) > 250: - recent_graphs = recent_graphs[:250] + if len(recent_graphs) > 250: + recent_graphs = recent_graphs[:250] - graph_tags = [] + graph_tags = [] - for graph in recent_graphs: + for graph in recent_graphs: - if request.GET.get(search_type): - graph_tags = db.get_all_tags_for_graph(graph[0], graph[5]) - else: - graph_tags = db.get_all_tags_for_graph(graph[0], graph[2]) + if request.GET.get(search_type): + graph_tags = db.get_all_tags_for_graph(graph[0], graph[5]) + else: + graph_tags = db.get_all_tags_for_graph(graph[0], graph[2]) - for tag in graph_tags: - if len(tag) > 0: - if tag in all_tags: - all_tags[tag] += 1 - else: - all_tags[tag] = 1 + for tag in graph_tags: + if len(tag) > 0: + if tag in all_tags: + all_tags[tag] += 1 + else: + all_tags[tag] = 1 - sorted_tags = sorted(all_tags.items(), key=operator.itemgetter(1), reverse = True)[:10] + sorted_tags = sorted(all_tags.items(), key=operator.itemgetter(1), reverse=True)[:10] - all_tags_refined = [i[0] for i in sorted_tags] + all_tags_refined = [i[0] for i in sorted_tags] - # Populates tags search bar with most used tags of last 250 graphs - context['all_tags'] = all_tags_refined #list(set(all_tags))[:10] + # Populates tags search bar with most used tags of last 250 graphs + context['all_tags'] = all_tags_refined # list(set(all_tags))[:10] - # indicator to include css/js footer for side menu support etc. - context['footer'] = True + # indicator to include css/js footer for side menu support etc. + context['footer'] = True - return render(request, 'graphs/graphs.html', context) + return render(request, 'graphs/graphs.html', context) # Controller method for notifications. -def get_notifications_for_user(user_id, group_id=None): - if group_id == None: - events = db.get_share_graph_event_by_member_id(user_id) - events_group = {} - for event in events: - if event.group_id in events_group: - events_group[event.group_id].append(event) - else: - events_group[event.group_id] = [event] - for group, events in events_group.items(): - if all(event.is_active == 0 for event in events): - events_group[group] = None - return events_group - else: - events = db.get_share_graph_event_by_member_id_and_group_id(user_id, group_id) - return events +def get_notifications_for_user(user_id, group_id=None, only_active=1): + # TODO: @Mridul Add docstring + try: + if group_id == None: + events = db.get_share_graph_events_by_member_id(user_id, only_active) + else: + events = db.get_share_graph_event_by_member_id_and_group_id(user_id, group_id, only_active) + except NoResultFound: + events = list() + except: + raise Exception('Database Error: Error while fetching notifications') + + return events # View method for notifications -def notifications(request, uid, groupname=None): - # handle login - context = login(request) - # Checks to see if a user is currently logged on - uid = request.session['uid'] - if groupname == None: - context['groups_for_user'] = db.groups_for_user(uid) - context['notifications'] = get_notifications_for_user(uid) - context['check_new_notifications'] = db.check_new_notifications(uid) - if groupname != None: - context['groups_for_user'] = db.groups_for_user(uid) - context['all_events_in_group'] = get_notifications_for_user(uid, groupname) - context['notifications'] = None - context['group'] = groupname - - # Better way of handling errors? As we don't have any GraphSpace - # specific excpections I am just using the generic SQLalchemy ORM - # generic exception NoResultFound. - for i in context: - try: - if 'Error' in i: - context['Error'] = i['Error'] - return render(request, 'graphs/error.html', context) - except: - pass - if uid is None: - context['Error'] = "Please log in to view notifications." - return render(request, 'graphs/error.html', context) - return render(request, 'graphs/notifications.html', context) +def notifications(request, uid): + # TODO: @Mridul Add docstring + # handle login + context = login(request) + # Checks to see if a user is currently logged in + uid = request.session['uid'] + group_id = request.GET.get('group_id') + all = request.GET.get('all') + + if uid is None: + context['Error'] = "Please log in to view notifications." + return render(request, 'graphs/error.html', context) + + try: + notifications = get_notifications_for_user(uid, group_id, all=all) + + context['grouped_notifications'] = dict() + for notification in notifications: + if notification.group_id in context['grouped_notifications']: + context['grouped_notifications'][notification.group_id].append(notification) + else: + context['grouped_notifications'][notification.group_id] = [notification] + + context['group'] = group_id + context['num_notifications'] = len(notifications) + + return render(request, 'graphs/notifications.html', context) + except Exception, e: + # Better way of handling errors? As we don't have any GraphSpace + # specific excpections I am just using the generic SQLalchemy ORM + # generic exception NoResultFound. + context['Error'] = str(e) + return render(request, 'graphs/error.html', context) def mark_notifications_as_read(request, uid): - ''' + ''' View for marking a notification as read. Fetch gid, nid, allid and uid for a request and update the database accordingly. ''' - if request.method == 'POST': - nid = request.POST['nid'] if 'nid' in request.POST else None - allid = request.POST['allid'] if 'allid' in request.POST else None - gid = request.POST['gid'] if 'gid' in request.POST else None - uid = request.session.get('uid', None) - # Check if the user is authenticated - if uid == None: - return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") - - # If gid and allid are None then the user clicked on mark as read - # for an individual notification. - if gid is None and allid is None: - events = [nid] - - # if gid is not None then the user clicked on mark as read for - # all the notifications in a group - elif gid is not None: - events = [event.id for event in db.get_share_graph_event_by_member_id_and_group_id(uid, gid)] - - # if allid is not None then the user clicked on mark all notifications - # as read for a user. - elif allid is not None: - events = [event.id for event in db.get_share_graph_event_by_member_id(uid)] - - event_info = db.set_share_graph_events_inactive(events, uid) - - if event_info is not None: - return HttpResponse(json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully updated share event(s) owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") + if request.method == 'POST': + nid = request.POST['nid'] if 'nid' in request.POST else None + allid = request.POST['allid'] if 'allid' in request.POST else None + gid = request.POST['gid'] if 'gid' in request.POST else None + uid = request.session.get('uid', None) + # Check if the user is authenticated + if uid == None: + return HttpResponse( + json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, + separators=(',', ': ')), content_type="application/json") + + # If gid and allid are None then the user clicked on mark as read + # for an individual notification. + if gid is None and allid is None: + events = [nid] + + # if gid is not None then the user clicked on mark as read for + # all the notifications in a group + elif gid is not None: + events = [event.id for event in db.get_share_graph_event_by_member_id_and_group_id(uid, gid)] + + # if allid is not None then the user clicked on mark all notifications + # as read for a user. + elif allid is not None: + events = [event.id for event in db.get_share_graph_events_by_member_id(uid)] + + event_info = db.set_share_graph_events_inactive(events, uid) + + if event_info is not None: + return HttpResponse( + json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse( + json.dumps(db.sendMessage(200, "Successfully updated share event(s) owned by " + uid + '.'), indent=4, + separators=(',', ': ')), content_type="application/json") def upload_graph_through_ui(request): + if request.method == 'POST': + login_form = LoginForm() + register_form = RegisterForm() + + upload_json = True + + title_of_graph = None + + if 'title' in request.POST: + title_of_graph = request.POST['title'] + + if str(request.FILES['graphname'])[-4:] != "json": + upload_json = None + + if request.POST['email'] == 'Public User': + # assign random id generator + if upload_json: + result = db.uploadJSONFile(None, request.FILES['graphname'].read(), title_of_graph) + else: + result = db.uploadCyjsFile(None, request.FILES['graphname'].read(), title_of_graph) + + if 'Error' not in result: + context = {'login_form': login_form, 'register_form': register_form, 'Success': result['Success']} + else: + context = {'login_form': login_form, 'register_form': register_form, 'Error': result['Error']} + return render(request, 'graphs/upload_graph.html', context) + else: + + if upload_json: + result = db.uploadJSONFile(request.POST['email'], request.FILES['graphname'].read(), title_of_graph) + else: + result = db.uploadCyjsFile(request.POST['email'], request.FILES['graphname'].read(), title_of_graph) + + if 'Error' not in result: + context = {'login_form': login_form, 'uid': request.POST['email'], 'register_form': register_form, + 'Success': result['Success']} + else: + context = {'login_form': login_form, 'uid': request.POST['email'], 'register_form': register_form, + 'Error': result['Error']} + + return render(request, 'graphs/upload_graph.html', context) + else: + context = login(request) + return render(request, 'graphs/upload_graph.html', context) - if request.method == 'POST': - login_form = LoginForm() - register_form = RegisterForm() - - upload_json = True - - title_of_graph = None - - if 'title' in request.POST: - title_of_graph = request.POST['title'] - - if str(request.FILES['graphname'])[-4:] != "json": - upload_json = None - - if request.POST['email'] == 'Public User': - # assign random id generator - if upload_json: - result = db.uploadJSONFile(None, request.FILES['graphname'].read(), title_of_graph) - else: - result = db.uploadCyjsFile(None, request.FILES['graphname'].read(), title_of_graph) - - if 'Error' not in result: - context = {'login_form': login_form, 'register_form': register_form, 'Success': result['Success']} - else: - context = {'login_form': login_form, 'register_form': register_form, 'Error': result['Error']} - return render(request, 'graphs/upload_graph.html', context) - else: - - if upload_json: - result = db.uploadJSONFile(request.POST['email'], request.FILES['graphname'].read(), title_of_graph) - else: - result = db.uploadCyjsFile(request.POST['email'], request.FILES['graphname'].read(), title_of_graph) - - if 'Error' not in result: - context = {'login_form': login_form, 'uid': request.POST['email'], 'register_form': register_form, 'Success': result['Success']} - else: - context = {'login_form': login_form, 'uid': request.POST['email'], 'register_form': register_form, 'Error': result['Error']} - - return render(request, 'graphs/upload_graph.html', context) - else: - context = login(request) - return render(request, 'graphs/upload_graph.html', context) def save_layout(request, uid, gid): - ''' + ''' Saves a layout for a graph. :param HTTP POST Request ''' - graph_owner = uid - if request.POST: - if uid == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to save a layout!")), content_type="application/json") + graph_owner = uid + if request.POST: + if uid == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to save a layout!")), + content_type="application/json") - result = db.save_layout(gid, graph_owner, request.POST['layout_name'], request.POST['loggedIn'], request.POST['points'], request.POST['public'], request.POST['unlisted']) - if result == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Layout saved!")), content_type="application/json") - - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + result = db.save_layout(gid, graph_owner, request.POST['layout_name'], request.POST['loggedIn'], + request.POST['points'], request.POST['public'], request.POST['unlisted']) + if result == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Layout saved!")), content_type="application/json") + + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def update_layout(request, uid, gid): - ''' + ''' Updates a layout for a graph. :param HTTP POST Request ''' - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + error = db.update_layout(gid, uid, request.POST['layout_name'], request.POST['loggedIn'], request.POST['points'], + request.POST['public'], request.POST['unlisted'], request.POST['originalLayout']) + if error == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Layout updated!")), content_type="application/json") + + return HttpResponse(json.dumps(db.throwError(400, error)), content_type="application/json"); - error = db.update_layout(gid, uid, request.POST['layout_name'], request.POST['loggedIn'], request.POST['points'], request.POST['public'], request.POST['unlisted'], request.POST['originalLayout']) - if error == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Layout updated!")), content_type="application/json") - - return HttpResponse(json.dumps(db.throwError(400, error)), content_type="application/json"); def design_graph(request, uid, gid): - ''' + ''' View a graph with CytoscapeJS along with tool pallete to help researcher layout of a graph. @@ -480,183 +506,189 @@ def design_graph(request, uid, gid): :param gid: Graph id of the graph to view ''' - # Context contains all the elements we want to render on the web - # page. We fill in the various elements of context before calling - # the render() function. - #handle login - # context = login(request) - context = { - "uid": request.session['uid'], - "Error": None - } - - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - #TODO: Create trigger to delete older tasks (3 days) - - # if the graph is public, or if a user is a member - # of the group where this graph is shared - # or if he owns this graph, then allow him to view it - # otherwise do not allow it - if db.is_public_graph(uid, gid) or 'Public_User_' in uid: - graph_to_view = db.get_all_info_for_graph(uid, gid) - elif request.session['uid'] == None: - context['Error'] = "You are not authorized to view this graph, create an account and contact graph's owner for permission to see this graph." - return render(request, 'graphs/error.html', context) - else: - # If the user is member of group where this graph is shared - user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) - - # if user is owner of graph or a member of group that shares graph - if request.session['uid'] == uid or user_is_member == True: - graph_info = db.getGraphInfo(uid, gid) - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it." - return render(request, 'graphs/error.html', context) - else: - context['Error'] = "You are not authorized to view this graph, please contact graph's owner for permission." - return render(request, 'graphs/error.html', context) - - # Get correct layout for the graph to view - context = db.set_layout_context(request, context, uid, gid) - - if context['Error']: - return render(request, 'graphs/error.html', context) - - # Convert JSON for CytoscapeJS, if needed - context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) - context['draw_graph'] = True - - # TODO: This will eventually get deleted - json_data = json.loads(context['graph']) - - # id of the owner of this graph - context['owner'] = uid - - # graph id - context['graph_id'] = gid - - # Don't display the task_view - context["task_view"] = False - context["approve_view"] = False - context["researcher_view"] = False - context["designer_view"] = True - - return render(request, 'graphs/view_graph.html', context) + # Context contains all the elements we want to render on the web + # page. We fill in the various elements of context before calling + # the render() function. + # handle login + # context = login(request) + context = { + "uid": request.session['uid'], + "Error": None + } + + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + # TODO: Create trigger to delete older tasks (3 days) + + # if the graph is public, or if a user is a member + # of the group where this graph is shared + # or if he owns this graph, then allow him to view it + # otherwise do not allow it + if db.is_public_graph(uid, gid) or 'Public_User_' in uid: + graph_to_view = db.get_all_info_for_graph(uid, gid) + elif request.session['uid'] == None: + context[ + 'Error'] = "You are not authorized to view this graph, create an account and contact graph's owner for permission to see this graph." + return render(request, 'graphs/error.html', context) + else: + # If the user is member of group where this graph is shared + user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) + + # if user is owner of graph or a member of group that shares graph + if request.session['uid'] == uid or user_is_member == True: + graph_info = db.getGraphInfo(uid, gid) + if graph_info != None: + graph_to_view = graph_info + else: + context[ + 'Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it." + return render(request, 'graphs/error.html', context) + else: + context['Error'] = "You are not authorized to view this graph, please contact graph's owner for permission." + return render(request, 'graphs/error.html', context) + + # Get correct layout for the graph to view + context = db.set_layout_context(request, context, uid, gid) + + if context['Error']: + return render(request, 'graphs/error.html', context) + + # Convert JSON for CytoscapeJS, if needed + context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) + context['draw_graph'] = True + + # TODO: This will eventually get deleted + json_data = json.loads(context['graph']) + + # id of the owner of this graph + context['owner'] = uid + + # graph id + context['graph_id'] = gid + + # Don't display the task_view + context["task_view"] = False + context["approve_view"] = False + context["researcher_view"] = False + context["designer_view"] = True + + return render(request, 'graphs/view_graph.html', context) + def view_graph(request, uid, gid): - ''' + ''' View a graph with CytoscapeJS. :param request: HTTP GET Request :param uid: Owner of the graph to view :param gid: Graph id of the graph to view ''' - # Context contains all the elements we want to render on the web - # page. We fill in the various elements of context before calling - # the render() function. - #handle login - context = login(request) - - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - #TODO: Create trigger to delete older tasks (3 days) - - # if the graph is public, or if a user is a member - # of the group where this graph is shared - # or if he owns this graph, then allow him to view it - # otherwise do not allow it - if db.is_public_graph(uid, gid) or 'Public_User_' in uid: - graph_to_view = db.get_all_info_for_graph(uid, gid) - elif request.session['uid'] == None: - context['Error'] = "You are not authorized to view this graph, create an account and contact graph's owner for permission to see this graph." - return render(request, 'graphs/error.html', context) - else: - # If the user is member of group where this graph is shared - user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) - - # if user is owner of graph or a member of group that shares graph - if request.session['uid'] == uid or user_is_member == True: - graph_info = db.getGraphInfo(uid, gid) - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it." - return render(request, 'graphs/error.html', context) - else: - context['Error'] = "You are not authorized to view this graph, please contact graph's owner for permission." - return render(request, 'graphs/error.html', context) - - # Get correct layout for the graph to view - context = db.set_layout_context(request, context, uid, gid) - - if context['Error']: - return render(request, 'graphs/error.html', context) - - # Convert JSON for CytoscapeJS, if needed - context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) - context['draw_graph'] = True - - # Get all the groups that are shared for this graph - shared_groups = db.get_all_groups_for_this_graph(uid, graph_to_view[2]) - - format_shared_groups = [] - for shared_group in shared_groups: - format_shared_groups.append((shared_group.group_id, shared_group.owner_id)) - - context['shared_groups'] = format_shared_groups - - if graph_to_view[1] == 1: - context['shared'] = 'Publicly Shared' - else: - context['shared'] = 'Privately Shared' - - # TODO: This will eventually get deleted - json_data = json.loads(context['graph']) - #add sidebar information to the context for display - if 'description' in json_data['metadata']: - context['description'] = json_data['metadata']['description'] + "
    " - else: - context['description'] = "" - - # id of the owner of this graph - context['owner'] = uid - - # If the metadata has either a name or a title (backward-compatible) - # display it on the top of the graph - if 'name' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['name'] - elif 'title' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['title'] - else: - context['graph_name'] = '' - - # graph id - context['graph_id'] = gid - - # Don't display the task_view - context["task_view"] = False - context["approve_view"] = False - context["researcher_view"] = True - - # HARDCODED GROUP.. IF USER IS IN THIS GROUP, THEN ONLY THEN CAN THEY LAUNCH TASKS ON MTURK - context["crowd_group"] = db.getCrowdEnabledGroup() - - if len(json_data['graph']['edges']) > 0 and 'k' in json_data['graph']['edges'][0]['data']: - context['filters'] = True - - # redirect if the user wishes to view the json data - if request.method == "GET" and 'view_json' in request.GET: - return HttpResponseRedirect("/json/%s/%s" % (uid, gid)) - - return render(request, 'graphs/view_graph.html', context) + # Context contains all the elements we want to render on the web + # page. We fill in the various elements of context before calling + # the render() function. + # handle login + context = login(request) + + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + # TODO: Create trigger to delete older tasks (3 days) + + # if the graph is public, or if a user is a member + # of the group where this graph is shared + # or if he owns this graph, then allow him to view it + # otherwise do not allow it + if db.is_public_graph(uid, gid) or 'Public_User_' in uid: + graph_to_view = db.get_all_info_for_graph(uid, gid) + elif request.session['uid'] == None: + context[ + 'Error'] = "You are not authorized to view this graph, create an account and contact graph's owner for permission to see this graph." + return render(request, 'graphs/error.html', context) + else: + # If the user is member of group where this graph is shared + user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) + + # if user is owner of graph or a member of group that shares graph + if request.session['uid'] == uid or user_is_member == True: + graph_info = db.getGraphInfo(uid, gid) + if graph_info != None: + graph_to_view = graph_info + else: + context[ + 'Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it." + return render(request, 'graphs/error.html', context) + else: + context['Error'] = "You are not authorized to view this graph, please contact graph's owner for permission." + return render(request, 'graphs/error.html', context) + + # Get correct layout for the graph to view + context = db.set_layout_context(request, context, uid, gid) + + if context['Error']: + return render(request, 'graphs/error.html', context) + + # Convert JSON for CytoscapeJS, if needed + context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) + context['draw_graph'] = True + + # Get all the groups that are shared for this graph + shared_groups = db.get_all_groups_for_this_graph(uid, graph_to_view[2]) + + format_shared_groups = [] + for shared_group in shared_groups: + format_shared_groups.append((shared_group.group_id, shared_group.owner_id)) + + context['shared_groups'] = format_shared_groups + + if graph_to_view[1] == 1: + context['shared'] = 'Publicly Shared' + else: + context['shared'] = 'Privately Shared' + + # TODO: This will eventually get deleted + json_data = json.loads(context['graph']) + # add sidebar information to the context for display + if 'description' in json_data['metadata']: + context['description'] = json_data['metadata']['description'] + "" + else: + context['description'] = "" + + # id of the owner of this graph + context['owner'] = uid + + # If the metadata has either a name or a title (backward-compatible) + # display it on the top of the graph + if 'name' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['name'] + elif 'title' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['title'] + else: + context['graph_name'] = '' + + # graph id + context['graph_id'] = gid + + # Don't display the task_view + context["task_view"] = False + context["approve_view"] = False + context["researcher_view"] = True + + # HARDCODED GROUP.. IF USER IS IN THIS GROUP, THEN ONLY THEN CAN THEY LAUNCH TASKS ON MTURK + context["crowd_group"] = db.getCrowdEnabledGroup() + + if len(json_data['graph']['edges']) > 0 and 'k' in json_data['graph']['edges'][0]['data']: + context['filters'] = True + + # redirect if the user wishes to view the json data + if request.method == "GET" and 'view_json' in request.GET: + return HttpResponseRedirect("/json/%s/%s" % (uid, gid)) + + return render(request, 'graphs/view_graph.html', context) + def view_task(request, uid, gid): - ''' + ''' View that workers will see for a launched task. :param request: HTTP GET Request @@ -664,127 +696,129 @@ def view_task(request, uid, gid): :param gid: name of graph that the user owns ''' - # db.getAssignmentsForGraph(uid, gid) - if 'uid' in request.session: - context = login(request) - context["task_view"] = True + # db.getAssignmentsForGraph(uid, gid) + if 'uid' in request.session: + context = login(request) + context["task_view"] = True - else: - login_form = LoginForm() - register_form = RegisterForm() - context = {'login_form': login_form, 'register_form': register_form, "Error": None, "task_view": True} + else: + login_form = LoginForm() + register_form = RegisterForm() + context = {'login_form': login_form, 'register_form': register_form, "Error": None, "task_view": True} - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] - graph_info = db.getGraphInfo(uid, gid) + graph_info = db.getGraphInfo(uid, gid) + if graph_info != None: + graph_to_view = graph_info + else: + context['Error'] = "Task does not exist anymore!." + return render(request, 'graphs/error.html', context) - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Task does not exist anymore!." - return render(request, 'graphs/error.html', context) + layout_name = request.GET.get('layout', '') + layout_owner = request.GET.get('layout_owner', '') - layout_name = request.GET.get('layout', '') - layout_owner = request.GET.get('layout_owner', '') + # Get correct layout for the graph to view + context = db.set_task_layout_context(request, context, uid, gid, layout_name, layout_owner) - # Get correct layout for the graph to view - context = db.set_task_layout_context(request, context, uid, gid, layout_name, layout_owner) + if context['Error']: + return render(request, 'graphs/error.html', context) - if context['Error']: - return render(request, 'graphs/error.html', context) + # Convert JSON for CytoscapeJS, if needed + context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) - # Convert JSON for CytoscapeJS, if needed - context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) + context['draw_graph'] = True - context['draw_graph'] = True + # TODO: This will eventually get deleted - # TODO: This will eventually get deleted + json_data = json.loads(context['graph']) - json_data = json.loads(context['graph']) + # add sidebar information to the context for display + if 'description' in json_data['metadata']: + context['description'] = json_data['metadata']['description'] + "" + else: + context['description'] = "" - #add sidebar information to the context for display - if 'description' in json_data['metadata']: - context['description'] = json_data['metadata']['description'] + "" - else: - context['description'] = "" + # id of the owner of this graph + context['owner'] = uid - # id of the owner of this graph - context['owner'] = uid + if 'name' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['name'] + else: + context['graph_name'] = '' - if 'name' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['name'] - else: - context['graph_name'] = '' + # graph id + context['graph_id'] = gid - # graph id - context['graph_id'] = gid + # owner + context["owner"] = uid - # owner - context["owner"] = uid + context["researcher_view"] = False + context["approve_view"] = False - context["researcher_view"] = False - context["approve_view"] = False + return render(request, 'graphs/view_graph.html', context) - return render(request, 'graphs/view_graph.html', context) def approve_task_expert(request): - if 'uid' in request.session: - context = login(request) - else: - context = {} + if 'uid' in request.session: + context = login(request) + else: + context = {} - tasks = db.getAllApproveTasks() - all_tasks = len(tasks) - for task in tasks: - if task.submitted == 0: + tasks = db.getAllApproveTasks() + all_tasks = len(tasks) + for task in tasks: + if task.submitted == 0: - uid = task.user_id - gid = task.graph_id + uid = task.user_id + gid = task.graph_id - graph_info = db.getGraphInfo(uid, gid) + graph_info = db.getGraphInfo(uid, gid) - layout = db.getLayoutById(task.layout_id) + layout = db.getLayoutById(task.layout_id) - context = db.set_task_layout_context(request, context, uid, gid, layout.layout_name, layout.owner_id, approve=True, expert=True) + context = db.set_task_layout_context(request, context, uid, gid, layout.layout_name, layout.owner_id, + approve=True, expert=True) - context['graph'] = db.retrieve_cytoscape_json(graph_info[0]) - context['remaining'] = all_tasks - context['draw_graph'] = True - - context["researcher_view"] = False - context["approve_view"] = True + context['graph'] = db.retrieve_cytoscape_json(graph_info[0]) + context['remaining'] = all_tasks + context['draw_graph'] = True - json_data = json.loads(context['graph']) + context["researcher_view"] = False + context["approve_view"] = True - #add sidebar information to the context for display - if 'description' in json_data['metadata']: - context['description'] = json_data['metadata']['description'] + "" - else: - context['description'] = "" + json_data = json.loads(context['graph']) - # id of the owner of this graph - context['owner'] = uid + # add sidebar information to the context for display + if 'description' in json_data['metadata']: + context['description'] = json_data['metadata']['description'] + "" + else: + context['description'] = "" - if 'name' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['name'] - else: - context['graph_name'] = '' + # id of the owner of this graph + context['owner'] = uid - # graph id - context['graph_id'] = gid + if 'name' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['name'] + else: + context['graph_name'] = '' - # owner - context["owner"] = uid + # graph id + context['graph_id'] = gid - return render(request, 'graphs/view_graph_expert.html', context) + # owner + context["owner"] = uid + + return render(request, 'graphs/view_graph_expert.html', context) + + context['Error'] = "It appears as if there are no more graphs to lay out. Thank you for your time!" + return render(request, 'graphs/error.html', context) - context['Error'] = "It appears as if there are no more graphs to lay out. Thank you for your time!" - return render(request, 'graphs/error.html', context) def approve_task(request, uid, gid): - ''' + ''' Approve or reject a task. :param request: HTTP GET Request @@ -792,237 +826,253 @@ def approve_task(request, uid, gid): :param gid: name of graph that the user owns ''' - if 'uid' in request.session: - context = login(request) + if 'uid' in request.session: + context = login(request) - else: - login_form = LoginForm() - register_form = RegisterForm() - context = {'login_form': login_form, 'register_form': register_form, "Error": None, "task_view": True} + else: + login_form = LoginForm() + register_form = RegisterForm() + context = {'login_form': login_form, 'register_form': register_form, "Error": None, "task_view": True} - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] - graph_info = db.getGraphInfo(uid, gid) + graph_info = db.getGraphInfo(uid, gid) - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Task does not exist anymore!." - return render(request, 'graphs/error.html', context) + if graph_info != None: + graph_to_view = graph_info + else: + context['Error'] = "Task does not exist anymore!." + return render(request, 'graphs/error.html', context) - layout_name = request.GET.get('layout', '') - layout_owner = request.GET.get('layout_owner', '') + layout_name = request.GET.get('layout', '') + layout_owner = request.GET.get('layout_owner', '') - context = db.set_task_layout_context(request, context, uid, gid, layout_name, layout_owner, approve=True) + context = db.set_task_layout_context(request, context, uid, gid, layout_name, layout_owner, approve=True) - if context['Error']: - return render(request, 'graphs/error.html', context) + if context['Error']: + return render(request, 'graphs/error.html', context) - # Convert JSON for CytoscapeJS, if needed - context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) + # Convert JSON for CytoscapeJS, if needed + context['graph'] = db.retrieve_cytoscape_json(graph_to_view[0]) - context['draw_graph'] = True - - context["researcher_view"] = False - context["approve_view"] = True + context['draw_graph'] = True - # TODO: This will eventually get deleted + context["researcher_view"] = False + context["approve_view"] = True - json_data = json.loads(context['graph']) + # TODO: This will eventually get deleted - #add sidebar information to the context for display - if 'description' in json_data['metadata']: - context['description'] = json_data['metadata']['description'] + "" - else: - context['description'] = "" + json_data = json.loads(context['graph']) - # id of the owner of this graph - context['owner'] = uid + # add sidebar information to the context for display + if 'description' in json_data['metadata']: + context['description'] = json_data['metadata']['description'] + "" + else: + context['description'] = "" - if 'name' in json_data['metadata']: - context['graph_name'] = json_data['metadata']['name'] - else: - context['graph_name'] = '' + # id of the owner of this graph + context['owner'] = uid - # graph id - context['graph_id'] = gid + if 'name' in json_data['metadata']: + context['graph_name'] = json_data['metadata']['name'] + else: + context['graph_name'] = '' - # owner - context["owner"] = uid + # graph id + context['graph_id'] = gid + + # owner + context["owner"] = uid + + return render(request, 'graphs/view_graph.html', context) - return render(request, 'graphs/view_graph.html', context) def submitEvaluation(request): - ''' + ''' Submits Evaluation for a task. ''' - if request.POST: + if request.POST: - gid = request.POST["graph_id"] - uid = request.POST["user_id"] - layout_name = request.POST["layout_name"] - layout_owner = request.POST["layout_owner"] - triangle_rating = request.POST["triangle_rating"] - rectangle_rating = request.POST["rectangle_rating"] - shape_rating = request.POST["shape_rating"] - color_rating = request.POST["color_rating"] - hit_id = request.POST["hit_id"] + gid = request.POST["graph_id"] + uid = request.POST["user_id"] + layout_name = request.POST["layout_name"] + layout_owner = request.POST["layout_owner"] + triangle_rating = request.POST["triangle_rating"] + rectangle_rating = request.POST["rectangle_rating"] + shape_rating = request.POST["shape_rating"] + color_rating = request.POST["color_rating"] + hit_id = request.POST["hit_id"] - task_code = db.submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, shape_rating, color_rating, hit_id) + task_code = db.submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, + shape_rating, color_rating, hit_id) + + if task_code != None: + return HttpResponse(json.dumps(db.sendMessage(201, task_code)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(500, "Evaluation Submission Unsucessful!")), + content_type="application/json") + else: + return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) - if task_code != None: - return HttpResponse(json.dumps(db.sendMessage(201, task_code)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(500, "Evaluation Submission Unsucessful!")), content_type="application/json") - else: - return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) def submitExpertEvaluation(request): - ''' + ''' Submits Expert Evaluation for a task. ''' - if request.POST: + if request.POST: - gid = request.POST["graph_id"] - uid = request.POST["user_id"] - layout_name = request.POST["layout_name"] - layout_owner = request.POST["layout_owner"] - triangle_rating = request.POST["triangle_rating"] - rectangle_rating = request.POST["rectangle_rating"] - shape_rating = request.POST["shape_rating"] - color_rating = request.POST["color_rating"] - hit_id = request.POST["hit_id"] + gid = request.POST["graph_id"] + uid = request.POST["user_id"] + layout_name = request.POST["layout_name"] + layout_owner = request.POST["layout_owner"] + triangle_rating = request.POST["triangle_rating"] + rectangle_rating = request.POST["rectangle_rating"] + shape_rating = request.POST["shape_rating"] + color_rating = request.POST["color_rating"] + hit_id = request.POST["hit_id"] - task_code = db.submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, shape_rating, color_rating, hit_id, expert=True) + task_code = db.submitEvaluation(uid, gid, layout_name, layout_owner, triangle_rating, rectangle_rating, + shape_rating, color_rating, hit_id, expert=True) + + if task_code != None: + return HttpResponse(json.dumps(db.sendMessage(201, task_code)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(500, "Evaluation Submission Unsucessful!")), + content_type="application/json") + else: + return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) - if task_code != None: - return HttpResponse(json.dumps(db.sendMessage(201, task_code)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(500, "Evaluation Submission Unsucessful!")), content_type="application/json") - else: - return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) def retrieveTaskCode(request): - ''' + ''' Retrieves code for a task when worker has completed task. ''' - if request.POST: + if request.POST: - gid = request.POST["graph_id"] - uid = request.POST["user_id"] - worked_layout = request.POST["layout_name"] - numChanges = request.POST["numChanges"] - timeSpent = request.POST["timeSpent"] - events = request.POST["events"] - hit_id = request.POST["hit_id"] + gid = request.POST["graph_id"] + uid = request.POST["user_id"] + worked_layout = request.POST["layout_name"] + numChanges = request.POST["numChanges"] + timeSpent = request.POST["timeSpent"] + events = request.POST["events"] + hit_id = request.POST["hit_id"] - if not gid or not uid: - return HttpResponse(json.dumps(db.throwError(201, "Must include both graph_id and user_id in POST request.")), content_type="application/json") - - surveyCode = db.retrieveTaskCode(uid, gid, worked_layout, numChanges, timeSpent, events, hit_id) + if not gid or not uid: + return HttpResponse( + json.dumps(db.throwError(201, "Must include both graph_id and user_id in POST request.")), + content_type="application/json") + + surveyCode = db.retrieveTaskCode(uid, gid, worked_layout, numChanges, timeSpent, events, hit_id) + + if surveyCode == None: + surveyCode = "Task does not exist anymore!" + return HttpResponse(json.dumps(db.sendMessage(201, surveyCode)), content_type="application/json") - if surveyCode == None: - surveyCode = "Task does not exist anymore!" - return HttpResponse(json.dumps(db.sendMessage(201, surveyCode)), content_type="application/json") + else: + return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) - else: - return render(request, 'graphs/error.html', {"Error": "This route only accepts POST Requests"}) def view_json(request, uid, gid): - ''' + ''' View json structure of a graph. :param request: HTTP GET Request :param uid: email of the user that owns this graph :param gid: name of graph that the user owns ''' - #handle login - context = login(request) - - if gid[len(gid) - 1] == '/': - gid = gid[:len(gid) - 1] - - # if the graph is public, or if a user is a member - # of the group where this graph is shared - # or if he owns this graph, then allow him to view it's JSON - # otherwise do not allow it - if db.is_public_graph(uid, gid) or 'Public_User_' in uid: - graph_to_view = db.get_all_info_for_graph(uid, gid) - elif request.session['uid'] == None: - context['Error'] = "You are not authorized to view JSON for this graph, create an account and contact graph's owner for permission to see this." - return render(request, 'graphs/error.html', context) - else: - # If the user is member of group where this graph is shared - user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) - - # if user is owner of graph or a member of group that shares graph - if request.session['uid'] == uid or user_is_member == True: - graph_info = db.getGraphInfo(uid, gid) - if graph_info != None: - graph_to_view = graph_info - else: - context['Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it's JSON." - return render(request, 'graphs/error.html', context) - else: - context['Error'] = "You are not authorized to view JSON for this graph, please contact graph's owner for permission." - return render(request, 'graphs/error.html', context) - - - graph_to_view = db.get_graph_json(uid, gid) - - if graph_to_view == None: - context['Error'] = "Graph not found, please make sure you have the correct URL." - return render(request, 'graphs/error.html', context) - - # Get correct json for CytoscapeJS - context['json'] = db.retrieve_cytoscape_json(graph_to_view) - - # id of the owner of this graph - context['owner'] = uid - - # graph id - context['graph_id'] = gid - - # If it is http request, render it in the specific page, otherwise just return the JSON - if request: - return render(request, 'graphs/view_json.html', context) - else: - return HttpResponse(context['json']) + # handle login + context = login(request) + + if gid[len(gid) - 1] == '/': + gid = gid[:len(gid) - 1] + + # if the graph is public, or if a user is a member + # of the group where this graph is shared + # or if he owns this graph, then allow him to view it's JSON + # otherwise do not allow it + if db.is_public_graph(uid, gid) or 'Public_User_' in uid: + graph_to_view = db.get_all_info_for_graph(uid, gid) + elif request.session['uid'] == None: + context[ + 'Error'] = "You are not authorized to view JSON for this graph, create an account and contact graph's owner for permission to see this." + return render(request, 'graphs/error.html', context) + else: + # If the user is member of group where this graph is shared + user_is_member = db.can_see_shared_graph(context['uid'], uid, gid) + + # if user is owner of graph or a member of group that shares graph + if request.session['uid'] == uid or user_is_member == True: + graph_info = db.getGraphInfo(uid, gid) + if graph_info != None: + graph_to_view = graph_info + else: + context[ + 'Error'] = "Graph: " + gid + " does not exist for " + uid + ". Upload a graph with this name into GraphSpace in order to see it's JSON." + return render(request, 'graphs/error.html', context) + else: + context[ + 'Error'] = "You are not authorized to view JSON for this graph, please contact graph's owner for permission." + return render(request, 'graphs/error.html', context) + + graph_to_view = db.get_graph_json(uid, gid) + + if graph_to_view == None: + context['Error'] = "Graph not found, please make sure you have the correct URL." + return render(request, 'graphs/error.html', context) + + # Get correct json for CytoscapeJS + context['json'] = db.retrieve_cytoscape_json(graph_to_view) + + # id of the owner of this graph + context['owner'] = uid + + # graph id + context['graph_id'] = gid + + # If it is http request, render it in the specific page, otherwise just return the JSON + if request: + return render(request, 'graphs/view_json.html', context) + else: + return HttpResponse(context['json']) + def groups(request): - ''' + ''' Render the Owner Of page, showing groups that are owned by the user. :param request: HTTP GET Request ''' - return _groups_page(request, 'owner of') + return _groups_page(request, 'owner of') + def groups_member(request): - ''' + ''' Render the Member Of page, showing the groups that the user belong to . :param request: HTTP GET Request ''' - return _groups_page(request, 'member') + return _groups_page(request, 'member') + def all_groups(request): - ''' + ''' Render the All Groups page, showing all groups in the database.Admin feature [NOT CURRENTLY SUPPORTED]. :param request: HTTP GET Request ''' - return _groups_page(request, 'all') + return _groups_page(request, 'all') + def _groups_page(request, view_type): - ''' + ''' Wrapper view for the following pages: groups/ groups/member/ @@ -1033,82 +1083,87 @@ def _groups_page(request, view_type): :param view_type: Type of view for the group (Example: owner of, member, public, all) ''' - #context of the view to be passed in for rendering - context = {} - group_list = None - - #handle login - context = login(request) - - #Send view_type to front end to tell the user (through button color) where they are - context['view_type'] = view_type - - #check for authentication - uid = request.session['uid'] - if uid is not None: - # Get groups that the user is a member of - if view_type == 'member': - group_list = db.get_all_groups_with_member(context['uid']) + db.get_groups_of_user(context['uid']) - - # if admin, then they can view this - elif view_type == 'all': - if db.is_admin(uid) == 1: - group_list = db.get_all_groups_in_server() - else: - context['Error'] = "You are not authorized to see this group's contents! Please contact group's owner to add you to the group!" - return render(request, 'graphs/error.html', context) - - #groups of logged in user(my groups) - else: - # List all groups that uid either owns. - group_list = db.get_groups_of_user(context['uid']) - - #Order all tuples if user wants to order their results - order_term = request.GET.get('order') - - if order_term: - if request.GET.get('order') == 'group_ascending': - group_list = sorted(group_list, key=lambda graph: graph[0]) - elif order_term == 'group_descending': - group_list = sorted(group_list, key=lambda graph: graph[0], reverse=True) - elif order_term == 'owner_ascending': - group_list = sorted(group_list, key=lambda graph: graph[2]) - elif order_term == 'owner_descending': - group_list = sorted(group_list, key=lambda graph: graph[2], reverse=True) - - else: - group_list = sorted(group_list, key=lambda graph: graph[0]) - - #add the group list to context to display on the page. - if len(group_list) != 0: - context['group_list'] = group_list - else: - context['group_list'] = None - - pager_context = pager(request, group_list) - - if type(pager_context) is dict: - context.update(pager_context) - - context['my_groups'] = len(db.get_groups_of_user(context['uid'])) - context['member_groups'] = len(db.get_all_groups_with_member(context['uid'])) + context['my_groups'] - - if view_type == 'owner of' and context['my_groups'] == 0: - context['message'] = "It appears that you are not an owner of any group. Please create a group in order to own a group." - elif view_type == 'member' and context['member_groups'] == 0 : - context['message'] = "It appears as if you are not a member of any group. Please join a group in order for them to appear here." - else: - context['message'] = "It appears as if there are currently no groups on GraphSpace." - - return render(request, 'graphs/groups.html', context) - - #No public groups anymore - else: - context['Error'] = "You need to be logged in and also be a member of this group in order to see this group's contents!" - return render(request, 'graphs/error.html', context) + # context of the view to be passed in for rendering + context = {} + group_list = None + + # handle login + context = login(request) + + # Send view_type to front end to tell the user (through button color) where they are + context['view_type'] = view_type + + # check for authentication + uid = request.session['uid'] + if uid is not None: + # Get groups that the user is a member of + if view_type == 'member': + group_list = db.get_all_groups_with_member(context['uid']) + db.get_groups_of_user(context['uid']) + + # if admin, then they can view this + elif view_type == 'all': + if db.is_admin(uid) == 1: + group_list = db.get_all_groups_in_server() + else: + context[ + 'Error'] = "You are not authorized to see this group's contents! Please contact group's owner to add you to the group!" + return render(request, 'graphs/error.html', context) + + # groups of logged in user(my groups) + else: + # List all groups that uid either owns. + group_list = db.get_groups_of_user(context['uid']) + + # Order all tuples if user wants to order their results + order_term = request.GET.get('order') + + if order_term: + if request.GET.get('order') == 'group_ascending': + group_list = sorted(group_list, key=lambda graph: graph[0]) + elif order_term == 'group_descending': + group_list = sorted(group_list, key=lambda graph: graph[0], reverse=True) + elif order_term == 'owner_ascending': + group_list = sorted(group_list, key=lambda graph: graph[2]) + elif order_term == 'owner_descending': + group_list = sorted(group_list, key=lambda graph: graph[2], reverse=True) + + else: + group_list = sorted(group_list, key=lambda graph: graph[0]) + + # add the group list to context to display on the page. + if len(group_list) != 0: + context['group_list'] = group_list + else: + context['group_list'] = None + + pager_context = pager(request, group_list) + + if type(pager_context) is dict: + context.update(pager_context) + + context['my_groups'] = len(db.get_groups_of_user(context['uid'])) + context['member_groups'] = len(db.get_all_groups_with_member(context['uid'])) + context['my_groups'] + + if view_type == 'owner of' and context['my_groups'] == 0: + context[ + 'message'] = "It appears that you are not an owner of any group. Please create a group in order to own a group." + elif view_type == 'member' and context['member_groups'] == 0: + context[ + 'message'] = "It appears as if you are not a member of any group. Please join a group in order for them to appear here." + else: + context['message'] = "It appears as if there are currently no groups on GraphSpace." + + return render(request, 'graphs/groups.html', context) + + # No public groups anymore + else: + context[ + 'Error'] = "You need to be logged in and also be a member of this group in order to see this group's contents!" + return render(request, 'graphs/error.html', context) + def graphs_in_group(request, group_owner, group_id): - ''' + ''' Groups/group_name page, where group_name is the name of the group to view the graphs that belong to the group. @@ -1123,133 +1178,136 @@ def graphs_in_group(request, group_owner, group_id): ''' - #handle login - context = login(request) + # handle login + context = login(request) + + # add search form + search_form = SearchForm() + context['search_form'] = search_form + + # if the group name is not one of the designated names, display graphs + # that belong to the group + if "uid" in context: + if group_id != 'all' or group_id != 'member': + + group_dict = db.groups_for_user(context['uid']) + + if not any(g_dict['groupId'] == group_id for g_dict in group_dict): + context[ + 'Error'] = "You need to be a member of a group to see its contents! Please contact group's owner to add you to the group!" + return render(request, 'graphs/error.html', context) + + # Get all graph information that belong to this group + graph_data = db.get_all_graphs_for_group(context['uid'], group_owner, group_id, request) + + search_type = None + context['search_result'] = False + + if 'partial_search' in request.GET: + search_type = 'partial_search' + elif 'full_search' in request.GET: + search_type = 'full_search' + + if search_type != None: + context['search_result'] = True + context['search_type'] = search_type + context['search_word'] = "" + + cleaned_search_terms = request.GET.get(search_type).split(',') + for i in xrange(len(cleaned_search_terms)): + cleaned_search_terms[i] = cleaned_search_terms[i].strip() + # Deleted no length search terms + if len(cleaned_search_terms[i]) == 0: + del cleaned_search_terms[i] + + for i in xrange(len(cleaned_search_terms)): + context['search_word'] += cleaned_search_terms[i] + ',' + + if len(context['search_word']) > 0: + context['search_word'] = context['search_word'][:len(context['search_word']) - 1] + + # include the graph data to the context + if len(graph_data) != 0: + context['graph_list'] = graph_data + else: + context['graph_list'] = None + if context['search_result'] == True: + context[ + 'message'] = "It appears as if there are no graphs in this group that match your search query!" + else: + context['message'] = "It appears as if there are no graphs in this group yet." + + group_information = db.get_group_by_id(group_owner, group_id) - # add search form - search_form = SearchForm() - context['search_form'] = search_form - - # if the group name is not one of the designated names, display graphs - # that belong to the group - if "uid" in context: - if group_id != 'all' or group_id != 'member': - - group_dict = db.groups_for_user(context['uid']) - - if not any(g_dict['groupId'] == group_id for g_dict in group_dict): - context['Error'] = "You need to be a member of a group to see its contents! Please contact group's owner to add you to the group!" - return render(request, 'graphs/error.html', context) - - # Get all graph information that belong to this group - graph_data = db.get_all_graphs_for_group(context['uid'], group_owner, group_id, request) - - search_type = None - context['search_result'] = False - - if 'partial_search' in request.GET: - search_type = 'partial_search' - elif 'full_search' in request.GET: - search_type = 'full_search' - - if search_type != None: - context['search_result'] = True - context['search_type'] = search_type - context['search_word'] = "" - - cleaned_search_terms = request.GET.get(search_type).split(',') - for i in xrange(len(cleaned_search_terms)): - cleaned_search_terms[i] = cleaned_search_terms[i].strip() - # Deleted no length search terms - if len(cleaned_search_terms[i]) == 0: - del cleaned_search_terms[i] - - for i in xrange(len(cleaned_search_terms)): - context['search_word'] += cleaned_search_terms[i] + ',' - - if len(context['search_word']) > 0: - context['search_word'] = context['search_word'][:len(context['search_word']) - 1] - - # include the graph data to the context - if len(graph_data) != 0: - context['graph_list'] = graph_data - else: - context['graph_list'] = None - if context['search_result'] == True: - context['message'] = "It appears as if there are no graphs in this group that match your search query!" - else: - context['message'] = "It appears as if there are no graphs in this group yet." - - group_information = db.get_group_by_id(group_owner, group_id) - - # pass the group_id to the context for display - context['group_id'] = group_information[0][4] - - context['group_name'] = group_information[0][3] - - context['group_owner'] = group_information[0][2] - - context['group_description'] = group_information[0][0] - - context['group_members'] = group_information[0][1] - - all_tags = [] - - # Goes through all the graphs that are currently on a page - pager_context = pager(request, graph_data) - if type(pager_context) is dict: - context.update(pager_context) - for i in xrange(len(context['current_page'].object_list)): - graph = list(context['current_page'][i]) - - graph_tags = [] - if request.GET.get(search_type): - user_id = graph[5] - graph_id = graph[0] - graph_tags = db.get_all_tags_for_graph(graph_id, user_id) - graph[1] = graph_tags - graph.append(db.get_visibility_of_graph(user_id, graph_id)) - else: - user_id = graph[2] - graph_id = graph[0] - graph_tags = db.get_all_tags_for_graph(graph_id, user_id) - graph.insert(1, graph_tags) - graph.append(db.get_visibility_of_graph(user_id, graph_id)) - all_tags += graph_tags - - context['current_page'].object_list[i] = graph - - context['all_tags'] = list(set(all_tags)) - # indicator to include css/js footer for side menu support etc. - context['footer'] = True - - return render(request, 'graphs/graphs_in_group.html', context) - # if the group name is one of the designated names, display - # appropriate vies for each - else: - if group_id == 'member': - return groups_member(request) - else: - return public_groups(request) - else: - context['Error'] = "Please log in to view groups page" - return render(request, 'graphs/error.html', context) + # pass the group_id to the context for display + context['group_id'] = group_information[0][4] + + context['group_name'] = group_information[0][3] + + context['group_owner'] = group_information[0][2] + + context['group_description'] = group_information[0][0] + + context['group_members'] = group_information[0][1] + + all_tags = [] + + # Goes through all the graphs that are currently on a page + pager_context = pager(request, graph_data) + if type(pager_context) is dict: + context.update(pager_context) + for i in xrange(len(context['current_page'].object_list)): + graph = list(context['current_page'][i]) + + graph_tags = [] + if request.GET.get(search_type): + user_id = graph[5] + graph_id = graph[0] + graph_tags = db.get_all_tags_for_graph(graph_id, user_id) + graph[1] = graph_tags + graph.append(db.get_visibility_of_graph(user_id, graph_id)) + else: + user_id = graph[2] + graph_id = graph[0] + graph_tags = db.get_all_tags_for_graph(graph_id, user_id) + graph.insert(1, graph_tags) + graph.append(db.get_visibility_of_graph(user_id, graph_id)) + all_tags += graph_tags + + context['current_page'].object_list[i] = graph + + context['all_tags'] = list(set(all_tags)) + # indicator to include css/js footer for side menu support etc. + context['footer'] = True + + return render(request, 'graphs/graphs_in_group.html', context) + # if the group name is one of the designated names, display + # appropriate vies for each + else: + if group_id == 'member': + return groups_member(request) + else: + return public_groups(request) + else: + context['Error'] = "Please log in to view groups page" + return render(request, 'graphs/error.html', context) def features(request): - ''' + ''' View features page. :param request: HTTP GET Request ''' - #handle login - context = login(request) + # handle login + context = login(request) + + return render(request, 'graphs/features.html', context) - return render(request, 'graphs/features.html', context) def help(request): - ''' + ''' Render the following pages: help/ @@ -1259,91 +1317,98 @@ def help(request): ''' - #handle login - context = login(request) + # handle login + context = login(request) + + return render(request, 'graphs/help_users.html', context) - return render(request, 'graphs/help_users.html', context) def help_programmers(request): - ''' + ''' Render the help/tutorials page. :param request: HTTP GET Request ''' - #handle login - context = login(request) + # handle login + context = login(request) + + return render(request, 'graphs/help_programmers.html', context) - return render(request, 'graphs/help_programmers.html', context) def help_graphs(request): - ''' + ''' Render the help/graphs page. :param request: HTTP GET Request ''' - #handle login - context = login(request) + # handle login + context = login(request) + + return render(request, 'graphs/help_graphs.html', context) - return render(request, 'graphs/help_graphs.html', context) def help_restapi(request): - ''' + ''' Render the help/restapi page. :param request: HTTP GET Request ''' - #handle login - context = login(request) + # handle login + context = login(request) + + return render(request, 'graphs/help_restapi.html', context) - return render(request, 'graphs/help_restapi.html', context) def help_jsonref(request): - ''' + ''' Render the help/jsonref page. :param request: HTTP GET Request ''' - #handle login - context = login(request) + # handle login + context = login(request) + + return render(request, 'graphs/help_jsonref.html', context) - return render(request, 'graphs/help_jsonref.html', context) def help_about(request): - ''' + ''' Render the help/about page. :param request: HTTP GET Request ''' - #handle login - context = login(request) + # handle login + context = login(request) + + return render(request, 'graphs/help_about.html', context) - return render(request, 'graphs/help_about.html', context) def help_tutorial(request): - ''' + ''' Render the help/tutorial page. :param request: HTTP GET Request ''' - #handle login - context = login(request) + # handle login + context = login(request) + + return render(request, 'graphs/help_tutorial.html', context) - return render(request, 'graphs/help_tutorial.html', context) def register(request): - ''' + ''' Register a new user. :param request: HTTP POST Request containing: @@ -1352,39 +1417,41 @@ def register(request): ''' - #if the form has been submitted - if request.method == 'POST' and 'user_id' in request.POST and 'password' in request.POST: - # RegisterForm is bound to POST data - register_form = RegisterForm(request.POST) - # form validation - if register_form.is_valid(): - # create new account by inserting the new account - # record to the database - user_id = register_form.cleaned_data['user_id'] + # if the form has been submitted + if request.method == 'POST' and 'user_id' in request.POST and 'password' in request.POST: + # RegisterForm is bound to POST data + register_form = RegisterForm(request.POST) + # form validation + if register_form.is_valid(): + # create new account by inserting the new account + # record to the database + user_id = register_form.cleaned_data['user_id'] + + if user_id == None: + return HttpResponse(json.dumps(db.throwError(400, "Email already exists!")), + content_type="application/json"); - if user_id == None: - return HttpResponse(json.dumps(db.throwError(400, "Email already exists!")), content_type="application/json"); - - # hash the password using bcrypt library - hashed_pw = bcrypt.hashpw( - register_form.cleaned_data['password'], - bcrypt.gensalt()) - admin = 0 + # hash the password using bcrypt library + hashed_pw = bcrypt.hashpw( + register_form.cleaned_data['password'], + bcrypt.gensalt()) + admin = 0 - db.insert_user(user_id, hashed_pw, admin) + db.insert_user(user_id, hashed_pw, admin) - # should display success message. not there yet. - return HttpResponseRedirect('/index/') - else: - register_form = RegisterForm() # An unbound form + # should display success message. not there yet. + return HttpResponseRedirect('/index/') + else: + register_form = RegisterForm() # An unbound form - context['register_form'] = register_form - context['footer'] = True + context['register_form'] = register_form + context['footer'] = True + + return render(request, 'graphs/register.html', context) - return render(request, 'graphs/register.html', context) def retrieveIDs(request): - ''' + ''' Retrieves ID's of the nodes. Used when highlighting elements of the graph. @@ -1396,25 +1463,28 @@ def retrieveIDs(request): ''' - #Grab id's of the nodes to highlight given the label of the nodes - if request.POST: - element_values = request.POST['values'].split(',') - elementDictionary = {} - for element in element_values: - elementDictionary[element] = [] - # Find an edge - if ':' in element: - elementDictionary[element] += db.find_edge(request.POST['uid'], request.POST['gid'], element.strip(), request.POST['search_type']) - else: - elementDictionary[element] += db.find_node(request.POST['uid'], request.POST['gid'], element.strip(), request.POST['search_type']) + # Grab id's of the nodes to highlight given the label of the nodes + if request.POST: + element_values = request.POST['values'].split(',') + elementDictionary = {} + for element in element_values: + elementDictionary[element] = [] + # Find an edge + if ':' in element: + elementDictionary[element] += db.find_edge(request.POST['uid'], request.POST['gid'], element.strip(), + request.POST['search_type']) + else: + elementDictionary[element] += db.find_node(request.POST['uid'], request.POST['gid'], element.strip(), + request.POST['search_type']) + + return HttpResponse(json.dumps(elementDictionary)) + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - return HttpResponse(json.dumps(elementDictionary)) - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def sendResetEmail(request): - ''' + ''' Sends an email to the requester. :param request: HTTP POST Request containing: @@ -1424,21 +1494,23 @@ def sendResetEmail(request): :returns JSON: {"Error|Success": "Email does not exist! | "Email has been sent!"} ''' - if request.POST: - db.add_user_to_password_reset(request.POST['forgot_email']) - emailId = db.sendForgotEmail(request.POST['forgot_email']) + if request.POST: + db.add_user_to_password_reset(request.POST['forgot_email']) + emailId = db.sendForgotEmail(request.POST['forgot_email']) - # If email is not found, throw an error - if emailId == None: - return HttpResponse(json.dumps(db.throwError(404, "Email does not exist!")), content_type="application/json") + # If email is not found, throw an error + if emailId == None: + return HttpResponse(json.dumps(db.throwError(404, "Email does not exist!")), + content_type="application/json") + + return HttpResponse(json.dumps(db.sendMessage(200, "Email has been sent!")), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - return HttpResponse(json.dumps(db.sendMessage(200, "Email has been sent!")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def resetLink(request): - ''' + ''' Directs the user to a link that allows them to change their password. @@ -1446,25 +1518,27 @@ def resetLink(request): :return JSON: {"email": | "Error": "Unrecognized ID"} ''' - if request.method == 'GET': - login_form = LoginForm() - register_form = RegisterForm() - code = request.GET.get('id') - email = db.retrieveResetInfo(code) - context = {'login_form': login_form, 'register_form': register_form} + if request.method == 'GET': + login_form = LoginForm() + register_form = RegisterForm() + code = request.GET.get('id') + email = db.retrieveResetInfo(code) + context = {'login_form': login_form, 'register_form': register_form} + + if email == None: + context['Error'] = "This password reset link is outdated. Please try resetting your password again." + return render(request, 'graphs/error.html', context) - if email == None: - context['Error'] = "This password reset link is outdated. Please try resetting your password again." - return render(request, 'graphs/error.html', context) + context['email'] = email + context['url'] = URL_PATH + return render(request, 'graphs/reset.html', context) + else: + return HttpResponse(json.dumps(db.throwError(500, "This route only accepts GET requests.")), + content_type="application/json") - context['email'] = email - context['url'] = URL_PATH - return render(request, 'graphs/reset.html', context) - else: - return HttpResponse(json.dumps(db.throwError(500, "This route only accepts GET requests.")), content_type="application/json") def resetPassword(request): - ''' + ''' Resets the password of the user. :param request: HTTP POST Request containing @@ -1474,19 +1548,22 @@ def resetPassword(request): :return JSON: {"Error|Success": "Password Update not successful! | Password updated for !"} ''' - if request.method == "POST": - resetInfo = db.resetPassword(request.POST['email'], request.POST['password'], request.POST['code']) + if request.method == "POST": + resetInfo = db.resetPassword(request.POST['email'], request.POST['password'], request.POST['code']) + + if resetInfo == None: + return HttpResponse(json.dumps(db.throwError(500, "Password Update not successful!")), + content_type="application/json"); - if resetInfo == None: - return HttpResponse(json.dumps(db.throwError(500, "Password Update not successful!")), content_type="application/json"); + return HttpResponse(json.dumps(db.sendMessage(200, "Password updated for " + request.POST['email'])), + content_type="application/json"); + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - return HttpResponse(json.dumps(db.sendMessage(200, "Password updated for " + request.POST['email'])), content_type="application/json"); - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def launchTask(request): - ''' + ''' Launches a task on Amazon Mechanical Turk. :param request: HTTP POST Request containing @@ -1495,16 +1572,18 @@ def launchTask(request): :return JSON: {"Error|Success": Error | Task Launched on Amazon Mechanical Turk!"} ''' - # Only 1 task per graph as long as there is a HIT active (3 days) - error = db.launchTask(request.POST["graph_id"], request.POST["user_id"], request.POST.getlist('layout_array')) + # Only 1 task per graph as long as there is a HIT active (3 days) + error = db.launchTask(request.POST["graph_id"], request.POST["user_id"], request.POST.getlist('layout_array')) + + if error != None: + return HttpResponse(json.dumps(db.throwError(500, error)), content_type="application/json") - if error != None: - return HttpResponse(json.dumps(db.throwError(500, error)), content_type="application/json") + return HttpResponse(json.dumps(db.sendMessage(201, "Task Launched on Amazon Mechanical Turk!")), + content_type="application/json"); - return HttpResponse(json.dumps(db.sendMessage(201, "Task Launched on Amazon Mechanical Turk!")), content_type="application/json"); def changeLayoutName(request): - ''' + ''' Changes the name of the layout :param request: Incoming HTTP POST Request containing: @@ -1513,27 +1592,32 @@ def changeLayoutName(request): :return JSON: {"Success": } ''' - if request.method == 'POST': - loggedIn = request.session.get('uid') - uid = request.POST['uid'] - gid = request.POST['gid'] - old_layout_name = request.POST['old_layout_name'] - new_layout_name = request.POST['new_layout_name'] - - if loggedIn == None: - return HttpResponse(json.dumps({"StatusCode": 500, "Message": "Must be logged in to make those requests", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name + "&layout_owner=" + loggedIn}), content_type="application/json") - - error = db.changeLayoutName(uid, gid, old_layout_name, new_layout_name, loggedIn) - if error == None: - return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout name changed!", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name + "&layout_owner=" + loggedIn}), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, error)), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + if request.method == 'POST': + loggedIn = request.session.get('uid') + uid = request.POST['uid'] + gid = request.POST['gid'] + old_layout_name = request.POST['old_layout_name'] + new_layout_name = request.POST['new_layout_name'] + + if loggedIn == None: + return HttpResponse(json.dumps({"StatusCode": 500, "Message": "Must be logged in to make those requests", + "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name + "&layout_owner=" + loggedIn}), + content_type="application/json") + + error = db.changeLayoutName(uid, gid, old_layout_name, new_layout_name, loggedIn) + if error == None: + return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout name changed!", + "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name + "&layout_owner=" + loggedIn}), + content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, error)), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def deleteLayout(request): - ''' + ''' Deletes layout of a graph :param request: Incoming HTTP POST Request containing: @@ -1542,28 +1626,32 @@ def deleteLayout(request): :return JSON: {"Success": } ''' - if request.method == 'POST': - uid = request.session.get('uid') - gid = request.POST['gid'] + if request.method == 'POST': + uid = request.session.get('uid') + gid = request.POST['gid'] - if uid == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to delete a layout!")), content_type="application/json") + if uid == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to delete a layout!")), + content_type="application/json") - layoutToDelete = request.POST['layout'] - layout_owner = request.POST['layout_owner'] + layoutToDelete = request.POST['layout'] + layout_owner = request.POST['layout_owner'] - result = db.deleteLayout(uid, gid, layoutToDelete, layout_owner) + result = db.deleteLayout(uid, gid, layoutToDelete, layout_owner) + + if result == None: + return HttpResponse(json.dumps( + {"StatusCode": 200, "Message": "Layout deleted!", "url": URL_PATH + 'graphs/' + uid + '/' + gid}), + content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if result == None: - return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout deleted!", "url": URL_PATH + 'graphs/' + uid + '/' + gid}), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def makeLayoutPublic(request): - ''' + ''' Makes a layout of graph public :param request: Incoming HTTP POST Request containing: @@ -1572,33 +1660,39 @@ def makeLayoutPublic(request): :return JSON: {"Success": } ''' - if request.method == 'POST': - uid = request.POST['uid'] - gid = request.POST['gid'] - layoutToMakePpublic = request.POST['layout'] - loggedIn = request.POST['user_id'] + if request.method == 'POST': + uid = request.POST['uid'] + gid = request.POST['gid'] + layoutToMakePpublic = request.POST['layout'] + loggedIn = request.POST['user_id'] - current_user = request.session.get('uid') + current_user = request.session.get('uid') - # If user is not logged on, they can't do anything - if current_user == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make share layouts!")), content_type="application/json") + # If user is not logged on, they can't do anything + if current_user == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make share layouts!")), + content_type="application/json") - # If user is the owner of the graph or if they are the layout owner, can they share a layout - if current_user != uid and db.get_layout_for_graph(layoutId, layout_owner, gid, uid, current_user) == None: - return HttpResponse(json.dumps(db.throwError(500, "Not authorized to share layouts!")), content_type="application/json") + # If user is the owner of the graph or if they are the layout owner, can they share a layout + if current_user != uid and db.get_layout_for_graph(layoutId, layout_owner, gid, uid, current_user) == None: + return HttpResponse(json.dumps(db.throwError(500, "Not authorized to share layouts!")), + content_type="application/json") - if uid == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make a layout public!")), content_type="application/json") + if uid == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make a layout public!")), + content_type="application/json") + + db.makeLayoutPublic(uid, gid, layoutToMakePpublic, loggedIn) + return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout made public!", + "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name}), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - db.makeLayoutPublic(uid, gid, layoutToMakePpublic, loggedIn) - return HttpResponse(json.dumps({"StatusCode": 200, "Message": "Layout made public!", "url": URL_PATH + 'graphs/' + uid + '/' + gid + '/?layout=' + new_layout_name}), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def getGroupsForGraph(request): - ''' + ''' Returns all the groups that are associated with request. :param request:Incoming HTTP POST Request containing: @@ -1607,20 +1701,24 @@ def getGroupsForGraph(request): :return JSON: {"Groups": [list of groups]} ''' - if request.method == 'POST': - owner = request.session.get('uid') - gid = request.POST['gid'] + if request.method == 'POST': + owner = request.session.get('uid') + gid = request.POST['gid'] - if owner == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to see groups for this graph!")), content_type="application/json") + if owner == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to see groups for this graph!")), + content_type="application/json") + + return HttpResponse(json.dumps( + {"StatusCode": 200, "Group_Information": db.get_all_groups_for_user_with_sharing_info(owner, gid)}), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - return HttpResponse(json.dumps({"StatusCode": 200, "Group_Information": db.get_all_groups_for_user_with_sharing_info(owner, gid)}), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def shareGraphWithGroups(request): - ''' + ''' Shares graph with specified groups. Unshares graph with specified groups. @@ -1628,28 +1726,29 @@ def shareGraphWithGroups(request): {"gid": , "owner": , "groups_to_share_with": [group_ids], "groups_not_to_share_with": [group_ids]} :return TBD ''' - if request.method == 'POST': - owner = request.POST['owner'] - gid = request.POST['gid'] - groups_to_share_with = request.POST.getlist('groups_to_share_with[]') - groups_not_to_share_with = request.POST.getlist('groups_not_to_share_with[]') + if request.method == 'POST': + owner = request.POST['owner'] + gid = request.POST['gid'] + groups_to_share_with = request.POST.getlist('groups_to_share_with[]') + groups_not_to_share_with = request.POST.getlist('groups_not_to_share_with[]') + + for group in groups_to_share_with: + groupInfo = group.split("12345__43121__") + db.share_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) - for group in groups_to_share_with: - groupInfo = group.split("12345__43121__") - db.share_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) + for group in groups_not_to_share_with: + groupInfo = group.split("12345__43121__") + db.unshare_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) - for group in groups_not_to_share_with: - groupInfo = group.split("12345__43121__") - db.unshare_graph_with_group(owner, gid, groupInfo[0], groupInfo[1]) + return HttpResponse("Done") - return HttpResponse("Done") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def create_group(request, groupname): - ''' + ''' Allows group creation from the GUI. :param request: Incoming HTTP POST Request containing: @@ -1659,22 +1758,26 @@ def create_group(request, groupname): :return JSON: {"Upload": , "Group Name | Error": } ''' - # If request is a POST request, add it to the server - if request.method == 'POST': - group_created = db.create_group(request.POST['username'], groupname) - - # If there isn't already a group name that exists with the same name under account - # add it to account - if group_created != None: - return HttpResponse(json.dumps({"StatusCode": 201, "Message": "Group created!", "Group Name": group_created[0], "Group Id": group_created[1]}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "Group name already exists for this account"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + # If request is a POST request, add it to the server + if request.method == 'POST': + group_created = db.create_group(request.POST['username'], groupname) + + # If there isn't already a group name that exists with the same name under account + # add it to account + if group_created != None: + return HttpResponse(json.dumps( + {"StatusCode": 201, "Message": "Group created!", "Group Name": group_created[0], + "Group Id": group_created[1]}, indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, "Group name already exists for this account"), indent=4, + separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def deleteGraph(request): - ''' + ''' Allows deletion of graph. :param request: Incoming HTTP POST Request containing: @@ -1683,29 +1786,37 @@ def deleteGraph(request): :return JSON: {"Delete": } ''' - if request.method == 'POST': - gid = request.POST['gid'] - uid = request.session.get('uid') - - # Check if the user is authenticated - if uid == None: - return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to delete this graph"), indent=4, separators=(',', ': ')), content_type="application/json") + if request.method == 'POST': + gid = request.POST['gid'] + uid = request.session.get('uid') + + # Check if the user is authenticated + if uid == None: + return HttpResponse(json.dumps(db.throwError(401, "You are not allowed to delete this graph"), indent=4, + separators=(',', ': ')), content_type="application/json") + + # if the user owns the graph only then allow him to delete it + graph_info = db.getGraphInfo(uid, gid) + if graph_info == None: + return HttpResponse( + json.dumps(db.throwError(404, "You do not own any such Graph."), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + + jsonData = db.get_graph_json(uid, gid) + if jsonData != None: + db.delete_graph(uid, gid) + return HttpResponse( + json.dumps(db.sendMessage(200, "Successfully deleted " + gid + " owned by " + uid + '.'), indent=4, + separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse( + json.dumps(db.throwError(404, "You do not own any such Graph."), indent=4, separators=(',', ': ')), + content_type="application/json") - # if the user owns the graph only then allow him to delete it - graph_info = db.getGraphInfo(uid, gid) - if graph_info == None: - return HttpResponse(json.dumps(db.throwError(404, "You do not own any such Graph."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - - jsonData = db.get_graph_json(uid, gid) - if jsonData != None: - db.delete_graph(uid, gid) - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully deleted " + gid + " owned by " + uid + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(404, "You do not own any such Graph."), indent=4, separators=(',', ': ')), content_type="application/json") def delete_group_through_ui(request): - ''' + ''' Allows group creation from the GUI. :param request: Incoming HTTP POST Request containing: @@ -1715,17 +1826,20 @@ def delete_group_through_ui(request): :return JSON: {"Delete": } ''' - # If request is a POST request, add it to the server - if request.method == 'POST': - if request.POST['username'] == request.POST['groupOwner']: - db.remove_group(request.POST['groupOwner'], request.POST['groupName']) - return HttpResponse(json.dumps(db.sendMessage(200, request.POST['groupName'] + " deleted for " + request.POST['groupOwner'])), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + # If request is a POST request, add it to the server + if request.method == 'POST': + if request.POST['username'] == request.POST['groupOwner']: + db.remove_group(request.POST['groupOwner'], request.POST['groupName']) + return HttpResponse(json.dumps( + db.sendMessage(200, request.POST['groupName'] + " deleted for " + request.POST['groupOwner'])), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def unsubscribe_from_group(request): - ''' + ''' Allows group creation from the GUI. :param request: Incoming HTTP POST Request containing: @@ -1735,19 +1849,22 @@ def unsubscribe_from_group(request): :return JSON: {"Unsubscribe | Error": } ''' - # If request is a POST request, add it to the server - if request.method == 'POST': - result = db.remove_user_through_ui(request.POST['username'], request.POST['groupOwner'], request.POST['groupName']) - if result != None: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "You are no longer following " + request.POST['groupName'] + " owned by " + request.POST['groupOwner'])), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + # If request is a POST request, add it to the server + if request.method == 'POST': + result = db.remove_user_through_ui(request.POST['username'], request.POST['groupOwner'], + request.POST['groupName']) + if result != None: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "You are no longer following " + request.POST[ + 'groupName'] + " owned by " + request.POST['groupOwner'])), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def change_description_through_ui(request): - ''' + ''' Allows user to change description of group through UI. :param request: Incoming HTTP POST Request containing: @@ -1757,19 +1874,21 @@ def change_description_through_ui(request): :return JSON: {"Changed | Error": } ''' - # If request is a POST request, add it to the server - if request.method == 'POST': - result = db.change_description(request.POST['username'], request.POST['groupId'], request.POST['groupOwner'], request.POST['description']) - if result != None: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Changed description")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + # If request is a POST request, add it to the server + if request.method == 'POST': + result = db.change_description(request.POST['username'], request.POST['groupId'], request.POST['groupOwner'], + request.POST['description']) + if result != None: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Changed description")), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def add_member_through_ui(request): - ''' + ''' Allows user to add members to a group through UI. :param request: Incoming HTTP POST Request containing: @@ -1779,16 +1898,17 @@ def add_member_through_ui(request): :return JSON: {"Message": } ''' - # If request is a POST request, add it to the server - if request.method == 'POST': - result = db.add_user_to_group(request.POST['member'], request.POST['groupOwner'], request.POST['groupId']) - return HttpResponse(json.dumps(db.sendMessage(200, result)), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + # If request is a POST request, add it to the server + if request.method == 'POST': + result = db.add_user_to_group(request.POST['member'], request.POST['groupOwner'], request.POST['groupId']) + return HttpResponse(json.dumps(db.sendMessage(200, result)), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def remove_member_through_ui(request): - ''' + ''' Allows user to remove members from a group through UI. :param request: Incoming HTTP POST Request containing: @@ -1798,16 +1918,17 @@ def remove_member_through_ui(request): :return JSON: {"Message": } ''' - # If request is a POST request, add it to the server - if request.method == 'POST': - result = db.remove_user_from_group(request.POST['member'], request.POST['groupOwner'], request.POST['groupId']) - return HttpResponse(json.dumps(db.sendMessage(200, result)), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + # If request is a POST request, add it to the server + if request.method == 'POST': + result = db.remove_user_from_group(request.POST['member'], request.POST['groupOwner'], request.POST['groupId']) + return HttpResponse(json.dumps(db.sendMessage(200, result)), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def getGroupsWithLayout(request): - ''' + ''' Gets all groups that have the particular graph shared in the group. :param request: Incoming HTTP POST Request containing: @@ -1816,110 +1937,131 @@ def getGroupsWithLayout(request): :return JSON: {"Groups":[Groups]} ''' - if request.method == 'POST': - result = db.is_layout_shared(request.POST['layout'], request.POST['loggedIn'], request.POST['owner'], request.POST['gid']) - return HttpResponse(json.dumps({"StatusCode": 200, "Group_Information": result}), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + if request.method == 'POST': + result = db.is_layout_shared(request.POST['layout'], request.POST['loggedIn'], request.POST['owner'], + request.POST['gid']) + return HttpResponse(json.dumps({"StatusCode": 200, "Group_Information": result}), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def setDefaultLayout(request): - if request.method == 'POST': - result = db.setDefaultLayout(request.POST['layoutId'], request.POST['gid'], request.POST['uid']) - if result != None: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Set " + request.POST['layoutId'] + " as default")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + if request.method == 'POST': + result = db.setDefaultLayout(request.POST['layoutId'], request.POST['gid'], request.POST['uid']) + if result != None: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Set " + request.POST['layoutId'] + " as default")), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def removeDefaultLayout(request): - if request.method == 'POST': - result = db.removeDefaultLayout(request.POST['layoutId'], request.POST['gid'], request.POST['uid']) - if result != None: - return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "Removed " + request.POST['layoutId'] + " as default")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) + if request.method == 'POST': + result = db.removeDefaultLayout(request.POST['layoutId'], request.POST['gid'], request.POST['uid']) + if result != None: + return HttpResponse(json.dumps(db.throwError(400, result)), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, "Removed " + request.POST['layoutId'] + " as default")), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) + def renderImage(request): - # This is a temporary route so Allison's graphs show up - return HttpResponseRedirect(URL_PATH + 'static/images/legend.png'); + # This is a temporary route so Allison's graphs show up + return HttpResponseRedirect(URL_PATH + 'static/images/legend.png'); + def shareLayoutWithGroups(request): - ''' + ''' Toggles shares/unshare graph with specified groups. :param request:Incoming HTTP POST Request containing: {"gid": , "owner": , "groups_to_share_with": [group_ids], "groups_not_to_share_with": [group_ids]} :return TBD ''' - if request.method == 'POST': - layout_owner = request.POST['loggedIn'] - gid = request.POST['gid'] - uid = request.POST['uid'] - layoutId = request.POST['layoutId'] - current_user = request.session.get('uid') - - # If user is not logged on, they can't do anything - if current_user == None: - return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make share layouts!")), content_type="application/json") + if request.method == 'POST': + layout_owner = request.POST['loggedIn'] + gid = request.POST['gid'] + uid = request.POST['uid'] + layoutId = request.POST['layoutId'] + current_user = request.session.get('uid') + + # If user is not logged on, they can't do anything + if current_user == None: + return HttpResponse(json.dumps(db.throwError(500, "Must be signed in to make share layouts!")), + content_type="application/json") + + # If user is the owner of the graph or if they are the layout owner, can they share a layout + if current_user != uid and db.get_layout_for_graph(layoutId, layout_owner, gid, uid, current_user) == None: + return HttpResponse(json.dumps(db.throwError(500, "Not authorized to share layouts!")), + content_type="application/json") + + if db.can_see_shared_graph(current_user, uid, gid) == None: + return HttpResponse(json.dumps(db.throwError(500, "Not allowed to do this operation!")), + content_type="application/json") + + if len(db.get_all_groups_for_this_graph(uid, gid)) == 0: + return HttpResponse(json.dumps(db.throwError(400, + "No groups to share with. Either share this graph with a group first or make this graph public!")), + content_type="application/json") + else: + if db.is_public_graph(uid, gid): + db.makeLayoutPublic(uid, gid, layoutId, layout_owner) + else: + db.share_layout_with_all_groups_of_user(uid, gid, layoutId, layout_owner) + + return HttpResponse(json.dumps(db.sendMessage(200, "Okay")), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - # If user is the owner of the graph or if they are the layout owner, can they share a layout - if current_user != uid and db.get_layout_for_graph(layoutId, layout_owner, gid, uid, current_user) == None: - return HttpResponse(json.dumps(db.throwError(500, "Not authorized to share layouts!")), content_type="application/json") - - if db.can_see_shared_graph(current_user, uid, gid) == None: - return HttpResponse(json.dumps(db.throwError(500, "Not allowed to do this operation!")), content_type="application/json") - - if len(db.get_all_groups_for_this_graph(uid, gid)) == 0: - return HttpResponse(json.dumps(db.throwError(400, "No groups to share with. Either share this graph with a group first or make this graph public!")), content_type="application/json") - else: - if db.is_public_graph(uid, gid): - db.makeLayoutPublic(uid, gid, layoutId, layout_owner) - else: - db.share_layout_with_all_groups_of_user(uid, gid, layoutId, layout_owner) - - return HttpResponse(json.dumps(db.sendMessage(200, "Okay")), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) ##### END VIEWS ##### ##### REST API ##### def graph_exists(request, user_id, graphname): - ''' + ''' Checks to see if a graph exists @param request: HTTP POST Request @param user_id: Owner of graph @param: graph_name: Name of graph ''' - if request.method == 'POST': + if request.method == 'POST': - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - graph_exists = db.graph_exists(user_id, graphname) + graph_exists = db.graph_exists(user_id, graphname) + + if graph_exists == False: + return HttpResponse( + json.dumps(db.throwError(404, "User " + user_id + " owns no graph with id " + graphname + "!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse( + json.dumps(db.sendMessage(200, "User " + user_id + " owns a graph with id " + graphname + "!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if graph_exists == False: - return HttpResponse(json.dumps(db.throwError(404, "User " + user_id + " owns no graph with id " + graphname + "!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, "User " + user_id + " owns a graph with id " + graphname + "!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def upload_graph(request, user_id, graphname): - ''' + ''' Uploads a graph for a user :param request: Incoming HTTP POST Request containing: @@ -1932,26 +2074,32 @@ def upload_graph(request, user_id, graphname): :return response: JSON Response: {"Success|Error": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + graph_errors = db.insert_graph(user_id, graphname, request.FILES['graphname'].read()) - graph_errors = db.insert_graph(user_id, graphname, request.FILES['graphname'].read()) + if graph_errors != None: + return HttpResponse(json.dumps(db.throwError(400, graph_errors), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse( + json.dumps(db.sendMessage(201, "Added " + graphname + " for " + user_id + '.'), indent=4, + separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if graph_errors != None: - return HttpResponse(json.dumps(db.throwError(400, graph_errors), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(201, "Added " + graphname + " for " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def update_graph(request, user_id, graphname): - ''' + ''' Updates an already existing graph. :param request: Incoming HTTP POST Request containing: @@ -1964,25 +2112,31 @@ def update_graph(request, user_id, graphname): :return response: JSON Response: {"Success|Error": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + graph_errors = db.update_graph(user_id, graphname, request.FILES['graphname'].read()) + if graph_errors != None: + return HttpResponse(json.dumps(db.throwError(404, graph_errors), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse( + json.dumps(db.sendMessage(201, "Updated " + graphname + " for " + user_id + '.'), indent=4, + separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - graph_errors = db.update_graph(user_id, graphname, request.FILES['graphname'].read()) - if graph_errors != None: - return HttpResponse(json.dumps(db.throwError(404, graph_errors), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(201, "Updated " + graphname + " for " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def retrieve_graph(request, user_id, graphname): - ''' + ''' Retrieves the json of a specified graph :param request: Incoming HTTP POST Request containing: @@ -1994,25 +2148,30 @@ def retrieve_graph(request, user_id, graphname): :return response: JSON Response: {"Graph|Error": } ''' - if request.method == 'POST': + if request.method == 'POST': - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + jsonData = db.get_graph_json(user_id, graphname) + if jsonData != None: + return HttpResponse(jsonData) + else: + return HttpResponse( + json.dumps(db.throwError(404, "No Such Graph Exists!"), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - jsonData = db.get_graph_json(user_id, graphname) - if jsonData != None: - return HttpResponse(jsonData) - else: - return HttpResponse(json.dumps(db.throwError(404, "No Such Graph Exists!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def remove_graph(request, user_id, graphname): - ''' + ''' Removes a graph from the server :param request: Incoming HTTP POST Request containing: @@ -2025,26 +2184,33 @@ def remove_graph(request, user_id, graphname): :return response: JSON Response: {"Success|Error": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + jsonData = db.get_graph_json(user_id, graphname) + if jsonData != None: + db.delete_graph(user_id, graphname) + return HttpResponse( + json.dumps(db.sendMessage(200, "Successfully deleted " + graphname + " owned by " + user_id + '.'), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse( + json.dumps(db.throwError(404, "No Such Graph Exists."), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - jsonData = db.get_graph_json(user_id, graphname) - if jsonData != None: - db.delete_graph(user_id, graphname) - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully deleted " + graphname + " owned by " + user_id + '.'), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(404, "No Such Graph Exists."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def view_all_graphs_for_user(request, user_id): - ''' + ''' View all graphs for a user :param request: Incoming HTTP POST Request containing: @@ -2053,22 +2219,26 @@ def view_all_graphs_for_user(request, user_id): :return response: JSON Response: {"Graphs|Error": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(user_id, request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(user_id, request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + data = db.get_all_graphs_for_user(user_id) + return HttpResponse(json.dumps({"StatusCode": 200, "Graphs": data}, indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - data = db.get_all_graphs_for_user(user_id) - return HttpResponse(json.dumps({"StatusCode": 200, "Graphs": data}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def make_graph_public(request, user_id, graphname): - ''' + ''' Makes specified graph and all of its layouts public :param request: Incoming HTTP POST Request containing: @@ -2076,26 +2246,32 @@ def make_graph_public(request, user_id, graphname): :param graphname: name of graph to make public :return response: JSON Response: {"Success|Error": } ''' - if request.method == 'POST': - - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - data = db.change_graph_visibility(1, request.POST['username'], graphname) + data = db.change_graph_visibility(1, request.POST['username'], graphname) + + if data == None: + return HttpResponse( + json.dumps(db.sendMessage(200, "Successfully made " + graphname + " owned by " + user_id + " public."), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(404, data), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if data == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully made " + graphname + " owned by " + user_id + " public."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(404, data), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def make_graph_private(request, user_id, graphname): - ''' + ''' Makes specified graph and all of its layouts public :param request: Incoming HTTP POST Request containing: @@ -2103,25 +2279,31 @@ def make_graph_private(request, user_id, graphname): :param graphname: name of graph to make public :return response: JSON Response: {"Success|Error": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if request.POST['username'] != user_id: + return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + data = db.change_graph_visibility(0, request.POST['username'], graphname) + if data == None: + return HttpResponse( + json.dumps(db.sendMessage(200, "Successfully made " + graphname + " owned by " + user_id + " private."), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(404, data), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if request.POST['username'] != user_id: - return HttpResponse(json.dumps(db.usernameMismatchError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - data = db.change_graph_visibility(0, request.POST['username'], graphname) - if data == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Successfully made " + graphname + " owned by " + user_id + " private."), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(404, data), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def get_groups(request): - ''' + ''' Get all groups that are on this server :param request: Incoming HTTP POST Request containing: @@ -2130,19 +2312,22 @@ def get_groups(request): :return response: JSON Response: {"Groups|Error": } ''' - if request.method == 'POST': + if request.method == 'POST': - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + data = db.get_all_groups_with_member(request.POST['username']) + return HttpResponse(json.dumps({"StatusCode": 200, "Groups": data}, indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - data = db.get_all_groups_with_member(request.POST['username']) - return HttpResponse(json.dumps({"StatusCode": 200, "Groups": data}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def get_group(request, group_owner, groupname): - ''' + ''' Get information about this group :param request: Incoming HTTP POST Request containing: {"username": ,"password": } @@ -2151,22 +2336,27 @@ def get_group(request, group_owner, groupname): :return response: JSON Response: {"Groups|Error": } ''' - if request.method == 'POST': + if request.method == 'POST': - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + data = db.get_group(group_owner, groupname) + if data == None: + return HttpResponse( + json.dumps(db.throwError(404, "Group does not exist for this user!"), indent=4, separators=(',', ': ')), + content_type="application/json") + + return HttpResponse(json.dumps({"StatusCode": 200, "Groups": data}, indent=4, separators=(',', ': ')), + content_type="application/json"); + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - data = db.get_group(group_owner, groupname) - if data == None: - return HttpResponse(json.dumps(db.throwError(404, "Group does not exist for this user!"), indent=4, separators=(',', ': ')), content_type="application/json") - - return HttpResponse(json.dumps({"StatusCode": 200, "Groups": data}, indent=4, separators=(',', ': ')), content_type="application/json"); - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def delete_group(request, group_owner, groupname): - ''' + ''' Deletes a group from the server. :param request: Incoming HTTP POST Request containing: @@ -2177,25 +2367,32 @@ def delete_group(request, group_owner, groupname): :return response: JSON Response: {"Success|Failure": } ''' - if request.method == 'POST': + if request.method == 'POST': - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + if group_owner == request.POST['username']: + data = db.remove_group(request.POST['username'], groupname) + if data != None: + return HttpResponse( + json.dumps(db.throwError(404, "Group not found!"), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(200, data), indent=4, separators=(',', ': ')), + content_type="application/json"); + else: + return HttpResponse(json.dumps( + db.throwError(400, "The group owner and the person making this request are not the same person!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if group_owner == request.POST['username']: - data = db.remove_group(request.POST['username'], groupname) - if data != None: - return HttpResponse(json.dumps(db.throwError(404, "Group not found!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(200, data), indent=4, separators=(',', ': ')), content_type="application/json"); - else: - return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def add_group(request, group_owner, groupname): - ''' + ''' Adds a group to the server. If groupname already exists under a user account, then it will fail, otherwise a group name is created under the user's account. :param request: Incoming HTTP POST Request containing: @@ -2206,23 +2403,27 @@ def add_group(request, group_owner, groupname): :return response: JSON Response: {Upload: "Success|Failure", "Group Name|Error": group | error} ''' - # If request is a POST request, add it to the server - if request.method == 'POST': + # If request is a POST request, add it to the server + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + if group_owner == request.POST['username']: + data = create_group(request, groupname) + return HttpResponse(data) + else: + return HttpResponse(json.dumps( + db.throwError(400, "The group owner and the person making this request are not the same person!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - - if group_owner == request.POST['username']: - data = create_group(request, groupname) - return HttpResponse(data) - else: - return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def get_group_for_user(request, user_id): - ''' + ''' Gets all groups that a user is a part of. :param request: Incoming HTTP POST Request containing: @@ -2233,19 +2434,22 @@ def get_group_for_user(request, user_id): :return JSON Response: {"User": , "Groups": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + group = db.groups_for_user(user_id) + return HttpResponse(json.dumps({"StatusCode": 200, "Groups": group}, indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - group = db.groups_for_user(user_id) - return HttpResponse(json.dumps({"StatusCode": 200, "Groups": group}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def add_user_to_group(request, group_owner, groupname, user_id): - ''' + ''' Adds specified user to a group. :param request: Incoming HTTP POST Request containing: @@ -2257,30 +2461,37 @@ def add_user_to_group(request, group_owner, groupname, user_id): :return JSON Response: {"Response": } ''' - if request.method == 'POST': + if request.method == 'POST': + + # Check to see if the user/password is acceptable + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - # Check to see if the user/password is acceptable - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if group_owner == request.POST['username']: + # Adds user to group + data = db.add_user_to_group(user_id, request.POST['username'], groupname) - if group_owner == request.POST['username']: - # Adds user to group - data = db.add_user_to_group(user_id, request.POST['username'], groupname) + # If nothing is returned, that means that something went wrong + if data == None: + return HttpResponse( + json.dumps(db.throwError(400, "Group doesn't exist or user has already been added!"), indent=4, + separators=(',', ': ')), content_type="application/json") - # If nothing is returned, that means that something went wrong - if data == None: - return HttpResponse(json.dumps(db.throwError(400, "Group doesn't exist or user has already been added!"), indent=4, separators=(',', ': ')), content_type="application/json") + return HttpResponse(json.dumps(db.sendMessage(200, data), indent=4, separators=(',', ': ')), + content_type="application/json") - return HttpResponse(json.dumps(db.sendMessage(200, data), indent=4, separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps( + db.throwError(400, "The group owner and the person making this request are not the same person!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - else: - return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def remove_user_from_group(request, group_owner, groupname, user_id): - ''' + ''' Removes user from group :param HTTP POST Request containing @@ -2289,21 +2500,26 @@ def remove_user_from_group(request, group_owner, groupname, user_id): :param user_id: Email of user to remove ''' - if request.method == 'POST': + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + if group_owner == request.POST['username']: + group = db.remove_user_from_group(user_id, request.POST['username'], groupname) + return HttpResponse(json.dumps(db.sendMessage(200, group), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse(json.dumps( + db.throwError(400, "The group owner and the person making this request are not the same person!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") - if group_owner == request.POST['username']: - group = db.remove_user_from_group(user_id, request.POST['username'], groupname) - return HttpResponse(json.dumps(db.sendMessage(200, group), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "The group owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def share_graph(request, graphname, group_owner, groupname): - ''' + ''' Share a graph with group. :param HTTP POST Request containing @@ -2313,22 +2529,26 @@ def share_graph(request, graphname, group_owner, groupname): :return JSON: {"Response": } ''' - if request.method == 'POST': + if request.method == 'POST': - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + result = db.share_graph_with_group(request.POST['username'], graphname, groupname, group_owner) + if result == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Graph successfully shared with group!"), indent=4, + separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(400, result), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - result = db.share_graph_with_group(request.POST['username'], graphname, groupname, group_owner) - if result == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Graph successfully shared with group!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(400, result), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def unshare_graph(request, graphname, group_owner, groupname): - ''' + ''' Unshare a graph from a group. :param HTTP POST Request containing @@ -2338,22 +2558,26 @@ def unshare_graph(request, graphname, group_owner, groupname): :return JSON: {"Response": } ''' - if request.method == 'POST': + if request.method == 'POST': - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + result = db.unshare_graph_with_group(request.POST['username'], graphname, groupname, group_owner) + if result == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Graph successfully unshared with group!"), indent=4, + separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.sendMessage(400, result), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - result = db.unshare_graph_with_group(request.POST['username'], graphname, groupname, group_owner) - if result == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Graph successfully unshared with group!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.sendMessage(400, result), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def get_tags_for_user(request, username): - ''' + ''' Get all tags that a user has under their name :param HTTP POST Request containing {"username": , "password": } @@ -2361,19 +2585,22 @@ def get_tags_for_user(request, username): :return JSON: {"Response": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + result = db.get_all_tags_for_user(username) + return HttpResponse(json.dumps({"StatusCode": 200, "Tags": result}, indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - result = db.get_all_tags_for_user(username) - return HttpResponse(json.dumps({"StatusCode": 200, "Tags": result}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def get_all_tags_for_graph(request, username, graphname): - ''' + ''' Get all tags that a user has under their graph :param HTTP POST Request containing {"username": , "password": } @@ -2382,22 +2609,27 @@ def get_all_tags_for_graph(request, username, graphname): :return JSON: {"Response": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + result = db.get_all_tags_for_graph(graphname, username) + if result == None: + return HttpResponse( + json.dumps(db.sendMessage(404, "Graph does not exist!"), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse(json.dumps({"StatusCode": 200, "Tags": result}, indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - result = db.get_all_tags_for_graph(graphname, username) - if result == None: - return HttpResponse(json.dumps(db.sendMessage(404, "Graph does not exist!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps({"StatusCode": 200, "Tags": result}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def get_all_graphs_for_tags(request, tag): - ''' + ''' Get all graphs associated with these tags :param HTTP POST Request containing {"username": , "password": } @@ -2405,20 +2637,23 @@ def get_all_graphs_for_tags(request, tag): :return JSON: {"Response": } ''' - - if request.method == 'POST': - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + result = db.get_all_graphs_for_tags(tag) + return HttpResponse(json.dumps({"StatusCode": 200, "Graphs": result}, indent=4, separators=(',', ': ')), + content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - result = db.get_all_graphs_for_tags(tag) - return HttpResponse(json.dumps({"StatusCode": 200, "Graphs": result}, indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def make_all_graphs_for_tag_public(request, username, tagname): - ''' + ''' Makes all graphs with this tag public :param HTTP POST Request containing {"username": , "password": } @@ -2428,25 +2663,31 @@ def make_all_graphs_for_tag_public(request, username, tagname): :return JSON: {"Response": } ''' - if request.method == 'POST': + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if username == request.POST['username']: + error = db.change_graph_visibility_for_tag(1, tagname, username) + if error == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been made public"), indent=4, + separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, error), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse(json.dumps( + db.throwError(400, "The tag owner and the person making this request are not the same person!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if username == request.POST['username']: - error = db.change_graph_visibility_for_tag(1, tagname, username) - if error == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been made public"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, error), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def make_all_graphs_for_tag_private(request, username, tagname): - ''' + ''' Makes all graphs with this tag private :param HTTP POST Request containing {"username": , "password": } @@ -2455,25 +2696,31 @@ def make_all_graphs_for_tag_private(request, username, tagname): :return JSON: {"Response": } ''' - if request.method == 'POST': - - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if request.method == 'POST': + + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + if username == request.POST['username']: + error = db.change_graph_visibility_for_tag(0, tagname, username) + if error == None: + return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been made private"), indent=4, + separators=(',', ': ')), content_type="application/json") + else: + return HttpResponse(json.dumps(db.throwError(400, error), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse(json.dumps( + db.throwError(400, "The tag owner and the person making this request are not the same person!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if username == request.POST['username']: - error = db.change_graph_visibility_for_tag(0, tagname, username) - if error == None: - return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been made private"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, error), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) def delete_all_graphs_for_tag(request, username, tagname): - ''' + ''' Makes all graphs with this tag private :param HTTP POST Request containing {"username": , "password": } @@ -2482,72 +2729,94 @@ def delete_all_graphs_for_tag(request, username, tagname): :return JSON: {"Response": } ''' - if request.method == 'POST': + if request.method == 'POST': - if db.get_valid_user(request.POST['username'], request.POST['password']) == None: - return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), content_type="application/json") + if db.get_valid_user(request.POST['username'], request.POST['password']) == None: + return HttpResponse(json.dumps(db.userNotFoundError(), indent=4, separators=(',', ': ')), + content_type="application/json") + + if username == request.POST['username']: + db.delete_all_graphs_for_tag(tagname, username) + return HttpResponse( + json.dumps(db.sendMessage(200, "Graphs with tag have been deleted"), indent=4, separators=(',', ': ')), + content_type="application/json") + else: + return HttpResponse(json.dumps( + db.throwError(400, "The tag owner and the person making this request are not the same person!"), + indent=4, separators=(',', ': ')), content_type="application/json") + else: + context = {"Error": "This route only accepts POST requests."} + return render(request, 'graphs/error.html', context) - if username == request.POST['username']: - db.delete_all_graphs_for_tag(tagname, username) - return HttpResponse(json.dumps(db.sendMessage(200, "Graphs with tag have been deleted"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - return HttpResponse(json.dumps(db.throwError(400, "The tag owner and the person making this request are not the same person!"), indent=4, separators=(',', ': ')), content_type="application/json") - else: - context = {"Error": "This route only accepts POST requests."} - return render(request, 'graphs/error.html', context) # Private Utility methods used throughout views.py def handler_404(request): - if request.method == 'POST': - return HttpResponse(json.dumps(db.throwError(404, "REST API endpoint does not exist!")), content_type="application/json") - else: - return render(request,'404.html') + if request.method == 'POST': + return HttpResponse(json.dumps(db.throwError(404, "REST API endpoint does not exist!")), + content_type="application/json") + else: + return render(request, '404.html') + def handler_500(): - if request.method == 'POST': - return HttpResponse(json.dumps(db.throwError(500, "An error was encountered during this request. REST API call not successful.")), content_type="application/json") - else: - return render(request,'500.html') + if request.method == 'POST': + return HttpResponse(json.dumps( + db.throwError(500, "An error was encountered during this request. REST API call not successful.")), + content_type="application/json") + else: + return render(request, '500.html') + def constructGraphMessage(context, view_type, search, tags): - if view_type == 'shared': - if search == None and tags == None: - context['message'] = "It appears that there are no groups that have shared their graphs." - elif search != None and tags == None: - context['message'] = "It appears that there are no groups that have shared their graphs with the given search criteria." - elif tags != None and search == None: - context['message'] = "It appears that there are no groups that have shared their graphs with the given tag criteria." - else: - context['message'] = "It appears that there are no groups that have shared their graphs with the given search and tag criteria." - - elif view_type == 'public': - if search == None and tags == None: - context['message'] = "It appears that there are no public graphs available. Please create an account and join a group or upload your own graphs through the
    REST API or web interface." - elif search != None and tags == None: - context['message'] = "It appears that there are no public graphs available that match the search criteria. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search criteria." - elif tags != None and search == None: - context['message'] = "It appears that there are no public graphs available that match the tag criteria. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given tag criteria." - else: - context['message'] = "It appears that there are no public graphs available that match the search and tag criteria. Please create an account and join a group or upload your own graphs with the given search and tag criteria." - - elif view_type == 'all': - if search == None and tags == None: - context['message'] = "It appears that there are no graphs available." - elif search != None and tags == None: - context['message'] = "It appears that there are no graphs available that match the search criteria." - elif tags != None and search == None: - context['message'] = "It appears that there are no graphs available that match the tag criteria." - else: - context['message'] = "It appears that there are no graphs available that match the search and tag criteria." - else: - if search == None and tags == None: - context['message'] = "It appears that you currently have no graphs uploaded. Please create an account and join a group or upload your own graphs through the REST API or web interface." - elif search != None and tags == None: - context['message'] = "It appears that you currently have no graphs uploaded that match the search terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search criteria in order to see them here." - elif tags != None and search == None: - context['message'] = "It appears that you currently have no graphs uploaded that match the tag terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given tag criteria in order to see them here." - else: - context['message'] = "It appears that you currently have no graphs uploaded that match the serach and tag terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search and tag criteria in order to see them here." - - return context + if view_type == 'shared': + if search == None and tags == None: + context['message'] = "It appears that there are no groups that have shared their graphs." + elif search != None and tags == None: + context[ + 'message'] = "It appears that there are no groups that have shared their graphs with the given search criteria." + elif tags != None and search == None: + context[ + 'message'] = "It appears that there are no groups that have shared their graphs with the given tag criteria." + else: + context[ + 'message'] = "It appears that there are no groups that have shared their graphs with the given search and tag criteria." + + elif view_type == 'public': + if search == None and tags == None: + context[ + 'message'] = "It appears that there are no public graphs available. Please create an account and join a group or upload your own graphs through the REST API or web interface." + elif search != None and tags == None: + context[ + 'message'] = "It appears that there are no public graphs available that match the search criteria. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search criteria." + elif tags != None and search == None: + context[ + 'message'] = "It appears that there are no public graphs available that match the tag criteria. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given tag criteria." + else: + context[ + 'message'] = "It appears that there are no public graphs available that match the search and tag criteria. Please create an account and join a group or upload your own graphs with the given search and tag criteria." + + elif view_type == 'all': + if search == None and tags == None: + context['message'] = "It appears that there are no graphs available." + elif search != None and tags == None: + context['message'] = "It appears that there are no graphs available that match the search criteria." + elif tags != None and search == None: + context['message'] = "It appears that there are no graphs available that match the tag criteria." + else: + context['message'] = "It appears that there are no graphs available that match the search and tag criteria." + else: + if search == None and tags == None: + context[ + 'message'] = "It appears that you currently have no graphs uploaded. Please create an account and join a group or upload your own graphs through the REST API or web interface." + elif search != None and tags == None: + context[ + 'message'] = "It appears that you currently have no graphs uploaded that match the search terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search criteria in order to see them here." + elif tags != None and search == None: + context[ + 'message'] = "It appears that you currently have no graphs uploaded that match the tag terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given tag criteria in order to see them here." + else: + context[ + 'message'] = "It appears that you currently have no graphs uploaded that match the serach and tag terms. Please create an account and join a group or upload your own graphs through the REST API or web interface with the given search and tag criteria in order to see them here." + + return context From 370626130836d2977fa9a095f19b992fac839117 Mon Sep 17 00:00:00 2001 From: Aditya Bharadwaj Date: Tue, 16 Aug 2016 15:46:58 -0400 Subject: [PATCH 18/23] refactored notifications flow --- graphs/static/graphs/js/notifications.js | 103 +++++++++++++++------ graphs/templates/graphs/notifications.html | 52 ++++------- graphs/urls.py | 2 +- graphs/util/db.py | 34 ++++--- graphs/views.py | 70 +++++++------- 5 files changed, 137 insertions(+), 124 deletions(-) diff --git a/graphs/static/graphs/js/notifications.js b/graphs/static/graphs/js/notifications.js index b91737bd..bc3131fd 100644 --- a/graphs/static/graphs/js/notifications.js +++ b/graphs/static/graphs/js/notifications.js @@ -1,33 +1,76 @@ -$(document).ready(function() { - /** - * Mark notification as read through the UI. - */ - $(".read_notification").click(function (e) { - // current user_id always present - var uid = $(this).attr('uid'); - // notification id present if clicked on individual notification - var nid = $(this).attr('nid'); - // group id present if clicked on mark read for group notifications - var gid = $(this).attr('gid'); - // allid present if clicked on mark all notification read for a user - var allid = $(this).attr('allid'); - - // send a post request to the view read_notification - $.post('../../../javascript/'+uid+'/mark_notifications_as_read/', { - 'uid': uid, - 'nid': nid, - 'gid': gid, - 'allid': allid - - }, function (data) { - if (data.Error) { - return alert(data.Error); - } - window.location.reload(); - // $('.remove_read'+nid).remove(); - // $('.notification_event'+nid).addClass('notification_read'); +$(document).ready(function () { + /** + * Mark notification as read through the UI. + */ + + $(".read_notification").click(function(e){ + var uid = $(this).attr('uid'); + // notification id present if clicked on individual notification + var notification_ids = [$(this).attr('notification_id')]; + + read_notifications(uid, notification_ids, function(error, data){ + if (error) { + return alert(error); + } else { + window.location.reload(); + //$('.remove_read'+nid).remove(); + //$('.notification_event'+nid).addClass('notification_read'); + } + }); + }); + + $(".read_all_group_notifications").click(function(e){ + var uid = $(this).attr('uid'); + var group_id = $(this).attr('group_id'); + // notification id present if clicked on individual notification + var notification_ids = []; + $.each($('.notifications.' + group_id), function( index, notification ) { + notification_ids.push($(notification).attr('id')); + }); + + read_notifications(uid, notification_ids, function(error, data){ + if (error) { + return alert(error); + } else { + window.location.reload(); + //$('.remove_read'+nid).remove(); + //$('.notification_event'+nid).addClass('notification_read'); + } + }); }); - }); - $('[data-toggle="tooltip"]').tooltip(); + + $(".read_all_notifications").click(function(e){ + var uid = $(this).attr('uid'); + var group_id = $(this).attr('group_id'); + // notification id present if clicked on individual notification + var notification_ids = []; + $.each($('.notifications'), function( index, notification ) { + notification_ids.push($(notification).attr('id')); + }); + + read_notifications(uid, notification_ids, function(error, data){ + if (error) { + return alert(error); + } else { + window.location.reload(); + //$('.remove_read'+nid).remove(); + //$('.notification_event'+nid).addClass('notification_read'); + } + }); + }); + + var read_notifications = function(uid, notification_ids, callback) { + $.post('/javascript/' + uid + '/mark_notifications_as_read/', { + 'notification_ids': notification_ids + }, function (data) { + if (data.Error) { + callback(data.Error, null); + } else { + callback(null, data); + } + }); + }; + + $('[data-toggle="tooltip"]').tooltip(); }); \ No newline at end of file diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index c5470583..6035039f 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -8,7 +8,7 @@
    + right: 100px; float: right;" class="btn btn-default read_all_notifications" uid="{{ uid }}" allid="True">Mark all notifications as read

    @@ -21,43 +21,44 @@

    Notifications

    - {% for group_id in notifications.keys() %} - + + + {% for group_id in grouped_notifications.keys %} + + {% endfor %}
    {{ group_id }}All notifications
    {{ group_id }}
    - {% if grouped_notifications != None %} {% if num_notifications > 0 %}
    {% for group, events in grouped_notifications.items %} - {% if events != None %} - + {% if events|length > 0 %} + - + {% for event in events %} {% if event.owner_id != uid %} - {% if event.is_active == 1 %} - + + - + - {% endif %} + {% endif %} {% endfor %} +
    {{ group }}
    {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}. Shared on {{ event.share_time }}
    {% endif %} - - -{% endfor %} + {% endfor %}
    @@ -69,32 +70,11 @@

    No new notifications.

    -
    -{% endif %} -{% else %} -
    - - -
    -
    -

    {{ group }}

    -
    - - {% for event in all_events_in_group %} - {% if event.owner_id != uid %} - - - - {% endif %} - {% endfor %} - -
    {{ event.owner_id }} shared {{ event.graph_id }} with {{ event.group_id }}.
    +{% endif %} -
    -{% endif %} {% endif %} {% endblock %} \ No newline at end of file diff --git a/graphs/urls.py b/graphs/urls.py index 8f00b7b4..22984026 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -80,7 +80,7 @@ url(r'^resetPassword/$', views.resetPassword, name='resetPassword'), url(r'^launchTask/$', views.launchTask, name='launchTask'), url(r'^retrieveTaskCode/$', views.retrieveTaskCode, name='retrieveTaskCode'), - url(r'^javascript/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/mark_notifications_as_read/$', views.mark_notifications_as_read, name='mark_notifications_as_read'), + url(r'^javascript/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/mark_notifications_as_read/$', views.mark_notifications_as_read_api, name='mark_notifications_as_read_api'), #REST API diff --git a/graphs/util/db.py b/graphs/util/db.py index 770189da..8e1410f0 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -4800,7 +4800,7 @@ def delete_share_graph_event(event_id, member_id): return {'Error': 'No share graph event found.'} -def get_share_graph_events_by_member_id(member_id, only_active=1): +def get_share_graph_events_by_member_id(member_id, all_notifications=1): """ Return a list of share graph events for a user for a given userid of a member of any group and given status of the notification. @@ -4813,10 +4813,10 @@ def get_share_graph_events_by_member_id(member_id, only_active=1): """ # Create database connection db_session = data_connection.new_session() - if only_active: - events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.is_active == 1).all() - else: + if all_notifications == 1: events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).all() + else: + events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.is_active == 1).all() db_session.close() return events @@ -4863,24 +4863,22 @@ def set_share_graph_events_inactive(event_ids, member_id): @param member_id: id of the logged in user ''' db_session = data_connection.new_session() - try: - for event_id in event_ids: - db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.id == event_id).update({"is_active": 0}) - db_session.commit() - db_session.close() - except NoResultFound: - db_session.close() - return {'Error': 'No share graph event found.'} + for event_id in event_ids: + db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.id == event_id).update({"is_active": 0}) + db_session.commit() + db_session.close() + -def get_share_graph_event_by_member_id_and_group_id(member_id, group_id): +def get_share_graph_event_by_member_id_and_group_id(member_id, group_id, all_notifications=1): + # TODO: @Mridul Add docstring db_session = data_connection.new_session() - try: + if all_notifications == 1: events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.group_id == group_id).all() - return events - except NoResultFound: - db_session.close() - return {'Error': 'No share graph event found.'} + else: + events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.group_id == group_id).filter(models.ShareGraphEvent.is_active == 1).all() + db_session.close() + return events def check_new_notifications(member_id): diff --git a/graphs/views.py b/graphs/views.py index 92afc13a..58536aaa 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -311,13 +311,14 @@ def _graphs_page(request, view_type): # Controller method for notifications. -def get_notifications_for_user(user_id, group_id=None, only_active=1): +def get_notifications_for_user(user_id, group_id=None, all_notifications=1): # TODO: @Mridul Add docstring + try: if group_id == None: - events = db.get_share_graph_events_by_member_id(user_id, only_active) + events = db.get_share_graph_events_by_member_id(user_id, all_notifications) else: - events = db.get_share_graph_event_by_member_id_and_group_id(user_id, group_id, only_active) + events = db.get_share_graph_event_by_member_id_and_group_id(user_id, group_id, all_notifications) except NoResultFound: events = list() except: @@ -334,14 +335,17 @@ def notifications(request, uid): # Checks to see if a user is currently logged in uid = request.session['uid'] group_id = request.GET.get('group_id') - all = request.GET.get('all') + if request.GET.get('all'): + all_notifications = int(request.GET.get('all')) + else: + all_notifications = 0 if uid is None: context['Error'] = "Please log in to view notifications." return render(request, 'graphs/error.html', context) try: - notifications = get_notifications_for_user(uid, group_id, all=all) + notifications = get_notifications_for_user(uid, group_id, all_notifications=all_notifications) context['grouped_notifications'] = dict() for notification in notifications: @@ -361,49 +365,37 @@ def notifications(request, uid): context['Error'] = str(e) return render(request, 'graphs/error.html', context) - -def mark_notifications_as_read(request, uid): - ''' - View for marking a notification as read. - Fetch gid, nid, allid and uid for a request and - update the database accordingly. - ''' +def mark_notifications_as_read_api(request, uid): if request.method == 'POST': - nid = request.POST['nid'] if 'nid' in request.POST else None - allid = request.POST['allid'] if 'allid' in request.POST else None - gid = request.POST['gid'] if 'gid' in request.POST else None - uid = request.session.get('uid', None) - # Check if the user is authenticated + notification_ids = request.POST.get('notification_ids[]') if 'notification_ids[]' in request.POST else [] if uid == None: return HttpResponse( json.dumps(db.throwError(401, "You are not allowed to update this share event."), indent=4, separators=(',', ': ')), content_type="application/json") + else: + try: + status = mark_notifications_as_read(notification_ids, uid) + return HttpResponse( + json.dumps(db.sendMessage(200, "Successfully updated share event(s) owned by " + uid + '.'), indent=4, + separators=(',', ': ')), content_type="application/json") + except Exception, e: + # Better way of handling errors? As we don't have any GraphSpace + # specific excpections I am just using the generic SQLalchemy ORM + # generic exception NoResultFound. + return HttpResponse( + json.dumps(db.throwError(500, str(e)), indent=4, separators=(',', ': ')), + content_type="application/json") - # If gid and allid are None then the user clicked on mark as read - # for an individual notification. - if gid is None and allid is None: - events = [nid] - - # if gid is not None then the user clicked on mark as read for - # all the notifications in a group - elif gid is not None: - events = [event.id for event in db.get_share_graph_event_by_member_id_and_group_id(uid, gid)] - # if allid is not None then the user clicked on mark all notifications - # as read for a user. - elif allid is not None: - events = [event.id for event in db.get_share_graph_events_by_member_id(uid)] - event_info = db.set_share_graph_events_inactive(events, uid) +def mark_notifications_as_read(notification_ids, uid): + print(notification_ids, uid) + try: + db.set_share_graph_events_inactive(notification_ids, uid) + return 1 + except: + raise Exception('Database Error: Error setting notifications as read.') - if event_info is not None: - return HttpResponse( - json.dumps(db.throwError(404, "There is no such share event."), indent=4, separators=(',', ': ')), - content_type="application/json") - else: - return HttpResponse( - json.dumps(db.sendMessage(200, "Successfully updated share event(s) owned by " + uid + '.'), indent=4, - separators=(',', ': ')), content_type="application/json") def upload_graph_through_ui(request): From 9c672dd9289ad95bcc75f8c83ecfb0fab7062866 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Wed, 17 Aug 2016 18:10:42 +0530 Subject: [PATCH 19/23] Update notifications.js according to notifications spec --- graphs/static/graphs/js/notifications.js | 59 ++++++++++++++++-------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/graphs/static/graphs/js/notifications.js b/graphs/static/graphs/js/notifications.js index bc3131fd..78fd63a5 100644 --- a/graphs/static/graphs/js/notifications.js +++ b/graphs/static/graphs/js/notifications.js @@ -3,65 +3,77 @@ $(document).ready(function () { * Mark notification as read through the UI. */ + + // Method to mark as read individual notifications $(".read_notification").click(function(e){ + // get the user id of the logged in user var uid = $(this).attr('uid'); - // notification id present if clicked on individual notification + // get a list of notification ids, in this case it will be a + // list of length 1 as it a single notification var notification_ids = [$(this).attr('notification_id')]; - + // Call function read_notification which sends a POST request to mark + // notification as read read_notifications(uid, notification_ids, function(error, data){ if (error) { return alert(error); } else { - window.location.reload(); - //$('.remove_read'+nid).remove(); - //$('.notification_event'+nid).addClass('notification_read'); + // If no error is caught that manipulate the DOM for notification + manipulate_dom(notification_ids); } }); }); + // Method to mark all notification of a group as read for the user $(".read_all_group_notifications").click(function(e){ + // get the user id of the logged in user var uid = $(this).attr('uid'); + // get the group id of the notifications that need to be marked as read var group_id = $(this).attr('group_id'); - // notification id present if clicked on individual notification + // create a list of notification ids which are present in the group var notification_ids = []; $.each($('.notifications.' + group_id), function( index, notification ) { notification_ids.push($(notification).attr('id')); }); - + // Call function read_notifications which sends a POST request to mark + // notifications as read read_notifications(uid, notification_ids, function(error, data){ if (error) { return alert(error); } else { - window.location.reload(); - //$('.remove_read'+nid).remove(); - //$('.notification_event'+nid).addClass('notification_read'); + // If no error is caught that manipulate the DOM for notification + manipulate_dom(notification_ids); } }); }); + // Method to mark all notifications as read for the user $(".read_all_notifications").click(function(e){ + // get the user id of the logged in user var uid = $(this).attr('uid'); - var group_id = $(this).attr('group_id'); - // notification id present if clicked on individual notification + // create a list of notification ids which are present in the group var notification_ids = []; $.each($('.notifications'), function( index, notification ) { notification_ids.push($(notification).attr('id')); }); - + // Call function read_notification which sends a POST request to mark + // notifications as read read_notifications(uid, notification_ids, function(error, data){ if (error) { return alert(error); } else { - window.location.reload(); - //$('.remove_read'+nid).remove(); - //$('.notification_event'+nid).addClass('notification_read'); + // If no error is caught that manipulate the DOM for notification + manipulate_dom(notification_ids); + $ } }); }); + // send a POST request to the view method mark_notificartions_as_read_api + // with a list of notification_ids var read_notifications = function(uid, notification_ids, callback) { - $.post('/javascript/' + uid + '/mark_notifications_as_read/', { - 'notification_ids': notification_ids + console.log(notification_ids); + $.post('/javascript/' + uid + '/mark_notifications_as_read_api/', { + 'notification_ids[]': [notification_ids] }, function (data) { if (data.Error) { callback(data.Error, null); @@ -71,6 +83,15 @@ $(document).ready(function () { }); }; - $('[data-toggle="tooltip"]').tooltip(); + // grey out notification row and remove the tick mark for notification row + var manipulate_dom = function(notification_ids){ + $.each($(notification_ids), function(index, element){ + $('.remove_read'+element).remove(); + $('.notification_event'+element).addClass('notification_read'); + }); + }; + // helper function for tooltip, used to display text when cursors hovers on + // the read tick marks. + $('[data-toggle="tooltip"]').tooltip(); }); \ No newline at end of file From 3542ff82bc90397013b303555a08bdad2efb5846 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Wed, 17 Aug 2016 18:11:45 +0530 Subject: [PATCH 20/23] Add group information and the number badges to the left column in notifications.html --- graphs/templates/graphs/notifications.html | 24 ++++++++-------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/graphs/templates/graphs/notifications.html b/graphs/templates/graphs/notifications.html index 6035039f..ad8ee820 100644 --- a/graphs/templates/graphs/notifications.html +++ b/graphs/templates/graphs/notifications.html @@ -13,27 +13,19 @@
    -
    -
    - Groups -
    -
    -

    Notifications

    + - - - - - {% for group_id in grouped_notifications.keys %} - - - +
    + Groups + {% for group_id, notifications in groups_of_user.items %} + {{ group_id }} {{ notifications }} {% endfor %} -
    All notifications
    {{ group_id }}
    - {% if num_notifications > 0 %} + {% if grouped_notifications|length != 0 %}
    {% for group, events in grouped_notifications.items %} From 8cd3d09422fba6a3ccc0463ede0733df04f8a049 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Wed, 17 Aug 2016 18:12:36 +0530 Subject: [PATCH 21/23] update urls.py to point to correct view --- graphs/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphs/urls.py b/graphs/urls.py index 22984026..7ef4021a 100644 --- a/graphs/urls.py +++ b/graphs/urls.py @@ -80,7 +80,7 @@ url(r'^resetPassword/$', views.resetPassword, name='resetPassword'), url(r'^launchTask/$', views.launchTask, name='launchTask'), url(r'^retrieveTaskCode/$', views.retrieveTaskCode, name='retrieveTaskCode'), - url(r'^javascript/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/mark_notifications_as_read/$', views.mark_notifications_as_read_api, name='mark_notifications_as_read_api'), + url(r'^javascript/(?P\b[A-Z0-9a-z._%+-]+@[A-Z0-9a-z.-]+\.[A-Za-z]{2,4}\b)/mark_notifications_as_read_api/$', views.mark_notifications_as_read_api, name='mark_notifications_as_read_api'), #REST API From d9f31bbbc32e30f6d0c2756e94c601ea02403418 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Wed, 17 Aug 2016 18:49:56 +0530 Subject: [PATCH 22/23] Update views and db to match the notifications spec --- graphs/util/db.py | 106 +++++++++++++++++++--------------------------- graphs/views.py | 105 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 143 insertions(+), 68 deletions(-) diff --git a/graphs/util/db.py b/graphs/util/db.py index 8e1410f0..264d7e2d 100644 --- a/graphs/util/db.py +++ b/graphs/util/db.py @@ -4759,6 +4759,7 @@ def add_share_graph_event(graph_id, owner_id, group_id, member_id): db_session.commit() db_session.close() + # admin function def update_share_graph_event(event_id, active, member_id): ''' @@ -4770,34 +4771,26 @@ def update_share_graph_event(event_id, active, member_id): @param member_id: id of the user, the logged in user. ''' db_session = data_connection.new_session() + event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() + event.is_active = active + db_session.commit() + db_session.close() - try: - event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() - event.is_active = active - db_session.commit() - db_session.close() - except NoResultFound: - db_session.close() - return {'Error': 'No share graph event found.'} # admin function def delete_share_graph_event(event_id, member_id): ''' Delete the share graph event from the table for the member - @param event_id: id of the share graph event - @param member_id: id of the member + :param event_id: id of the share graph event + :param member_id: id of the member ''' db_session = data_connection.new_session() - try: - event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() - db_session.delete(event) - db_session.commit() - db_session.close() - except NoResultFound: - db_session.close() - return {'Error': 'No share graph event found.'} + event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() + db_session.delete(event) + db_session.commit() + db_session.close() def get_share_graph_events_by_member_id(member_id, all_notifications=1): @@ -4806,9 +4799,9 @@ def get_share_graph_events_by_member_id(member_id, all_notifications=1): If no results are found the method will raise NoResultFound exception. - @param member_id: id of the user - @param is_active: 1 if we want only the list of unread share graph events else 0 to get all share graph events - @return: List of share graph events + :param member_id: id of the user + :param is_active: 1 if we want only the list of unread share graph events else 0 to get all share graph events + :return: List of share graph events """ # Create database connection @@ -4820,47 +4813,41 @@ def get_share_graph_events_by_member_id(member_id, all_notifications=1): db_session.close() return events - # db_session.close() - # except NoResultFound: - # return {'Error': 'No share graph event found.'} - def get_share_graph_event_by_id(event_id, member_id): ''' - Return share graph event notification + Query database to find share graph event by event_id - @param event_id: id of the event - @param member_id: id of the logged in user + :param event_id: id of the event + :param member_id: id of the logged in user + + :return: event with id event_id ''' db_session = data_connection.new_session() - try: - event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() - db_session.close() - return event - except NoResultFound: - db_session.close() - return {'Error': 'No share graph event found.'} + event = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.id == event_id).filter(models.ShareGraphEvent.member_id == member_id).one() + db_session.close() + return event + def get_all_share_graph_event(): ''' - Return all the share graph events. + Query database to find all the share graph events + and return a list of events + + :return: list of events ''' db_session = data_connection.new_session() - try: - events = db_session.query(models.ShareGraphEvent).all() - db_session.close() - return events - except NoResultFound: - db_session.close() - return {'Error': 'No share graph event found.'} + events = db_session.query(models.ShareGraphEvent).all() + db_session.close() + return events def set_share_graph_events_inactive(event_ids, member_id): ''' Set all events in the list event_ids as inactive - @param events_id: list of event ids - @param member_id: id of the logged in user + :param events_id: list of event ids + :param member_id: id of the logged in user ''' db_session = data_connection.new_session() for event_id in event_ids: @@ -4869,9 +4856,19 @@ def set_share_graph_events_inactive(event_ids, member_id): db_session.close() - def get_share_graph_event_by_member_id_and_group_id(member_id, group_id, all_notifications=1): - # TODO: @Mridul Add docstring + ''' + Query database to find share graph events with a specific member_id + ,group_id and all_notifications and return a list. If all_notifications + is `1` then all notifications are returned irrespective of `is_active` + field (i.e. read or unread), else (all_notifications=0) only unread + events are returned. + + :param member_id: id of the logged in user + :param group_id: id of thr group + :param all_notifications: boolean value to display all notifications + :return: list of events + ''' db_session = data_connection.new_session() if all_notifications == 1: events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.group_id == group_id).all() @@ -4879,18 +4876,3 @@ def get_share_graph_event_by_member_id_and_group_id(member_id, group_id, all_not events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.group_id == group_id).filter(models.ShareGraphEvent.is_active == 1).all() db_session.close() return events - - -def check_new_notifications(member_id): - db_session = data_connection.new_session() - try: - events = db_session.query(models.ShareGraphEvent).filter(models.ShareGraphEvent.member_id == member_id).filter(models.ShareGraphEvent.is_active == 1).all() - if len(events) != 0: - return True - else: - return False - except NoResultFound: - db_session.close() - return {'Error': 'No share graph event found.'} - - diff --git a/graphs/views.py b/graphs/views.py index 58536aaa..21097e49 100644 --- a/graphs/views.py +++ b/graphs/views.py @@ -312,7 +312,19 @@ def _graphs_page(request, view_type): # Controller method for notifications. def get_notifications_for_user(user_id, group_id=None, all_notifications=1): - # TODO: @Mridul Add docstring + ''' + Controller method to get notifications for a user. + This method returns a list of events of a user according to the parameters + provided. If a group_id is present then only the notifications for the specific + group are returned. If all_notifications is `1` then all notifications irrespective + of there `is_active` i.e. read field are returned otherwise (all_notifications=0) + only unread notifications are returned. + + :param user_id: user id of logged in user + :param group_id: id of requested group (default=None) + :param all_notifications: value of query string parameter (default=1) + :return: list of events + ''' try: if group_id == None: @@ -327,13 +339,48 @@ def get_notifications_for_user(user_id, group_id=None, all_notifications=1): return events +def get_notifications_group_stats_for_user(user_id): + ''' + Controller method to get statistics for groups and notifications. + These stats are displayed in the left column of the notificaitons.html + page. We calculate the number of all notifications, + active notifications (i.e. Unread) and groups the user is a part of. + + :param user_id: + :return: a three tuple of number of active notifications, all notificaitons + and groups of user. + ''' + try: + events = db.get_share_graph_events_by_member_id(user_id, all_notifications=0) + all_events = db.get_share_graph_events_by_member_id(user_id, all_notifications=1) + groups_of_user = [group['groupId'] for group in db.groups_for_user(user_id)] + + except: + raise Exception('Database Error: Error while fetching notifications') + + return len(events), len(all_events), groups_of_user + + # View method for notifications def notifications(request, uid): - # TODO: @Mridul Add docstring + ''' + View method to display notifications for a user. + Use uid to check if user is currently logged in the session + and query string parameters `group_id` and `all` to display + group specific notifications and all notifications for a user + + :param request: + :param uid: user_id of the user + + :return: Render HTML page for notifications or HTML page for + errors if any errors occurs. + ''' + # handle login context = login(request) # Checks to see if a user is currently logged in uid = request.session['uid'] + # Query string parameter for group_id group_id = request.GET.get('group_id') if request.GET.get('all'): all_notifications = int(request.GET.get('all')) @@ -347,6 +394,8 @@ def notifications(request, uid): try: notifications = get_notifications_for_user(uid, group_id, all_notifications=all_notifications) + # Create a grouped notification dictonary which is keyed by the group_id + # and has all the notifications for that group as a list in the values field. context['grouped_notifications'] = dict() for notification in notifications: if notification.group_id in context['grouped_notifications']: @@ -355,9 +404,31 @@ def notifications(request, uid): context['grouped_notifications'][notification.group_id] = [notification] context['group'] = group_id - context['num_notifications'] = len(notifications) + + # Create context for left column stats which display Unread notifications, all + # notifications and groups with number of notifications. + notification_stats = get_notifications_group_stats_for_user(uid) + + # Create a dictionary to store the groups and the number + # of notifications for each group. + # Instead of using grouped_notifications we need to create a new variable + # because grouped_notification will only have the notifications for a + # particular group when we click on notifications of a group, and we'll lose + # all the information about other groups. Hence only the particular group will + # be dispalyed in the left column when we iterate through the grouped_notifcation + # variable. + group_count = {} + for group in notification_stats[2]: + if group in context['grouped_notifications']: + group_count[group] = len(context['grouped_notifications'][group]) + else: + group_count[group] = 0 + + context['num_active_notifications'], context['num_notifications'] = notification_stats[0], notification_stats[1] + context['groups_of_user'] = group_count return render(request, 'graphs/notifications.html', context) + except Exception, e: # Better way of handling errors? As we don't have any GraphSpace # specific excpections I am just using the generic SQLalchemy ORM @@ -365,7 +436,20 @@ def notifications(request, uid): context['Error'] = str(e) return render(request, 'graphs/error.html', context) + def mark_notifications_as_read_api(request, uid): + ''' + view method to mark notifications as read. + It marks notifications as read and returns a HTTP response + depending on actions. + 401 - if the user is not logged in + 200 - Success + 500 - If any database error or any other exception is recorded + + :param request: request session variable + :param uid: user_id of the logged in user + :return: HttpResponse + ''' if request.method == 'POST': notification_ids = request.POST.get('notification_ids[]') if 'notification_ids[]' in request.POST else [] if uid == None: @@ -387,9 +471,19 @@ def mark_notifications_as_read_api(request, uid): content_type="application/json") - def mark_notifications_as_read(notification_ids, uid): - print(notification_ids, uid) + ''' + Controller method to mark a list of notifications + as read. Interacts with the database layer and updates + the is_active field for the list of share graph events + + It raises an error if any exception is raised by the + database layer. + + :param notification_ids: list of notification ids + :param uid: user_id of the logged in user + :return: 1 if successful + ''' try: db.set_share_graph_events_inactive(notification_ids, uid) return 1 @@ -397,7 +491,6 @@ def mark_notifications_as_read(notification_ids, uid): raise Exception('Database Error: Error setting notifications as read.') - def upload_graph_through_ui(request): if request.method == 'POST': login_form = LoginForm() From d4f786a958f91b1285afbb2492fd04670488e043 Mon Sep 17 00:00:00 2001 From: Mridul Seth Date: Wed, 17 Aug 2016 19:07:59 +0530 Subject: [PATCH 23/23] Remove unnecessary file get-pip --- get-pip.py | 2565 ---------------------------------------------------- 1 file changed, 2565 deletions(-) delete mode 100644 get-pip.py diff --git a/get-pip.py b/get-pip.py deleted file mode 100644 index e3484167..00000000 --- a/get-pip.py +++ /dev/null @@ -1,2565 +0,0 @@ -#!/usr/bin/env python -# -# Hi There! -# You may be wondering what this giant blob of binary data here is, you might -# even be worried that we're up to something nefarious (good for you for being -# paranoid!). This is a base85 encoding of a zip file, this zip file contains -# an entire copy of pip. -# -# Pip is a thing that installs packages, pip itself is a package that someone -# might want to install, especially if they're looking to run this get-pip.py -# script. Pip has a lot of code to deal with the security of installing -# packages, various edge cases on various platforms, and other such sort of -# "tribal knowledge" that has been encoded in its code base. Because of this -# we basically include an entire copy of pip inside this blob. We do this -# because the alternatives are attempt to implement a "minipip" that probably -# doesn't do things correctly and has weird edge cases, or compress pip itself -# down into a single file. -# -# If you're wondering how this is created, it is using an invoke task located -# in tasks/generate.py called "installer". It can be invoked by using -# ``invoke generate.installer``. - -import os.path -import pkgutil -import shutil -import sys -import struct -import tempfile - -# Useful for very coarse version differentiation. -PY2 = sys.version_info[0] == 2 -PY3 = sys.version_info[0] == 3 - -if PY3: - iterbytes = iter -else: - def iterbytes(buf): - return (ord(byte) for byte in buf) - -try: - from base64 import b85decode -except ImportError: - _b85alphabet = (b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" - b"abcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~") - - def b85decode(b): - _b85dec = [None] * 256 - for i, c in enumerate(iterbytes(_b85alphabet)): - _b85dec[c] = i - - padding = (-len(b)) % 5 - b = b + b'~' * padding - out = [] - packI = struct.Struct('!I').pack - for i in range(0, len(b), 5): - chunk = b[i:i + 5] - acc = 0 - try: - for c in iterbytes(chunk): - acc = acc * 85 + _b85dec[c] - except TypeError: - for j, c in enumerate(iterbytes(chunk)): - if _b85dec[c] is None: - raise ValueError( - 'bad base85 character at position %d' % (i + j) - ) - raise - try: - out.append(packI(acc)) - except struct.error: - raise ValueError('base85 overflow in hunk starting at byte %d' - % i) - - result = b''.join(out) - if padding: - result = result[:-padding] - return result - - -def bootstrap(tmpdir=None): - # Import pip so we can use it to install pip and maybe setuptools too - import pip - from pip.commands.install import InstallCommand - from pip.req import InstallRequirement - - # Wrapper to provide default certificate with the lowest priority - class CertInstallCommand(InstallCommand): - def parse_args(self, args): - # If cert isn't specified in config or environment, we provide our - # own certificate through defaults. - # This allows user to specify custom cert anywhere one likes: - # config, environment variable or argv. - if not self.parser.get_default_values().cert: - self.parser.defaults["cert"] = cert_path # calculated below - return super(CertInstallCommand, self).parse_args(args) - - pip.commands_dict["install"] = CertInstallCommand - - implicit_pip = True - implicit_setuptools = True - implicit_wheel = True - - # Check if the user has requested us not to install setuptools - if "--no-setuptools" in sys.argv or os.environ.get("PIP_NO_SETUPTOOLS"): - args = [x for x in sys.argv[1:] if x != "--no-setuptools"] - implicit_setuptools = False - else: - args = sys.argv[1:] - - # Check if the user has requested us not to install wheel - if "--no-wheel" in args or os.environ.get("PIP_NO_WHEEL"): - args = [x for x in args if x != "--no-wheel"] - implicit_wheel = False - - # We only want to implicitly install setuptools and wheel if they don't - # already exist on the target platform. - if implicit_setuptools: - try: - import setuptools # noqa - implicit_setuptools = False - except ImportError: - pass - if implicit_wheel: - try: - import wheel # noqa - implicit_wheel = False - except ImportError: - pass - - # We want to support people passing things like 'pip<8' to get-pip.py which - # will let them install a specific version. However because of the dreaded - # DoubleRequirement error if any of the args look like they might be a - # specific for one of our packages, then we'll turn off the implicit - # install of them. - for arg in args: - try: - req = InstallRequirement.from_line(arg) - except: - continue - - if implicit_pip and req.name == "pip": - implicit_pip = False - elif implicit_setuptools and req.name == "setuptools": - implicit_setuptools = False - elif implicit_wheel and req.name == "wheel": - implicit_wheel = False - - # Add any implicit installations to the end of our args - if implicit_pip: - args += ["pip"] - if implicit_setuptools: - args += ["setuptools"] - if implicit_wheel: - args += ["wheel"] - - delete_tmpdir = False - try: - # Create a temporary directory to act as a working directory if we were - # not given one. - if tmpdir is None: - tmpdir = tempfile.mkdtemp() - delete_tmpdir = True - - # We need to extract the SSL certificates from requests so that they - # can be passed to --cert - cert_path = os.path.join(tmpdir, "cacert.pem") - with open(cert_path, "wb") as cert: - cert.write(pkgutil.get_data("pip._vendor.requests", "cacert.pem")) - - # Execute the included pip and use it to install the latest pip and - # setuptools from PyPI - sys.exit(pip.main(["install", "--upgrade"] + args)) - finally: - # Remove our temporary directory - if delete_tmpdir and tmpdir: - shutil.rmtree(tmpdir, ignore_errors=True) - - -def main(): - tmpdir = None - try: - # Create a temporary working directory - tmpdir = tempfile.mkdtemp() - - # Unpack the zipfile into the temporary directory - pip_zip = os.path.join(tmpdir, "pip.zip") - with open(pip_zip, "wb") as fp: - fp.write(b85decode(DATA.replace(b"\n", b""))) - - # Add the zipfile to sys.path so that we can import it - sys.path.insert(0, pip_zip) - - # Run the bootstrap - bootstrap(tmpdir=tmpdir) - finally: - # Clean up our temporary working directory - if tmpdir: - shutil.rmtree(tmpdir, ignore_errors=True) - - -DATA = b""" -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 -)xSsFUoLR;R%DaVr#m(89VPc%8ES ->!Qx{G2E2p@c|HTc+LJuVfB3x!?NEGt-RX6?JK2PSI!~&hT8T+-RsBTU>vm5XFm%B|XCqI*cVSUM -KE8D7PlsZAfzBc|%NE!LiZ{;*{LCQ7Kep(w2j+w7Dz2Q{pCjJ;LVRdkyaF~<(8{ox2c!%YzT_`OU|Wh -}=}vH|m!ZT$zdtBZ!LfN-j6p_wh#?w`a|hS0CG`p?wAI8THbcFd>}wTq%ybF_&ZLQx_?75IuP623n~4v954u -6AHoALD`rAOrUvyo*~`=-$QCu&d?O=YQy7{kptSEMvTZiqE8x#VV#s~bs+GN<++bm%FD{Kp~Tv<>hXV -P-#5~LZ@)fm$9~dc7;i;yE>16wiR1^YZZ}Ldaj=pK0zbMdo*}f`28rHi`^Lf7!p>k~=&Lvrz%=3ku~I -~=*k>GAhhN(=Pc`kx+xggDudBtD#@*y{aY>gKbg{g=y_wwOZrz)Smy{}f1%-wWem+g_?B~Vx%{6^@a< -jNtYNBM)-7e}-^LK6LSO5)vf7}aIi#7BN_yNt#0H#Fkl_QBvsseVS -Ej;Sl<*Au@7y1HCMT*>LfUIcm=JXdE^@E-_gnfTv|LZ4$!7!tbW)I+41K2?j{LyndYc;R7{#{Bu-vfI -M^Af#JzxC%!`U$bxmmMp_J2(q4+}y@4B;L+<`~Ev1!@2^ld2q@33awrB+PE^6pW<|Wpmg;0O;$bg%9; -4K%$Tyh1iXSE+_i%&3`0DVu9a#ZE#PoP(p?P)Z4%`P^xhaCujDAk6_5eA_=`>GDGSriq5gBaunn6J9Z -4!UqSep=Y*s^Tb%x1EYQ0e_rUVAxSS|5$9Q<7X8lXK{hl3GWz5q>0ktNtEBOqs7Zu}rOD;jf&2ydVu! -Nlam5e_pQajyePSH79Z2%y!VPNeC0pc39Gy1@!p>>DJsY->=@J{I)kd6;j*extM1u7NK1yj-#W=SCsk{80psbb4eBv -VnCbQalM2hJIyG_>hxbnG`wuY}+c?MLIcQLzXHnb4kT`IA(P0ZH%g9Rst3umPW5L}Vk-#6xV<08E3P1 -#P!=dopWbhk}rSF$OJCpKBE3Ubbn&oKzq}dtC%^L|ivYY$mXrG?;)XAhXhT3&(%2__jy0;hm45`7~!} -}o-R>%}}>6ayto9Q;Zy+B}fW(Vl5Oh<%0B9>>0G5`9TvNp{mhO0kI7?%FHK)fGPeSd-OllI8d8S^9kO -)`Z_9AyL1nxeCGuAOKIzn8kc_b0pQGhR9Hz~3V(k5~F+g5lwRb6*Rrny6Q=%=cH@SU?*I4ZeVIsp2QP -wM$Ycsm!2V&vS>0W24s$@B67kT&ED}b7lKFGvj>F@aYeoo)9Ynb-;%^n*(RV!GVx^f;87WezW&)@ZYC -?dJ~B;VwR`)D8#Pu^7 -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 -Ha;c#$xhJ&kV=X#?>T_r -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 -(QOb2LHXTWh#HfR2duysG{y -69oQrdYd?8Lw#Q518f;v97PgMI50s&A+5EO;HmIZZOqUbc^P?QodeHCw-dAm#4YH({@@*^pGMk$3s?1lcvfMDM -v6ayG*$lx%Y9l`Wslqbhww5xfL6}5g56y=>Lzbi(FkN+dpnvqY!8-HjfPs5l5ZkCSw~jD*E}0tOgltr*o4N)XDx4FX9rIv_A6+%`QL{PKyZ=q(U{GVTDt}Z%3%|KXE22 -b?wrVvU)(O-Am~KP$Zx0Uat7(7_va6Z~Jd5wPV6{x8nj^+rYXRwxOsXxdb@I*CS8*?=7^aPw9zIEdv|$n{ -KS8Ni#@5?#7HEjszG+n?w$WBwi2(zHkRS(}MvLqFT^b|pB-0(3Ee -{DEN25jAVcQ)^NQy1CcdC)qg+(2+Z((a50>{hr_ -+@>c9=(}4U1|7BHGSIEUHm>u|%UmZOL`0P{rtXs<^XgctRutzj9jYd&oY1|K1YJWb3)E -&RzWCzmCasrh;u>i`!hFgCwo?jgkXS)5xs$7+q -AI2Tn52sXcjcBN-)?Yesh8Y9|y~zC#N|pC$`}-R;fd?oE!K21PrC4yO>3+Jd4PRCT^Y&k`Gya*ozH;tuMvx)D`d;}ktkc63iITMb3 -g97-8Gn-3OR59&Xx(;H*MVUWjqEiA&STtyaBf+x-d%|#LmYQ;4%9a -^QJ4d4Qy2T(_fq}Mhy4i4ZwdqSO)4CH~eI>&ZOmnmsQvg!}H6Op~YccKO51SuH7hZdWc1+MrPamFxvu -O}(fX4)INM@Lptj@!^cZ%MclrJl=;J!Q4LCnaB1-tenk>A>QxM))hk;!y@!Z4eBEkTa!UtlQbi>>}Km -$iQGPY;COZ7vnP@~p;YrDcy3iH!8Hfr!)GV#3AI#NQFFtUnP?j*k4-Y_>U&JHR$`Wj+WBt=~!!)X1RO -ji|F%ug@r2i#a2B5(@SnQePc?)o%hj8D2s6J)xy_mF0_^G=h`+(;f*p88RZ2VYpUGRIC%lQS$=t28WwHpvL-kjLjoWvGJ*xOUQm;l7d08h1KE~ISYbaO!V0meiF -5_Js|!@hQ40ckLan2Fi8vy$d*U86>qw{qT+UyMO|ak>zBLop1Z4P{FmP-vv)MZx(=0tR{o!E|V~pk4} -WxX4ipmKt{gN#J4!9uJrEV0|P#o7o1m5 -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 -d!}YK@cD99H1vIT3T@5F -NdLve=gCihW{K9Dk>JN0*bYXN1T(;Z-hrB-gDsGM*>c!fmuVj-o4TfrQ{;adlIb|jp_dp|M~;PD`sAqhJWLE7fB$XIT7fgv^vo;G~yTRImdbx58yK-sj;Xvq#KBv8$M1|9CB10n- -f6t!M|Y}PGKF>|QHdOd6fDKr@tBqaa#&eEz)EHwt&B=lJExNgqCQ-Az+FxW)aSxlGgFc2T_-q?M+5gn -cW)6PmlwQv0J6UxuWp_|SIg0&5389fWbu^@&l-|mFFYl$3dmCd2!y4=~U!GoA~q{B@!-U0hwll8?m?7s{0v0&ZB3Cg_-?z^L*PASG&IS54 -1Cn@f%bvZE|ar%8I9eBWEXMdf6T6k$-n7FfGKYXm|?Ju4tA5nw;4ODwSPZTkSXss2*Rm0P{A%zM+^QP -bICa=1l1T!dy^R=roM7)sIhk5=GA7)v6(JM)+N6jR~Z|+9)R`wOlv3XM8P8;N^HQ}$(qJ|ty=7mTmH87*Fp)*Wcej?poqW)q$3KFq#DzWm2)q(19VoC2t?hfqC>a-1 -v_G3g-oj@{qzUIKf`CyVmE94hHqft|Aru@U@NcFr}v5{4F2j(z>oNb;pO!j@m;WSKf`!z&6hAxwu|b( -p>x%L?xY(VU>nX85*>m^tuVvxViSv&u@na{@v+qygn0W0}N>ZmEgJpU`I~u#H>CQuy7M?vE?5np2;7y{hW!IJBw9R^h3*5(Q8V=f#+uuK*M=#B>pV`~6^=E -eX*6M*43tYmZGbg(GnD6q$4Pb;s>>PQdkW1DXQyQFxq3AHfmgC%9?#Kg4B0b+G%b^rm&U0;2JJ%XU&R -XV|OQ5(7|m<$)k?5=_TJ(8yOx5g -u9mk*)QA&ME7Vkde7W|-8pk>m!9!I4d?`92K)Cq&vf9X2vZ>((O+}a18GHIbij{VbBKuJk1lLKUHKU) -j!vHE6lNjUkdUI9~uewrhN@f`jEdbp+AHH3}#ZMOjhjP?4= -1b%8ilGwvB7qSoP|*0zVtB6PNQv6zZW{!NKjnN#A50)|r?bb3JRG&YwTAy;4@te_L$voe1{FuvADa;; -u(Dh+^3k9Oh#WV+!CWE4|Ja3^2sdInjD)Y`zIS6B)w?YQJL#MZI1sYo5Qyp++KFwbUN^p4U_*rIycmyj*0xgr -f{9O1zti)C7X$t -A3HZx2Bj`9S=LS)#l#=Uido3*v7?jyD -2`XoX8b>|y4PX%hqDOiFut0;sWSXIKpgH!pA;w9#Xhxp(SO@VF`T -)6R4HYwh=CEfmECa2efuPxI-Q&*MIig$l1z`f@*_+ti9hY|>xo9x8dl@y#;O@UX?GTb>E|N~rL%_JSw+ZDfj2FuXz|uzI8IfKBZU_2t0M~ -`#o&mu((>x{57qsRLHF)_d0}96Yr%HK%m#4PMEt%C>ZbdseAF1q}L;i&hU-cc -WN%5cm;=k=!l309m&=|ul)FF@gbj(=w -dcYX5Ma{YEjMIjD0@Fk+KQQol0be>>Ynm}%Pwxx>`%wgIzML*}Q~}-7AD*4jFNcPxnBC!~Fn|4bXRxp -!v=zy#m3B4`=5*1iRuf(b!?Z!&#buXdt)qn#;BvUPKfVvl*J&49(&3B%=b&#eQ#|Hrhavpv)(tC&j`5 -1?wQ(@TQ^8KOmf2If01IYuvqJW__X=yTnQBhS3GUVxMsWXll67l|b29S2aV{^c->v<8@QbMfJai9C8Z -VyDJhvr|%%__TfxUAh5miwj4*=pSEVsHLW3Z~h!Aa{J3_{KtATIAqeFj%%6N&-Qtp(U&&39EB;S!2WX^7SN2SeT7EbW^v-|OxU=V;W9KkwA9gx?XY?<^J@LicNpfrCtW01GR+lND5C9u*5y;Bl~rbJs^AQB0mXZ6Xxg*ad#c$V7 -=p=sP3-x`{eq@u8y%c>b>e3hwB&nu)2E2Pnu0wnMjvF^BppLJ`Ld6&0+t3-j8xzhx1ug8-n5%DAMIMt -Gw0~iQ+}Ik&Ct{OfXy@so0iMzH{J2uy2 -)6?G)RM_nngyWs13pBA5@81BB4f`z2a`ROrc%)R9(IMkpVeX|UC?*7b0wCZ|aQQM8wquHjR_vy#uY74 ->9EK00K+fTxk}-=p`WR@Q&Z(FNItD-c4({IH^xtdt-)ZviG4XxP>!8iHJ$?Pn>kUc}2ED!hsq5c;JyfuF&!LWr8r+_*ZD<+-Kl!p6^rxq*?PCW*-12f+G(`|7Ap4m^FSSm%}DG>rVaW6ooTA -?YxZ1W+;VuuR$jHL@amAXLVq_pj -=7jz#mHI$aDTk*2I_5lKqSl3V(RP%d*M_d6m*s)ow7xPs0b+hxZvr%Tc%46)&7cd~Z}aNZlFXIxjj5B -aW&Nc@c7SRObGdElYq+niI52*jd)sR@C2D-WI2Vmwe(*<1Eri~dQ5u8?I99q{A)VTFU>S$|Bo@qYnOO -9KQH0000800{-FNai)y_>c+!04yH>01*HH0B~t=FJ*XRWpH$9Z*FrgaCy~PO>^5g621FZpgfh5R3&qE -G9L$R<=`ZC?Apo4I!^X5Go^w^NWz!|H~=YG`|J001Efewa=f*tRaG2O0ve5n?$@uO^Yino$5fS-w#JL -vt4vgDXY1A4)|Dt_`XE>8{QUfE>1-k9^JQJtPR-{+7o~NTkPC0~x>ED-d=|#(O1a9~+&9D0wbW)RRUPSAtU%#__MRKG?(6sPrAHv&bo2y@?>@<@PdP{rB`H0sOCHJx|w-vd? -oW-(&xcMV{32WoifVFd~ZNxwbN!LbZ2tc=oh2^7qhiFW%#Z3mD7uwKL22=Yg?Oae6WT65_eM5!EM*d2 -r078Y>`T1Y$X;-EXj(ftne`5mphDf>aSWmRVY(+m%rP8?5}mMK1t_Q*xs|9ST(z*LBghK?L5NCdD?kz -WiOUkL*&}1r0d0N<*Tm>6Rf$+os!uuF0Qg8D0f${)=CuCSE4R2DtnR1N{LOdBv426x3F%ffquE{IlT#aQYyY -~|dG=PjXD>DKgi@T1h0HS7X+=l3AX5l2r_##0T|dV*GS}I^)=K3RHbxfesep;&X<3zX5YUXIpJBc(i4 -0UQ`;@uP*kH0}=|=iBPw-FKcwgt0E)k9G@DLOxRbDttv4IbeL)K72IpQ&_2w-?EvXZGKXi^(p&F5pU@ -@2G6FM3S){JeN2e*0rTLzX4@kH=5L2_K)9#DQT*cSYp<;er$zaj9d*JbWKNsNJhv7K=;@H4`eaE>kew -jHg|uxsP7?xn0gv;&s!I`M6_oD!P{DbH;u??|9UD)$9buk~LCra%6~lta!7@!e+bignd!8bkCRlOuY; -f`^!0nl4Zo`cQZ=b5li4fOSY!uf&@lwN^K0XDsILH5`q!927DnCkb>UViegJYD -Dm~p=hjB1D1?+rHGK_iGKG?})zvx?mpQz7fB_#FGo&gkIXG_zi9z)yyYfWkl~^_>p)KxuxU#oJ;Pv%R*!7h5y|IQ-RzF>S-_=7`JQcV4Ww<^$_(g#KWjY(SP3I2EVTKufVwFN2)LhEc;dW+{k`n%Z*&v3BwZU8q=Uu9MSQx5doFCI7 -~Zfg8b9i7ZBzue1vmcQ7X3I)!aAa$W>II*oiFw=ulG9OUhhk_A&!COr-WNUIO2?XiVwcg;Hfi0O* -+m_S8WzY>-7G*Qbjc|fEik+?9VcY8ItARc0o0#QfQF3`n#Q1he25_Dk_k!qjsH`5~Hv~%>_H0U%RG>` -_!%Tuy=P7AIrwy~HUsArrX6$%P=~fRgWuy|}OO@&+MGDbA*BlUB?COBJY(}JPDi-O(it}T?;@q -CBE;zz|EEYr`T)_k74`?s0=o_E7PP}?W4HQv(X362%=%BiSSkv_}DER;G6>Z48AbMnuRyC>$wh);kw0 -iNNSw9EPq<0NxHlcCQ^abPXwuY5oUK$qk2isiuKF-&&szn_DCz^7M!8Vl!;L|+* -PPQ&{d2m%x%zx1K3%@~$K|`L&*JSz@!{jWcz=2S=0@Nk-22m8ccVGLGO20W>(WBQ0rJ?@~Rryfq{nI7B&$_dITYIEM{x)TXnY8tJY7 -2m-y*2%gc=+XEL)^Y^JLCOuMx8Mf^ecgS1Wi(`cX*_uI<646i1*p-Z0Pw_GtsZ*%b$ONq)#*T+&@pk0 -5169<&Qu8{MHIL7|zcG}JDRX3pYYF48q< -nM0nhFSm+y5Q_c4nxxj2bDeHG(PQ#Gpa+G}PgfQF*5&&GB8DXY@;Uyk_4AMB$ItO7U_5GKie5)9?3-? -sXHVNW;fu`nC(>~@n+8yzA6e>07(D^p>5KQ^BE8xmgQSV6WiV(QY)|0UWO6!|_V@OU^T!sAKM0I^Q#; -{9w^dmA^fE1FbkI(~fq??Y0}oqRnsYcHli?2r{{c`-0|XQR000O82?eW2QW-jNrY8UZbCCc53;+NCaA -|NaX>Md?crI{x#eMsi+ctLa@AFq6a=oI~67897+U+!x)ODQ3=kt1fJa*Ea$yA3DA&EN_sgRUslzsf|- -+cfeUet^mZ>!zoa7f?+xVX44TwIJsqj&YbD=OKFMwVG8bJ5k}?cwdKsVJ&k-pO_}8l9|~dMoDh)xO&| -ay}Quc2_r@$QEr~?mIc>zfVro`?6lIi)yX^T&@d-u&m2cF1wsYw8^!=`a=Tt^|DDbyHE*%{ -D8GU*sHzKBeN%-@W+$)ekS{FaPuX%Qu&={`lr{DlR{~ef!6E?_Yjvzdf0F&8E5BHFBBZTnlQ`7;R -rR*m{mYMx)2Z|J^qG3?LsJA{1`Cu`b#r*z7nFBPxpPQ^hx~2Pc1^L^cOhZ!vgJ -*NC{I5!mIA`2hq?NC41hz1%dD!aV#$Ptcnxdno<(N#^78e|rm35glau)zCJQ*pg}54jhosxRsH?82%k -fl9l>7wY;^6>QZ=jL$v -|6WW8S~9|q?Pw}SzvX`>Q}u!VE^9y>_9FK2^z5J0XZXLTzmI)BKR48D^~!$Y>TV;Ourk*pIo81RV-+6AcCsx% -NO}L6RIg+7gNeBlN+4mb@iYxer@{bC(DK|u4;Qw2hRUPWUunw0SA+z={FL(?f89KS#R0YU40V@@_Nqv -J_4LlPJ4RR_L2Z2fne%#?30VB7Ii)@EFTvGrtK-^R}ZK}IC$UU*!b1JJ;bJRoN8G2UVk~9U44U~B3vT -QHD%gRLgYxXUw87PL@3zQkemzSV<&OzsssF^Jf+|D2nXaM<-s?JOc2!-S~y|gA6hV%k(0prZv$ -w~VeJ_icRwjQ8!@mx5Xf#BH$c}%h{6(3qQS5yV3pFkQ&?rd$4Ft7K`QkppuPsO6BvgXjg0Y$AsSna;- -P>1!w5z=`k`x(=-W-SDT9i%4gr$_5q0s7Z_uvbVEN&@7LTne6meFNZtY)(n%D;gLxQ2cG(HK5}fIxVXcLgc8dq5WA-dH!eRvuETs$RUg1l?FRKy2lKW>PjExj -QrJda>Sg(0LrN6n}z+0Nu2}EC2_2+4m1fwmNhhL_XmG9|#kf_*~Tr_Rq~eVLldsOm*Cq;P#yr+ZHfg_#%Q7;M1bQ@16QR&MZ}^N;9kzs+efl+Ur6Bdfczf7wL2afg%XS;Zv+V(kQ{QRMb0;F*P~(yMi6};wx(o~=!t}9-)m(^bg -|_S{p3-YxK!-=R*uV(84gPS8)MjKrI1y&Ajd?96aM4#cW-b8fVb?*3 -`7z%3?#0!0}B=)rvQ{eQvjsrHcHh}-ih;I&NZ_!ezbJC0dh>dg*o-tbA%(1b(O+Wt`sPD#m#NHl-|Z%;2TU*pobl^{=8TWCw -Mt@C|}l3u(8#?LYwk$nVK_Ax6;oZmEFC`1tc7Qnb}dQ~sW^MFp3+UL*BBlzEUum1B7FVCUZ(7kq3@5> -zN4Fub|tQR1P$$^1iNn&#epri8OKmq;@!VZXCVGIR+i>eZ0Luo~f(sn18#R`cLJ~=Rgb@wqV_cGR5P$ -larE)Lb5u7MU{F1NCsllvtpQkk;Ef9((!sZ>YSx=U4>!xBA3)T?iMhVq(vtiU1%25XpQiS~X1iJ?w%T -L5QR#7Jt?u=^89CKK!*7mBN;qfm8@`cOUn%>FAXC_$EYTJp9^^86d<4qdTiBdIqb+PXnMt6Ct5d76TT -wS%F}K|5nL-&NVo^CkQ9^$II0pcisGwIh*a&2j_URXZUpjgYN?Ji=$T*$k8n=&5LswvaSikr0F_R+vP -arSGmHrL+lGMcC~-B1-&9+z+4E(5H&^QB-}n)A+%)y4v2Wgt#J9kuHV*c-#=dU)T;{F63)*W-lUL?~z -pTIU#p3I5)NRnp5 -hqv0m@^8b4CsP>!`2z>3lpn@iFAKf}ZUjOgJLHLR^5T4g-YXa~x4_CGEx!hp4)sF;TRzT4K~;mF<^Ry -olAb1`DE~?CEZ>7EU3^^exUIR7Lu|r#t%BQ?_pMPcQ~qQ8UtO8`N76u$k5$mTEi(O}>kdr2-Al_CRX4Axp -Cxc~Gj+GU89Pg~)gNirqhZ%U@E9d&H-9pFx2tIU9ww)@aisCP3^L38Rx>yfHTd*Mwkk$qYqxQIRZr>QWLrQDdU_4zDSz!@5PS -Bi``E>pE7IcJk$3k<`iv)`rKqo$Pr^YKmHNm@#`87b>+!JuIRT)WCG?Jr;O3?-2^R;pH@nIIkB+Vd*&B*KcgAO1G^0K0hHYWpbbH?U -P7Sr>!PVB>b^Ng+luz%O7RY`^fLNJrwa>g+0p1^l@K1f34BGD~iUD7P<8?2)!eb6W$np;!|=@mZ9GbV -E*1&)zYiIV5&vdvCNkM|?QC^Z7`8D}mE&(K(488pIyT1L9tp-i-#kscJd!MS@{p746nQxW`@x9n@F=7 -Dl*#xB=?e?Ne>WTl?nx0$ZQ>ZWO)Gjw?zzQCg`6Vtu7;6`RMR!v|-7S?{Y(aaoMuL3t`re^$W?ov}<6 -C}1&_1!YP8rFOsEV1?1T=$`OKH?mdg5p8^@B6&r2CW%~_!eRxIca8qK&Vq&wl^JjqC9!&JnUP#5#DOx -OHWY`GjGv6B;&gH~J9YhM_Ot@i)EF0w+8x0TULNeq{Sm>WazD*?SOrFR!00T-6R$lXKCFy?7GyT}}dzw?X6=7cNY+j?jh-UP8gqZzE15^w -1X7Ub@Cc*BoN1){heOJC;Uy&7;mGVpQZfR+3V{{u*iAx9n<;fZA6DRpfmsFsae(tc~yJEWJyES{UVpK -5-rHl@`0XdW>^fHT=`-=vTnwu#Ri*#%d@OFO;I;Nyh0!)XiXhQ%P@SeS(9FJjw<@$|z1o`)>97QCK5< -Q_1eOZf<;3sn_u_y96_gF$*t#6(2prWAkf%+7LN5O8kJDBy9u^kgDZLUevVnMAAhllnAmv_?1EpjG&I -dV(&IEv*isFy;{GD^_k$PHY8c3v7QuQW!_IPppe|s=4G(9t#8L5`IXe#A21gU18`6rwSx$&yrMl+BFn -LGXiG00ni&FPcXS6hRNm-$>ZJp@P{djqTGDu2Lju@X#(A0v;e&vXURyc%PLv_rCFn#(sY?+^0@Sj$I7~j -AfV^!H%m|wa*P(z{s0STJw^aKtXZBnjB5E8qF^nS1FP#RZ`m(7A#OFp{BCxjl{w&Q?lR5?CW|eJmv)$ -y?1pl>IiC{ov;;swQ(qnCcvAFOF-jv}iyqeJ9R{x}njAedk?Gia|I~%p{qPU*(zON)d>|^NrAkVO6MXa -SZV9*_6`S6`sN(#+%-5>7=9xsgEalgX2X+p5K57>8v*oyI{6J%BlrNI4cblNw(olKxX{hahMF$M}pY_ -RFVFkq^nxA`7@1wh26PmYmo{=mU+Z?N+2uqy_rN&?P;=s7*Yl1(! -Y}~9$X&7BZ`n!F6^jD&{{s_^9WyrVl}X-=@IXlnMWekVu6nP$I^u;bX9rt|Ihf?|^tK;JRORv>T4G3C7C5f4!lxM1WEnLV}EZ!yP6a;>*wt(jr;0F2t%1%j93AdX9jB -Vbj!`4`w$=q4jX3}X`JwLio!Kj05&Ox{bhBhRis1*n~XIf#P-ma(Db`Nb;TA<*Md*C?q=S5FA8>*;2O -e~JJvzo!b?rA5w>1%K1DPRVbZqLw_8qmBS4H7D*9%dcf*hA_G?m2vY8||bc^;Ij)m(46O_c~)m{LTP~639^|b!rDi+;Njzui!XpJ -E^sBwTgF&>t`OzEh(TP-TKBtHha&iO0{~eR89L!>FBVz7Asd^S -7uQT5jDQq|q)|8@dS3Pz~@pjz2`w(dVJmncX -ak0-`D0Kz^_BC9h4K<1s4- -O+2jF=cRed2MXCk}*T6g2S+7b}iaSuOBpD)zf|ljWY(^1eB+nuDeSsf_GVLq-L)dQ`l;17ovm8B}jXy -zAQ{+e9@2MZ^NM7@76zoOVd4W{@1|5O-eyH=Wz)NpIBqj-2)G9X?T`!?on}3W^zdFuK%J1;EaCbU4w3DhbJ{cDuVL?aC$?-Vr>u5{ON(VG5*X^!CSrsSL{Sh$h9QRJECsWBnV}mdL{1Nm&K_{ -&Ij&}O7vvOM0Ug}$ND*b9!&Gl)kq5GsF<`-I^Hj}O)K?7UZ`MGp96!*Rj#C3Y4l?{VO9MaO|$57Vxn^cVsyIJv`dW#L!3uhIW8&D+-22XH -DKf3*^Sm=tL6sIhll82Uqxhsgc9NbV<%5hnBBHerq2LJfb7Sk7mS*Yb8H^$bt>u{Mf}1BZvXR*XYv2^j!J7lTrhh=zpH_K4aPU&9 ->LHApikdLG6?B2EGQLwa-OHeU3;{ckTYoWssy3P5B&-qgLSl3P`j;}ge(F|3bCj~Qr`D8mpBP!?~$=+ -9BgLW&Bz6Q{9QScO~CB(eDFB5Z3CS*&(%-Tg`BP**A#pQ4Sei-Fa<^572!4-csQG$}8=DAWF+u1J8y> -1aLfpzU|FuAUL9u%F=qA!z-gvKG{UdnXubSnLx5q<_sAqrDy)V&bF2ny5HWc;I017bms*5M%N%cue*f -d06ojRUc|>LL-S5)e#*8qrZHLRr%_{6qW39`G<@S^%pQf;tjLW52|rX_n_n#2<}6@; -_d9K2EE!*9-QE#=b%#rhJZNZM?D=+AtO7g*AULyd1N*;ZY!27TcoAh{xr7(%3uMM*9Gu -T5M&G59DT^+OFndx={N;!)YRP>{y&0`l%l}olU7GpjY!%ldUOzNK}Skzug=UL5aarSZdhu>T0oT)I6b -whoYsUJ@1~vG2otspC*82OSc!gOr`ApjWqoEVSFSYa -UmU3yvTD2M@7cXGf>|_yOT&Q*D({aIE##So9!`!>Ie -sasRgLi_eo43BU1!SN^`Ao8*AS<66`h|m35W)#3qHgaFp;581B|yZ2jQV#c^ocC@6@$w3A>|+BbNO0! -99qsbk(hWe`3gN>#-m0Tp44~i-&f2#F?2!JxNm-q}~rjosv*|(dX9uEQ0{Ul3GR$eupPEIvh@KPpW?4 -NHP5B!Nb{R%euWgeKyj3osPlU;~1yE6uZbF@Z+?g;o`xLPojq7n2R2qz@K~ij0O@vZ-ze=;T}8EHgM+ -m=vWJeqfeJ9NRd$wj9iUA+AYij;1*_(IXL%S8;GW$+KW?x+Y1Q>A0Ct91aE|}`ev?jxls(=RPAG+%G&OwIQ -!>k&z$r*BX-~4$6vk}TqO@hU(Z*}hQX(@6(4PCo)wlnCB~pq%c8l8l23Z!`d*K_n8-Kz(2kR?ng!7uhbq4P -6v;IGcxpuFcF+nA8h;}h;$>Gyzm%z9GEbHsf%%O)`JjA=sK -re?B22D<{9nd^?*oaR{ovoCtpKXOoeGMEH&=zPsVQq-+? -u{erWx2JWzal&l(aTZ{|DBmWX7UZujwM4q)A{DsG$G2l^L*f=wX2cL-1Y-FYnzgX -Fabls4pxQMvI$k4tpqs+DJ9G|$#F|7GgkY4y5rVVS^<5Wq(ZpIAPoQmj!4w3X`nqk&jvSHL$SD>k$)45qn6_9Pw@ -3mYPDLa6Neb0L>=Qq^pH1=veaQm9iA4SDIXa1T -#Sv}($Hh4-9~2=rr`}y77NW4K!xdimAZ$ixAjde6C3g)s5tA~=?+KZQA%Xs -eu$vh;HvvDsZpVtOFniH^&C`V`e@K|k<(v{pI)93A!OIUVvGor{rSEs-?0RP+U2R -yI@F(y_)iyPLs+xcH}M&mwjHdNdpYhLe)^fU5c&lilvyBwSG#MQcR1mMtP%)E^}$g2HSYtxlSO6GbPm -ajrd3g3d{E&^IkZl!PrCw{$}S!A`~ZCWUlnyrWqF%MJ*t>52s;Gx4D+G4;r;c2#EL`Bpi1lNpoQs-st!{z6}oMyU?Hh+0=J(u6 -5mBI+C4Y<)~yM%ZuPU91-2eS*!RzUaB}dt!agpqtJ`1ZL$S{URY;8pDwOB0gf{*ovg(jT -dtb8>Yx0Z5wq{_~=<}sK2Ot*3gflTx?LzOJ%CS>MPu$z<9?P4L=J4`0VEJo<9stU$|yFLCKw@zfICPHa~o%EZhBT#fuFgkys -U^lw|mc7qH+Gb7@&*SPkTs?+IoyCYeiE<1L@x}qAFr)fo$0Jb%qxkC=k8kO3PO2&9w0B-`&B+(srZ_~)tXkrKXn1a{#G;-^7@Bp7MF -{D*Lx;|qC?_brPBPT#lA1CJ$zlwjPEIc|KjyDO`(#gJ$?5|pGFyr_z&N6>Ybg2o}8Xh+O@I77x6BHd# -q}k_&M+%Q@k43d@s;OyZN)10G_aKKZ_D9bNkt&Lj`w<2(Ui~+32>ruvo=l&f|Les!|CT;_JpbvZzs#= -n-cawRvj)RnUpK=MOvN9v8_H&iX;oAtTT4EF<>AF_TA=Nc$~h&(i!uDK-PE@&Zjf84Q9HGlGUaqYe3L -~ZZ<|7=GJAaXOe7rOhy)J0ax=rsNOs)oW8^TLGzk5x*|I~WBekiVc -FdJ)U4L?6_4!_hu0DU)SBdh8F-Ck7bhY1}=*~?2`VZkcU1RD${07wVSFd@8`D@HdYb>{1VzWiSx=Ia5(Jn*9 -5e)WcmgLa7fK<2)t3oqt_>TTbQuCM&~eZoEz%Et4THn_#VBWK%$oDtNPU)yc0;@o|1@F8ouf+IVUFkvxO*PU3~z{_u#{9E`?xu$p1sIr-NWAg~mgGDshl9G -P~dQ+jhhNS8+%p%@?&Wqkw{m{de%Lo|`zKG0uh)(z5=UwmJ2g#pLCYL65bt#{h-=`r>5(QmGy$VGDX- -@m#3?PNTRNehlYdN;t%Yf;C}goBL7&sf8EET%{x#;G18=!(X}ecx#9viA==3ihdT4Wfe2#d{Fp6JKYfilt?5-D`onKO3D}eUvR)yb-b+sZ@)+&m#0qQW?cxm<&+SF2;f^;L(YlW^ -rLcEZ{TdFquc=E5gLg)>}S-f8t(H&arSgT_c6@wbr*Wo!!xK>bMo9o`GlSi|JK5A54_l-J+_3-WobXs -H&lB$*P*3oZxlcT#y_D(AC3~Z=vE0irqcItg_sJsk(T(Iw&u#E>D| -!xCoJG$njxysc-s(V(utHSSM3dp6~4*2^Ut#vfSq~(RX`g&02P@R$s|mj2OoPdZ|v;?iD*v)Q~x^zjX -sOYwL8Qn{^J1n;3eH$LbV3JCkgQkgv|+A06wd%7`c8)Nw|R&+&{o^v2vYaO;xyBk`lk4NaH2$*FzGb6 -&K|tjT%%kyj*8CAB4%=pcd>)MfV1fL -S9UA{=~H{8-$zJVI1#3*g@tP@oS35vsqQo0OpJ -jy3y@K(YuJPi-(bw^3}tsmGWXN-cmj;N`yvWl@#GstnN%mVeIy$#HY`KTZ!8acx?%spu9Lk1YGKbvbM -0}mnh}*>5rZ|by8UtbuY3V-I9zqFJ>(#7vv)me0t>Mp)^$`{;?f*L%ShDGT+&q$ih5C7(JKO<>DnI3@61Bz{{v7<0|XQR000O82?eW2b -W!e_lL!C+02TlM5C8xGaA|NaY;R*>bZKvHb1rasy;$Fm+qe;a*I&VUA0($leQ@87fa;KaQMwP!=WaV$y=3kD^+FKVJEa;2f<3E?%76ZmaA$ -bw`Rsip;-9Q5aXIe@bg^ckQc@R&>ut6M4 -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 -MKvcn3;5{7cBj+*l3FV}Kar( -d4KmBjyGv4FEiI6y>qWkla+L!%mk`7_E637Ise%T?@MfBLsAsy2Fai93p$MIbe}}j&j^)C6OBb&4tr^p&$ll*20?-&I2kNuLulvR4&6Lx*06hoz-=BpyP22$apUJ` -PV=eHDNl^a8_N}$G}X1bQ13uWvq_kI?FNq8f1i3^+f-{65xfk1Y)7ODBRz -{V`%kKMPNo;tk3Erlsz$oiC$z!e6{uox#qO`D2z7l9z -6yn`2aLI=XC!2oA6w1s$#7EM_QjM5lFfu#ra7|WP=&m*YrEozu_B(xjBr~=;RwK#@w2@5Q+NX0G|7P+ -`O7)8x*K|hpXqd_^ZwjIyeGKwfqnl$UVAMr*Mu2gsfC7SsRuoq(HIaU%9YB;fw1pjSfFpgx6uJFAf=SkF{PNlNC(PU5qk825GDnABM -d;*lLI?w2Mdziab2v^%_rbn}(g;Ve;S^A+J{|PGRe!)W-rn?wX-bdAh1DQ1jIfJ`Nspu}YJG@B)<*%} -bvwaxmK@JlJk@KOs_u|)_3Ggb3RS(x<3pE(K~yX>QHTsJD87=8!UdKvx+b2sGkwwrQ&Zu6<+N -1J5(c^(>vud0l(Wi8hxWWN#D9xeNNPf$z=PD3)MP?ah|ySTuKeIa-%l8F)3Abg6THY>p6}+p{@U_?5M -`0MAa|=ZL~G3Ix5BQ98}Kzr&;IEKyh}Ay@<5UI*x7PD%~}&ruYnThMLRQ&cOlhF>L-#XAiJG@*fZ+FJ -%dYx`Gt)Yz!C_LoiQo(I2yrS3jQj)2>dmF7w)9Z8~FOl*;s|+J%%GOJSr;=>yaV;3ELe#_+n-YIq>pz -M-`SPu1DT$wOZZw8K?Jd(xYBeLUOm2trAtMaH6|z>iS!rqT(jZdn>uIta&IRYSmxOP;N|ET5K`Vdr+X -kADUR+tMau5Q&_6(J -hy0mD0ylR$9`5EPhr-AYud;$f9-*b5N_LVp{VDUtTPpbCF!lJ%C)2`2k@5pt?ZLq371o -#}q>`uYFG^PhUD}7$?%5+0aCnK2I=ITwj&G_DVxRhCEr&c&g9!+V+b%=zDc_&OACcY(M>>9u{s7h7JX -s7T>gQfJ%}MYe(`qlQAf6wpHE69Yy$O@=|QZJ;g`h8oXpYga8Tg*~G^u#t60Ri^IF+iDbXs* -r&tUtot2xrAcb7t|R*itCe=L~BmQqC?tp0Y)wI{gyDC#kdlRn_t2pD4tv?1FK)zG-E>=c^UO^WG++iU -g(^QJGiO%w!9lLF&04h4@bx_31Ex0@qfW<9|E1;|!1MYK0#<%Si8B8=YiJ5VuWakl*0>PP-wNX@152O -&`!7Ug)^=X~{QR#Uo;xuN4fD$)x=mQd)U3$W{HpfKY|Dt#LYur4QujJ8sLaaH5@4sl4I;P)h>@6aWAK -2mlEMt4Q0dclLb%005i-000vJ003}la4&OoVRUtKUt@1%WpgfYc|D6k3c@fDMfY=x96?*RE@~4oSZ5^ -3#O0vSf?$cn4S|0*q3bH+uxjEWsydIC7=&WByvXj{(>mnf8BoToCg -NGK6b~p6uR>b8HbncXf4jb#+yBbzw9b9bLDZO<6U;qO5|fsGBs;vtk*f#XOj{Sw6>`W~GAB -cBNE43Z_|+R=Z%6&hFBs!fNZZiH}AAYEhNzAW0T&(^e`;f^1EY(`jAiZKD#tKRS}^VC-uG -lsj8|d_06I!W=&bs6jj^=)1*mw>0fEo=KL-lWY+-&RWhs#k53nRcPqjrqMZwHvkGEC54&zfG -$GnyHVtr_;1n-+!lj*;aX-E>tr8{ySC7%DLh~YMo{|AW_w-(nUYgoj}=Sn>e{w#k{QII(yJ%uA2&)y8 -K|M1?6l7BwgC*vg%<2!_^{8Q(|6ji@Z$dx(t#bY03o9oCIxw!-v0Z1@$mfn1G(2|gD!vy$RBk%gU?v(y7U&*}yTpib)9N@4Afmmia#F5X^zx=7x=y86$H -tK`k)+l%+F-dz|lrYmam-HYEgDGY>Zfq?ml&j9PBZl{~7oT<8=1PTxYzMN;!g;}~mCY>jBGcVg_5>)G -^Qh-OL(s?plwZ&aMIf^P=P@F4eh|u%j-T6?0WI2|VoBE -P&*U9xCFW>bU1?dO;AJ?-g+cc)**L3?Xy>oGrI)-|8%cN3u*;YW6K4hNPfW1%BSz44u1|-dXQ3;OsAo -IrS@?P0GWwFSX8)5b%k{^kBXoB-#9B)_o_~__Q7gyJpAKoXgKfL?+>eJ$Fz&(u$Qf`K8#Yd*Z8PFnKTVQ9wC%~#I%LY+HAuBaH75pa8f>%U -$WUGhx4ZVBKuLACQ(3C-*t+U1e!ixlV2E#K|$ehUN0S)3n!$=GL8z~Sc25A@$D{n&cI0~|b?O6asR>A -wSP#rLSR{)m(zzzU`nkPg^83b6D?k0NSq49U9U%^zW3cs4y{Z2PDxFNJUjR1=gOK@E%nqbwi1In@+{k ->v*QV?|&3`q$~CX7lAKZMwG&5Kz1Eo{=_s(V9wAcke*`P)#FbTFQ01amv%DgPbBs!XO4Gh~T2jn(uR=PDQ540&t=JU45{?@8 -ExQLf=5T9xVWt}Zo4P!Ace}Ods=bE1M*qmb3r?DyzflD>2SpHf3ERJI=0NnyCu^3m7v6Lx~qcNVtN;3 -!I1<++sN@ySe`xst07Bq4g95L46zOMdA -rJHM9>;Yo9CL!#A&hNX8LiAgT74*k}XRGiyjHO0@WUTgzO4EfW8QnJGGnG@zgc{+SZN0tJI0`c<}k^E -%bzW9>&2Lu@a{MJX33$Cs=qHm -rLr(6R00)Dn*9L~2RMvm88vBMyKk%CDEVeMZHR2y!eh75QdUTo7FB|k;Q6Q7V@+bShbq9Ro3iKfNWqYYfw@EZq93;eUFSO;INB``V92!07 -W4WCsGXf{ahOXG50(a>cy;mhfx%-)WbYms=5iI0WL1!g&@b$4|`viFy!!ZQ{fyXE+lIRo{=DE$_;e$U -getaoVr^Q*IO-es -qh%6krJJU@x8zJ@1t$t~+jxfzC*Wwk009a81)U4}#dyjhD^?YAsIv^RiCB*&!8e*`k9uo)Zv{TM_MYi -ki8IZL!S1q;kld0a`3@DLxtG<9(^rAR($J}FkQc{@~WK$#JWE=j)(*Q}6;|1=5A-KU=}uN;+ds0Z{c&1$6wV4lrkC8NoAtLf{Yi_JO6Ti{;zOcwzR*&(5=6 -PZ%d!y)V02ZiI76|Hi+k3&R2V`F89oxUCiKayHPI-(J&z>GUs>59{|nypS%GdtXrC5c|ZLa*6`I4%L* -EQ}@&X`;xVsT#^b?$<5V062_96vBFQ?u!^?l8ICP=GpDd^V`T3Np>#0MUHg59rQcWqNq|RJn0!I!}+p -2AM`S%wD}M#1Cy&_2{4J!gfA(2W<4s@Hbl^owgpzsVr5>^8-@h0pV2 -KXw&+<91jp5M9N|=0STbUxgekJ7yaKDZ-5`D4cf5Y0#m;0p9|uqSt} -bh7tzxs{;%yYq6%`nx1F3jEj^e6rs%#UE&&S>XhFs4OSH_FnEGTGZ^?k@{(;DE}b)nwXu_)@mnu`*6B -lvyrt?hb&lk)HXH1{C$ushQ+OC7LNyJg2PM!=f-f>tDjg+5Y@g -=u=;~uGgSc(@>~;8&$O&Ys4k8NIg-2NbJ!xQ&@ENqQpWFNWKet(`m82m!c>UC90P>`TMMUSO_6bjgB` --ZN%LBHFV%yRRTNnQ?q+S1g2k!zL3|3j40aNJf<-~kVcv#Ow6_${*>1)P#x7q10JRIEXzhdkc -wxGLj{KdJ)&+85LG~IK~Gnq!7bWf2Aj68E6X{+kFe^}EJ-Tt9ca|zgAXgY=GX8`FBNS*y`927{%|=fSdMzl1aqdmzb$xG3{EnG<*iBSn)k9Gw8hqhw@sE;2%~1xI=5V2T&$PcxL -~E~R677$N{mHs}JEMa`Rj#mlN}K`sYpZ|Dq@pc>$Z*??X!|m*PvWi -Z>2wpNDXs+g9*j1sCG&)>PH(TM>9`l@{W$B -A3`Y-335){DWA6OeT#mxO{&}qP>pEX_Gd%0o9P{i|90^HrtWzuu!IRy;BGCAfs(NXciQt4X!K5tJR!r -cDA<3s531IEN-u(eHcQK?y<4tf|Vl$Hqe_>&e*$r*+iV_~DuS)kRTAFE$}Ir`suuy3Cc;AC_%qZ_wYpLH|usgJB -PV!m(74^}cr3FAHm)&6*Hul2>b>PP7j?v_1rxk?KJ8%6f{Hv^T+eGMRAKhArWQ*g*twrT%86@dRBl)j -7?Kq+k6B6S5b9g>)cCyBt<9r4kl{WmOat5fNGNR#bDh2uyy|m(Ab|AHn)%YZ3k~2U{555-}> -~1pp`vKfX>Ja2p*Wt1eTbjtQZCTGc3NZk{jA(%%GrG4)mwH -C=Agcy<6qBA1R-j!1t9kz?Q-QtErfbQ^!PU{jhfvC{T}%)GQhO=+JZxUf9E`(x0Q~15ZD^9k?%4|ovw -*gu&CBbTS0D%H^yc6nx_T1*@S#BR|F;5-WY@AUzW1aOx><({xZ|6qqBE5xI@-2>Gyyxp(7jO8EPVlzU -_J#sJt!;h&HM+MaS8)C@VGYs{=O9L#vy}h9NT_`Tu5GIQ*^6R71Zl7~Ta2Lvq?KL+$}8^01J#=;gIrh -R{4lMXbWQnt>?kukxCWVOqK6jMC?@IVex3V+VFc?4Fnb$GSLD6XI9?1r}ho$h0u?cZT1kn*bd!*}c-{ -88$`k_RNW}@LtC5HY})t;u~@M$t@dkVl7#Ax -5r&DFedroFPh(z>KPtZOF5zNa0vy?0T@v?Cn1U|ya1B|p5*EUX^aaPRocCN-mIJ@JH?`iR>+bN2^iA* -onU^c5LJ)hE^nlROOP2OU4m7*Bw|K$&xd=iBt-Ur!? -ct;?S2W8HR60hroFWK7SBf`PiP0DAH>OQ1Pzyk{z#XKQ2O8G)l;ZT&&o%o3d -XeZm|E*LPq}wFPbrqFRE*hPa!h6R|S?3>SOoyq6zEV)rH&1s3(Ysio;7lX0Ntu@loV!>A-&TYy>f^p_ -p=AYC-FH%kq0hsd0qg7!85k1Smj#CBI-D@SOh?y$l7-<{uREsxQ<9RSuAX32^Blh&8_G@t`8{;YU+xZ -Lch3n8*Ks-6>iHbD~`f4qo9c_MdFfUt+UC-EmKN0cBSq=vU^i@VvOX>UP_uima6CO%YS=Zix7rFMweZ -4bd}wa$k{&0@^Y8gy+kz_m+aE1TJC3M$eeU|4($&<8*n2-xtfX;CqzmZ?^MBsfYo)?Mx^J%a=DS_35Z -GHvR$njy^E3OA>qeF{^+c%lJNvaHus5tw+*Ys(sc4WQfbM8R-gjqBh#1!i^HG!?9^lVH>$u+g5G4AAJ -kqdkER3N0jr4J?FoLkLBK)VN&GHaoElbksVgCme`Ht{E)h7(P^b0G8v`9_kh#o6a!S)eaOq`Z@XVpL= -@ubp*o;Y@T&i*A&Bh9_(MAgdDgR@(&8Dj<1u;pFdw;xwn6~`sm(%`1Hp?c*B?gy0aBD;Eafa+`tf6i> -$wCAcP|wbdM-^Oiw7}D7f8{Ut=N&H0@vNXvU(KPXac8sEjPxcDWussfX5Sb|o&#wwRBCC#WN6JM#}p! -d%n>EaEx)D+BgdvS(qF3@tLcNzgc9Np`)Qar4lKo#%R!06^C*E1(@~0CX{1AQ5vq?0995gNu(7_=np# -G~q8kp2!{th!zx?+RnG8wJV5^-A+Pf434w0)~-cEdX!ec91Rc}?kc6^q?auw{Bv`5^4+a9Qu6)KEbd< -c^ZB!{0y+X{X;*4JhGMM_mv;zwN!GkWwG&HfA%54q4lTIM_!-&J?K}rY5q2Qs3|0nI6yb$Y_MN?MoN| -Dh7f38>SobN~Hmr2|n>2fNNTlJeraai9@QOB!a5G$7s{{Kf0>Ezj4iu3N49*3wxN*=O?uE8*`-Z4>5O -RrIuf(uvF1}bc%;|q)ne!hRKU}%68_ubNknMQwlhkCob)pwZ`zdq7MKqmJTR`7`ql#EcUMm%>nr2gcU2vaPS&Dl%#6p79)P!L3R#2L+ -OB1?UD6L~r7~iy43q89f;u=?4c;??o5GrZ$a?S4W1M -zyUl792yoW=Rc(4i3TNuXOZU}@UyJw)7jeLr*L|P#*V%dlQf$Ye$LvI93+jQj!F1M`tub67QjAKMg7p -?w(sHZgOOfm#$U#j8xdN-~><%}+X8$7g-<|$T)xJG7YgvNJ9iy5wU|obXn;nUQVQcs(uG(3^3I{hB^+8#LG%Lly>xChMJq5&(7(@+KoDM+3O_91GD -plDOlx3*2YS#I&EC45(0TDg2R0e>cu7R -MCj$o@clwy(laMP~5dT~a796&<;EkclKw}Yl76ceY+oR7E-=g}7hidqo*T~>8^0aE0eE{=C{C_ae`=gEGIu76&wfAlOt -$i23-6T-WEDnC8lM4zc;K4(&Jq4ziQ!tCbvO8zS#D<>oIYPEgsZ*tnmXR2l+S#>2O;Vtx!8ayi^c(l+ -i{MD{HZD#SRyGB&RS7d5X>JHsp-Dm#o^5_!v&i=W_RmcDegSsQkf4H}pn?EXO*OE(ovobVb}``d3G7F -qReeQ{KPEv5F@y3}p`=p&hJ;OAv@+Z(`_!WzMuh9-#?C-GHpvkUr=t(#9a6{DenASb6(Nd-i(m9=`;}an`Sym~>hs%AP8U8os_sa$F0|`uFPk$ZtI&Y4Pl(=U`%-4xnh1C@!rIDS_arei8?G-db1 -d2g}TR5!v;QzElyAD-$Z%TW({~r!uHUSkW3Vs8ck{yK6UgVyjk@ch=qrT~ck>8v>znwTJNB=U;U(?pN -d)oSnc63qTFqJE@X8FXVm7h2WJ&&Je4uu1B!vf~cb=qLyl8#%g5dZMYJp2-WnMW^K#k-5FC&AQake%2 -+m20RscM{3uFzX)vbc=Kub)fqH7RS1vg>?s7`ycazH(L*K#z5=;`z+%1dXQH8OcBz;HXZ+2U*LwyRAxKut6F5htHS3HX&93Y1LM*&}@c5A5uLEeh8kO?WcRkr>X}bS}T{=D^H_t!4w=VWF91DAk}7gw_HfL3wxYwO0i>~Vb?>+C~v4r7>Sw1we_81K1+EKgBLeklaH?3?0dbPvAy*@+F*VGPcD -pM*}RB~DZw$mA+eyajcb8XREelJV61Wc0pP -$_KDgomvvd%8{0J!%<0riDW&IJR2wRW;Z)>pqcPj4c0_vimVCV07#XBxr$ylj-SvkCun-J1cRCgTCd@ -hWyNfYOB$8W8sm@9=(TEy=26hV`dD+)5DCCe5&%V~*%N~5pBEd>HueUf;5lw`K4jIb%~PFhi-*mZSGq -XS84a04d%_YrSQr`!9ofTYS~^)48agLU5OvyzzEIj3y1VK!ACR&;-~CPMIlx*JPGjs1r<0;yK?<@whq -5FiN(sv*Ke_1VQs5kPYT^yWFw6f&3GOl5!B;0|+lpXyl+{6updrtjk`X!gtu0N$?I^;Bllh7WBW#vlmc)1Wv;R1 -mcZOlV_3>HA$YUipQp#L-LFhk{uiqbet`-d!dFNY&jgJJ7)Qakm$DK8%G#oMf)U_;K~*aC`Bso!AdsN -Cw|IO^Us|T6RW-%qCZMnPdpPzD&;z)7$M4*%kOK)JnLkmfaJC>>om!{6siHt)?*y -3a1JWrqbkQStu4qqA_KwxtF+O;{-aTIb%}a;qA)mabO+EecB|X~gu)L|9PBVLh`^9u;;C#V+oBwc*gnWadO~s58ta1Tf@UgH%;Pzqx$x*Nt|o(@tKyj4I -aGZ(`l65TKmZ2I1#M9H_*6{8i(gc85T89|-VbE)UVl@cAI!uA#ityqyh0CbHF{*}8Q42hh&qptezE -niV_kzTb#@zp;OCJLbtgKAkE3>I?{&wFA^jgiHfjg+$n1+#omij`7G9*v*=O2$ILaLW#*gqhqL7(w$dtvdQpxAi -hP>gdE+N+V3S$LJ_Q_qBM_N0lu$vZNlCzpI{{WA^y0m -C50$a$x)W{HaKP5dHMgANYcA$Sz@-}Cd8K!kg9>HBl#12Dn%U~0aK@;dEQkO=()+h%c{y1hs@!YJ312 -%C62Ca(FE|q1r!KgO*l>`;VtKSU;cz;UQW~8E9E(;_~4=20I46|ldc3R<`SQbKm3=H*H5zib>PI3A%Z -hS+eFG85MnIS0n#o6c_*3a`;|SKVD=^N9{A(t*012x2hSh)54egQ8v$4-J313dX3B>vINA{zWPs6`6; -Lj4Mm#66H?tq-+BIvtPR2<|`NY`(7_nrr@8)!pY@a+qUnlzJ^|uJ(_1zqQ(;of_L3J0>URpwoTc?3>I -#1muz+R8-&w=AYJ;3^MawU419SJ^$OCG2-LIGt7I>YVE>gQkZH;w%?Z4=N>+YzdTvyR2r`4|EpZsJcTBpZ8Z@HTi#OM5 -sV89aMtPzdyALudX|H?f1-`sj6>NNeTOCM%M)s%vz9oCo8pwkSy3)?uVmN)aaZ1Be9)0nmrLCj;r?^! -Fx07dR*O;1`{Qk8a|O@lVe2!Y)tCpt_Zrf2G5=^G}1Dv4c73@pBE~Hu%zG>Zb#u{irLUEZK*XY7Zdw6 -x5;7$qCmyftn+C^ltqo28CDW`yinOJ_%;q`MJwwB#s6rtb5g~ax3{(bV4H;m~;6%bZXGgb*8U_t!9uK0vMgTyJu8!S+V8;n1G(jV(VvFZ)$(2x!e6Sa*rVD$i%sYixtc$lcVj8NJ(r5^sF1U0Un+y7(NP39x>&e4+@yCB9M|SPDr1pInR=(58*Zlz=B3hs&aR8(fJgLQL!Weq~10Z)tsS9@Nd2wR;J`IZC -6u;MnjiAavH$bh+Pj0lY=|XA=DVh(^M@+j>8BAJ-xfD3UjBG7q#q0?wAWWp5gmrE0b0@cRiL^ -Dk@4z)-BH4>$$4AJpA2(~q49<+Ww#$b#phnf!B3^CQAe -ln{XC;jUFe;#6FG!xI@@!5_u<)$AL!bpPoorNuubVS5;oFqNElX%KI&7ag&N4Gp|8E^X@nZx?*i=q(7 --{J$dPJs=Zgdf>-E2aVEBDc9t*s_Y8v7-`l_j@KbF-$tmwhwDGP9L#?B=?C>|YBsZ2Nbje-{4IwH_lm -P9V;LW7?>um^DQ;_m{?oz>E=j(e##{?XGF*||I`^ITEvgfMf>283VJByhUXL~z#~jrgs%U#EYO-e5nN -*G)jkW<=f2_=|pgi|&(_cHVAoC+=#8*Fehy63W^i14sXb>W>R24~*lp#3RtOP;;0SJR7WTJqtnq8yj7 -9WMX9CFo*5+*Wl%3SbEHzaqzPo-K(?ZJNj|R-Vk>ea?a>+n@TQY76%7rl*bOQZlsc -N^9)ne~tB5IN+z)%)N4`t)5k_;IcKxD*@u(K1;r@A3>`@|q)x5xqzD#gt5*VzZJlF#B7M5m=SG$+E1y -3*D&mG5f_LBl1@{&%=mhEyyA8u-M`aTt@ggRFMXNx=lZLNTc1F6Vt3Ht6Dh5$@eUT$S~wI;tuS>Xq9B -~=GyK07%8NPk%>Lk}LULg`#SYPY|A(N?XNWt6W@igV8k`sn298nydB!g^@sB}lZ5{)+6Y{TU3p*9iM= -2?weX<#7U{tkddF4f!?nWlpp>-_SEAsNJ+fWAvaJ4Hgt;zc%BTFswbQBDY*iMkQmOO9rNN2{sdSLWef -o73H=#jDj5FE~`7A^#SSucI@y1>%%;8b@=C3sAgS@k^$^p2^iha^z{(gNGA|l-R9tar3eYV7m$8(#_ -hWh7{j>>swpdr?51iJ9W{zu!zomsAa&lXt&xSlZ`fpH60|XQR000O82?eW2nYvru3kCoHClUYv7XSbN -aA|NaUv_0~WN&gWUtei%X>?y-E^v9RS51%GHW0n*R}jjfcIs%87HNS1IW*V}8X)MQAIKpHw6rvqn59I -Oq}FkR{P(^gCEHqi(`Zp2)|SZm;F~vZxM`Xuo$9jumfF%-p`6&@D?X{V5+!x`O>Lpb39}K{EW8n&CG7 -E{B`SGae4WJI!nDGfhJ4LS^vRQLrOuM%wbJmSTBk}Hob4mK+Vr{3wf0&*8H()(g3IM)#ib_?#re`%0Vj_}jRwlfE7w- -OeS+BXMcAZ44lgqry+M?!B>2Y-?52qL#M=+}3?tYNTg+J4JCghSbi$8v?)vP&sicbx(X8fZ%OUnfQ}m -Zf9Ko)Ty(1A|cS5Th5~)+4(!r&?l}bl2U4TBhyn6N5Z$Sf4;rUNLXXyORKI|f9$Om4&NCZCPO3v>9Q~LSom+bS -iFKGsx8|i@yujKhk9N8wp*4tfl!k>I-*F0ZLe6B*@g2Ia>v$hz2L8bJ|r^{eZp9<={coQ64S!?XU?@L{8dOq~V5_-L##6+t7w|Eky0j<^LE$dp>nGz_N4vhY2?-95hJ4O$Ta9`(MZ*cAjB -J!&&nRRuYW>Na}2TLpEC-MwRFB%IO?sWjxAtXN`V*4v=|VMrK6vQVNk1@+Wslef%)1HquxQ%bR*4jI3 -nbjAm?+wtOo%^xNX*3yCBHOUeN6AzhnomPI4X6Z^iTTa80M@8-8I0Ae65M8bA3mxSzkeLZ&AbT+ON_;}+w%qrxH*;5`5Zw50A?3$Wkx(x1O2M>tu0ZB&U@zeVk%rw@=jQ{K>-$w}2 -Vjp%T(#ISzT`tAkTm^9Bi(5%xcGi$hwG?N8-;zRn{KKc69)JeXY%W;sjzywUgt<0eHDvnP=fVR_{smA=0|XQR000O8 -2?eW2?KO!1Iy(RWAUFX47ytkOaA|NaUv_0~WN&gWX>eg=WO8M5b1ras?R|T98@aLP|N0ad>3T@o6h%@ -pEi0C?j>k@P&cvQO<0QN5b$FU&Q|g%P=5#mp$Y$?n->Sm<(aokLdpx}lGot^k*8m~(BHd@B7^E}IWtyNSMJ`La$YKyK_)U?aOpY$z|gI= -@AZ3zr~!@p1FY|{Ea{nm&6M|OJ3`X`L-i6LW$tcYPi%4Awl{aHL$l!?f=%;+!UD-doQ{?_5m<0p?{EK -+Q2Cteis$(<>D;6u|}r>kiCsXb|Z^M_WKO*+W&9F%l|N(MDQhQ*Dlp_>3{z?VJsWg!LlvgBHCO3~!jC -xzVNf4eMBXGxTTb{fnJS*xE=xUETY(K5P+-1ODIsV`TeGe}qyGYBR{k``r@PUH6Es_QFO$Ew8JXybog -ykN&2_5_-61q{E!#4tN62Bkn11x`Rq^;f#%r)W5!E{hPV-k-d|1SM&EaE6Hh!O#(c)bW3xASc{5iQv(snlaM?95Qcswo^Sy|9h2(lAn_@{W -W0)3?M!m3x$=khXU8T^qm>Q$Z;uzqAvjNE~Oq3IOW$Kyo_6mc0JkK>=mWAXjpVisLx$&4?pBV*vVQ#Y8fF{}1^n6*E%7p@-QcCbOR^hs%mvpeyNEjhQkZf?&EyY<;6D9ZR -QGQ|W?goV8uGhGg}mOOi9~ks*I-X;WF|^rRg=++>WnxpJn9k$R{7Io%0|5 -di4G4w|{*9Cusg~i2oj`f5RfFEY?fD3;1iaSaV0Mt^X=ILi-f@meJSH9BwCQr-4>VY$STLZxCeP#^o| -9KHZ0G%1H>aDC%T27Hze~e>JK1(I?;}g{=*)(O}ySU{NHKD1-yQaZ8L#KGGM)jG-^p@s1vcz3WI2vt<(NApppMuh3yt} -Gi#=jWXgj($MmM(R9B6u;o~b|`Z45-rLdzDX>9jn3pReOz-ZBX8Hd+Q5dHE#`!&}aS91Hc&Ee(SZ#`a -dB$KK_Vh>!X8Svk>*wK(a^4)dm8$AE!yX5?;g`yP12?l`|5~?OS3UX?Bba}xqpmT -@I_1$TGnQ;@*FWRnCm!cG{ -;QGmB#Tbo=NE-Wp-*F7plg3M7rHN5VGKh9Q1RCow4C&KW_J33X4sa#teusV0f977%_q2dz9~*4wNxA_ -?;BTJV|FVh9bh`%6rhlcmt(iNh{)?*D;>v5A3a`xAxk$Yx9H{Tn+OBIx?5*wXEeFmRm_<2wASGHu-EUw|5}}{BGK#hSnVfBVsHpVI-TZJ7~7j= -Q&=6!6ZYioyC47j^854u{d)YjKb^mS{nHOGU%ghOKA8J>&GW(VDX`6gKMsb+2ZsZ*R~6+B`v-@^R_9| -#y-EBh#z@ozO@*x?8TYIS%n67AY*@c?Z$)`Fm^ma=UX3!@-}ceJMjfS1wml^`&zNDb7d0|5l#u2j#S@ -fR1iKvc%56p5zCLjtEn_B9fiw-n`3%uDsF5=Yv@R&QDd)Rc)pn38(VCQn$!l4`@@Y(b?sP6fwMKrekzLEr(=d5or=Qa7 -n!-YYs^W1)i!spTjgDba9D@%(Pi|mc(JlvWGjt)b=%Yx)57M!q?|DvwuJ__p6*bmp(Jn4(HC}?E(v`f -LcGLI-mPEzhzQGK5J{?*bH77B5WZHqopd>MBEFRp|fl9V5=EP6g0F$j!wRD?`Ab1(zkfKla^84?{@7} -)t$u=hwP^eGZeOfG{RiPHjxZvSG*dc`6;qS9-g~78tTVGt7C!RPT1ugVW!BL&A^BlK@(2>a^n||_EZA -0Y-wx(L^+^A)o5U+++B}YeY(Zi~uxIQ{%k1ESPAM&!QaIMHbKwofdhxo@K{&DmXwBgJcfXW5^NUg;9p -?ucjGFvZZ81%!aWVBe|z9ZpzC%;1{9?FQXuad}U#OJa7;bUjh*5swRHIY2F3g;-KRa7m}4XeY}X@$FL -4E3fknK3gfhX2oFD?}F0oRw&gy&+q=jA4Vz_utu={(%Mp{XS-VTeP{&!@BcFlhpctt%uxA#Z@Il;__HVfX_TPUG3?H%Y_fL|qZi6c+Nl__EM+;Qu{}3M^$)V@N8YGzX$hA=C_th^yF3o9LemrxRDm@pv6Q<+pl+v~8yi)m7K()D0mD!b6*EkyG)ibrVd@lR~QQC*Bvll1=Az0lj2gi(d;q;Jxf+n(@1B5^MVT$Yd|a4(QOYGEc_ -=P^ZpPHt9rD$aipM3H_waK)l?qPmLb2 -upEo0&DZ^Vf-<4^WuM?z~a2T<@y!lHeQPiOOE2g!!U-@Mk*o-PW}eB?lS5$im1l&D -*2b{q&3TL_S&ez6Y8EjhLz8&}lZxowxin8PsjVuWa-3>lfjF2vcSE3>k)LXpybOmQ<_t(;%PEZ;u&V?wQ*_UwY$b`k;;o~K- -j!5F};kZpD4pZN|fK7h&;;yHb(7C-3wrx+ucVq)`s6YazCuk#8I^=P2!PS{&p=2Rfp_I89J#GIZ3)g5 -}i(nyE9QJ9D6ia#cPU>A3vIXets=yy?&eOeBA!JBgfN6!h_dE0}AUC{0_}_yuIm|wi7Dt!E%VTJgF4; -yC$uWNPWi@Q_0#`8-xf1nhM&@a3xar&vGR)flODcQ0GpAY)ERW!8Jwdt2Q?nv~VtC=-o_@Jx`J47q+= -xwWoMfTj`i%?&T`Pf0ws;fkcLaOv-EQr^v9;#?20B;74ybjg+XaPr9dxctH%JY-B9d*;43~p2zI(tZj -b*i-PWPoxFG{E-x-SE-(J~;G%7diU1g}n~t!0b<*P0eNo9&CQw_@0PqPgMNQD<_njMyZ-eZepbvh6`G -u=>$SY~g9l!w;k0Oj;geOJLol>om7E_Ouw$$EEG~hu!pLURC;D3uXw6f+WbJv#%=rcGtao3}cv~@&9G#`Ao``5x<>1_b6 -Cr|jA0s-Nix(k_bN_yCGyRGXp8*FZlKORk7tzRyi3uSb8B4P_EX-<7c{IQF*`+wGm}WmjImNyL -U0WpT?v%<@uhd$xBnn+IJx(!NVxV08_1xv=iX|x;y?LMCGH{5dmpIN!17O#SvAt`|O~BDQ7(DJjKHvc -q=}F} -?4!V7I`2=wJ0&dWJzs`O5AI|8hiw%iW-PXBXNzy!v(f<3X?Q?&rJ~h?~4)T!4v-MO0J^0a0f$ -%_eX%Ox2R?KfIe`rw9RT(COy!HJpsVGCuj)6Tyf%JD@9H8=Lz9Ht}{{r4a-DY(L67q<}V6_!}N)psn{zy)A~an!H=HCmbDXU6 -V>`lf8_J%SyWVWBW3`Y1{Qp0`5s%m{8lA-8^7;uVqm(c5VlTX;7t7$q -0ji)>E(U$X(2EBb_yILe5cMN{HUKu^2?Eky<3ak)xY^f$w`i1(2>klTWI){op14|4V6wq|5@~zInnA~ -tIKkG-+{MiC98mLPKOX=E!%bh9n6ucHe?BTex8t8nr&aR)dqC5Qn&aV0S`W)Zp+_{O|ib>~(YUkL?5D -yawwM@Hyvqk)(wk4|Z!md!`j^B4rS-DHB-(9*QIjsSeOnBqrD!N**G45wqCRBNK+-Y{BFkop{Ko_N2Uagj8z;D8rr8Fsf;jUJYf7LD^o_5H9t&` -_%u>T>zI^Nm3aR>XRN?@{$MskvcA51*l=KkHu*{bQT;$XX8q5;sSUH96QjlHCkaBDJvUDH;vsVym?+J ->q|aI2P-s#P_Wu9wC|EPwy`Rw0I{y(S&Xid(q+8ePZ?)!0I|U%lVJolX_6j>0+2?uoNJ%FsuTPg)n5b ->D{l^QFA;u5ixq6o{W+&kY}bk1exYAodj{_H+_oMpsF;hJ|LDXGLKFX)2+s&YBCeaT6d(XUSDEL)dHr --V8uKgpx#|yV*41hf{(Y$>4suqP~PTO(kch=rv7_4ERxt^(?Ml+J20e*;Q -}_-r@pc1A!Td)?pdAA~P!tf2sEh5V#T^GWMt51TkFQSrDh}KUy>N#Ce;J2CRBs<`hGr2(rq -sl?x)b53+Pq+bUv#OJPVb#P)+afMw%b&(WS~P9pz@`Y4Y}nG)Ml(*rL8;7)aJF11&#w-wy`Y?TGeDQJJw5Na`it`DV$EkFtkNC=KJQ4G{Bogat})xW)Ht2aJwAQ?X*m^}1;(EJPn+~9 -}qsda)IrNHKK5Q;{Ab&v%Lt-6qp(pCzbc6pW?OZdJGbWn)! -}_>G6V#2!7@f(hI0`MXjN#W!34cF3H)yMVF9;)B2m@+xF-*6fE6X&8nkE>1?|m$zgzcHG_B;k+Ujfna -w>jDu$eA!+n-`{idR$S}P=g3XgSwyyZAfBWkG2}a4;fY2@bb`+G{(&H+**bSN<#qR|P*nb{%xI}_x$TQ2%=&OZM7(s6r1-KoPf -_ccXo+Oh@Z}eTk()d9_4tsQEsBi4rW7v2@2Je;B0;wdzIQH|2oFyHwMI@`B2X&8vfITHff{o`qN6c;6 -`UXc#oYr{cFmEg-Jz|bObR{OcbGR_Vtua$%;W=~A)x+RZxZ;syLUgHM(TPzmK@naUf6JhY3wa5<{Vjxu9DhMr5 -+Mv#{{cl)wQ^R-;n#~V8vjUSkXYy -s1^%kXL70PV;i=-pGt&rfm8eth$a4G*6kvSIHTF(KecnryutLHA4%1QUhBPJ>m?+oH=8&R=l?Zu>GSq -O#28No&gyTr2S83VhsJf!xY)@^1LR6q9O$QLw6%?yB6;v(t)!>}(Rw=7eD{%=wp;f=6Y>@Qo#*6Vi#I -KoYeO^NgzvXIm`{kpgYuEK?encSt2&mERQap%m}zffVn>zpvs2*AuxoG+$1LCc$=`zkdHltuFUpOR%q -)pq@&sQ)l?aDn<`@%Je1+MFwdzEEvB)MH)U;OO>&YUSKA^zS$^XJaS9yS1Vl@I=rwY}#`Q$b#-AP0|69tJHluS5kU{2)RS -;P$A`)O}@N8PiU+)Dq9nziOK%T|+U>fMT{mS@Cd9p@$IduX0dv#DSY?`lm|d)wJkQO&`E`0G)dho=nq -2mD@~;MtfDh+m2~B+!qMkPa9Ol0Xhd9l9UFgG3VZh={24CF^|{1Nnje;-DbWSL)cG9nlwi?DfxUn0E^ -S^gnzG%Buf|bNg^89=4scK}&2hHM}g49__<8s*yG3-6#d3mS4+A>A})5M3fryd@pDv=*L=ND2c?2B4vR$qmx6}b>Idjk7dm-_e+=P|qhtJ|4} -T2dyTNn#ZZL%J`X2>T4&>;m(NV$ld-awBu@%vyzsMN~jCY1dF^TuCz-HuF8_?e+T6IC`8lURI3^RIa9 -JB${H%1OV#p<3^M}W1-FT$uDbuj(mt0+~#`9Wktiz5OgjPeWHdQir2#M+{i4F3?wws3+3xUYcdObmvMs9{^QUi6azd-nSDs)lz~QX -H^x~vO8ah@mLxS9bTVY -2e=d%of)%Sm1N90Q_{ct`f#WM$@+bV$EaJ||N>xt-fRx*2Py*lGTOLA%&VbnS0@)ha>v?QJ=H0Poj`Z -SRW{klzI)*C^T>KT9jgAGoqr!)?p&hgy@AWk@?F9ofx%<$tsY;sQCn}r3^z7M+|I%^W+V;(utBTht!< -LydRQd-hqdfrmtQjF+LAbR07*KWO(cNckpeiE~IxhikXlt{G(3Te<2bM02ozM70T6nV)GMy-yTwZHpX -K=b2^)Vv+Mcar1n77wMg3faQ6Wu!w0(2c8j;U7^fqZOYGD}-<5>w8x;-SGguJv2=mUHGzL=N=ioX+t% -o(e;4XnlnBP=&$}vc*UV}K|^NXr7vMqPX*nHP^Z1KyRh2+lXY -f-Z%Qe)}T6z~Gpy$4PNqX96mbq+xH+H_5+DZ>8a*WKY-~3jF92pR#?t#8)03qu8!<>`zKCAk#8hm|*R -=wc&ZDvkYerQHgINnmsD#TW$oYy0vTFQ+kAb$>V=;n~ZZ=0ymojqkDJ8p7HA)vVR}?AMudC)o+EzO5s -4aiHif#ZimsUzkINqS$M~xbNB>W6Vb!WXd5`1wywQ(if=BpC#`S((CV^D2Q(|_D -Z3wgvojITb%ZXI_#fQWKvR`v(Nay!oHOrjkR$0sm{?IsVMB*hc(z8c%y4T_%xd3Z0cNPJN#_pU?J7eI -jYObcIB)E16N9T@wPN5Y3{>Yz`mZ`fIe+8$MuRh=7ssDsNzmWq$T%|n!wcVNz1e_{9z0GJ3lXtFt}Xt -H+HF>uDxz6@A^y5Q_!z8-LUkvI3;eeE$wA{S#L#Pl^ctalau5%sz1h#NPD+px9=PVF3^U}Xc{1V4>W^ -<;4f=;q*UA~ICq!^sJVrqV>%v@lDwj9YWr~wpFB3f9NQJSpQ3p_kh@mswSjH{mdY~c-0Fh%3>bsT+yE{(@63%#xa%{MrTzDbdns+3Kk1 -01nLd10>eAM6{RwC^S4Z+t_yYi`)rw2gBjh=f_VwZhR$!g^yfNf>uTj -YTCdhx~k1i@E&DV>8%3+=X2wg-astCpSSjP!`Jh9GEFG*DZ|0|9yD&C)b31{b^?U*YSgA6p}-6Cp0@O -|E_7jh(x3+O&jgHkCD}$j5|Bjv8Agvl1^;~Iujbi#)KS$tvN@kznWl?~%7vdt88-E|CO`qSgU^Wh;&= -%g!=-$(#(Y?L5*w2cUx*gh^z7^F1|_~z5HIf(@>L(cA1f(IL%xoyDq#;PC}o>)uj4Qft%DZ9UyB+ILn -VJn_;9pvdVa=153MqNlWBx5wEFs!R(^VQ{b$PBo{!^8d^bwko>pc-FQ7NDl3rSN?C?5)2H8wiO)5 -d`Z!bwjx)kuJ#&P -un$5YV-w3`i7?eiIGBmQ -^Kmf25dNc&_X28fuYd`b;1wDoRIPybfwq^&AgGh=WY_u$O|>x+l7fS(80{@zyk!2$5^$B_Y)Z+NG2d9 -}mnZxx&_QKd*uA`sbiB&Tpf>V-UAwNL_{7|@Qw!B*3`IAI?Ii`&k$K!cE-7m6KGS*P_}@MDiX;sdYhA=52^pVmHn5~+K02TH(p>Wq -vMl|kjfV25aIfT?S)B7dPC}P$2*;@&0OI~f8|=fklKe;asKJJ5tohN*_^l`hB@g)ELn*fmy{!NSULvd -EsfLyJLD}u>r(~HdqI_}d&OPIxqVksmC$GPzBq&;t!6LIcjMcpX?tO4TDPa1x%jQu+*ECD_2na0G6|8 -QA88VUH@O1*cCyk2@pgQg*F`m6UXTVo)$Dt-*OZnkCzDi3kVa>~!pO7BN*EM9oy@NdR)Ke!q!(WO0S}`IbqK5>~IevZAkq#ot0Cr*?OQiCIOQ%c? -FED7K_qu|VvKt -XNnGmMn{S|tRfE;^*7Cq6~pB}-@&3u$IqOXQ%B?SASDGnd|d@@PHpciq8TLgYX%&i|DvWD`NJ*wGR)P4dFK3aYIWzXcrRjJU?{ -5J8;26C)DSV!%(-yD&%lvd24<4&vjY0fNvlsC_@yG|05(~#yO42qlb6P!PxnSxWLv9<=T*xhxyG#JF1 -erz*8svbd=5_QMnRPO!lORxLhYuRd#UuZB!{@(Y$Ki2JaW;ExrJ`p2Ixo$~`X-jQd!kCD%EHqXmv{fO;bpok{^zhE0vU!oK?WsI -$C(KF06BhuMMomosHy(watQS&GimuVPuQShN}L&0YwyCtj1WC`FTw}kfG`*W7wdi#<-4SSZElp$q~u^ -Q>GmN^z3)3s9 -R`d;@Yp=y>%Dj$PtE90A=nB<;s5gsJ5-Hu)HijOs=!LF;t~Uxic^5mZq^!8*<;%d(+C=0jK$qpcW8x3 -DG0#2jdn#P~9i$?O6$BokL&`Gb|KTXAqQ$*$sqNj%TQ6OdLPC9y@;h#0-TcA!sbEm{qSLB6Is&(?Ilg -fwU^PX*KkKcJ;U>uHAD6kC~lzU4w$oEnV>NV||wwS#6i!OXK{7`h4;qy3Z# -k{}E6OVZVaw!9HuNk$$2&7&y-{3B3rNmWl2w(4|N(7aRwBrLzR;I$z)McBl)3fWr`aA -mixpO6(}?_LYmoT_f3(Ku|*vc<}9NPQB -yg0!vg-iI~YY^kao{5*M08p$Ap6C`H060#taZltpehBX&#{qH46Z($M&?A%_?kTIVi>DIPjrPk8gCkw7_xsfERMP -j)#DkQ5Sk*H(FkmqhsFlp>f(kRJ14LpMy@z`juyxF<_RTMNA=_v3z~g -cq`m1!_NrxBEF%1T~%6FaX%{c5p^I26N;}0$8BFn@smiE41)iKntW2Re+zJqtXIWQekz4K7g?%{;i1A -!$FCNRzg-V-!yj%9iN1=t_r_I!Ip;hs@xX8%Rlq=347Br2v#PSQ-gkDyICv6Or!aMb1)Vdkey}@UUZ+ -=zzbf0V=+Tp4_nS!4O>M;oP5GGk)+(5|9O@D=dul9jp|e0~KVTMoXth3uY|n>qcTYkM;(?q;C(s|mB5 -&C%H5L2So#8yNC-MtD^kR{;zP}4ZX6Wr)fs^TMbGSEaoW6W1luX+jHW>&33dJF^4IW}QQqU&J#f3zK1 -R5N4~KLP -ClrRCD0!kxqu-cPX%%zLDv38UTWEGR?7)6w((;q&98=T8Ttr-#SG(bG}iJ~s~5F6s9zP9qj_Jf;fv1OAUReFp-8~&-bV;Xz!SpTj1n|NfJqloREiZMmZ? -O&85$OTBX4rcfh>=oJ=e4BFQBT%i^=kN^}y9WaWui{gAT6KAQ+miM?z`rGT+>a)%3*xGMW$uz$X%;PP^&;S`)ieXR)I0DG;U%am -3Up1OO1>o`GiD(TqdR!8lEuqOJj3`aFA8mKew<6zMe(-Dh`?5Vx=0AK3JBo#h{R*7n?+=n#eAy`!i{p -VyNVjH%M{|ajcf1UkZ}EV*G1kD4}2k%`i(ZZ5nq7R)jYD!Db`@ -%g?p})<^dQSeI#ewBvVTWwog4BlG+iw}l4-gmc;esvbIg#>$n*>bkJA{{7P>=4BcpGNh3>MWkA-}gc6Zay+zB<{a4*te3+ -Z(!dwal>FRutJcviDdt#Xx~Ova7WsZGanPWk%>RAJ|1roen(S~y$Z42kFcS`g4xR0Cb{w7p*qs -qLGRgN1&vg@jR8{;CWo4;@PnIbT-t3P<|B9-e>17dIFB8EG=!;^#IVSR}qlsT$q#t2Oux^V7JsR1%n) -35oISbkPrK#x*Aw0W)U3Bo=ktJ+Qq#_aEpxQr|79nu5}n1N>oq!^j`?6+S1oE|(VI&t9Rh1QalvUAHTBhfB(Pf$Bb -vllti!x24;vyPdCFNxrM|BY;`CT+i%L*zK=XH|h&@#YDq7gKUW$P;dR23KXZBnLCd>kcJRgAI(AV%Y2 -G@qtU!u;D)amr8p>di_qAUk^X{267w^@Bv%qse^C{ZIua*))? -@5CGw$La_4+#({l}5rNqS{!M9cv-$ZXtFB^zGR_eE`Mie56+RxNIo5;Gd{vZDl};uIBZJ=4h)oZv473 -A4W{BEakVS3ZUKLY!Jb6ivx0P3jCzoes@;b4V}VlpXiVTjOdKF)Al)ecSIF}zLAi<^`N#q -*vQHT0N!g0nF*lPs^Qs|1E{j(BJ6LGys?#2QzLy{KxSgbXHPR+QAJKi&a%;`{yR=-un%A9p|OM+ZmI` -w#E_^WfF~tEjVk1iw3R^y9(t_wRlx1LB2=z6 -9-HqPwemFkZ`{8i+L-hWK5AWX{?L!w{0pPa>Z(o0acJ|-wzdeQ#LCf$o+W$}Z86ADUdw59A?fw8`_&| -N!d-wiN9}fQT{c-gDyTe!e@bJ5R=-uvjhx^>v+dtt-Jls8a6GyLh-|YTjpX$8>I3K8>^zg^;_vsO|xC -{T^J3e^#7ICxp?(Ojh_=&hVe)r*6SN`$fXg`j2KO7t(VqSlE2Mr)%q0T!312y07GaN)Z_8eg7*?~ehm -LHDxO@ChP?;ZlQBdl&uB~I?f*%>U_v$JRzecb4DIvbkbCe5_g$i&L>F;aA&||aLoXe=tp2HrkineSIjrYMc#$Amh#G-q)7=~hTpeQbq5 -^o6D-=2abS-ao0}%}=n5ZNjQ|?47cL?;ppQ4%7*Pux?YvA%VzDE^cYT%A*cIwKA_m~p}+gA@VO{~^_h8wsRD+AO~5XbOlDU}#pRwTs!_d%#uD8$6`>zMj##-T -m7{*N8Fk_g{M&r|yAH#?!f`=GZ-De}R%SVDm{N^*tXyS&FkK08%h29Xa -uYG92V_pgp`010IiG)MF7}iYiJ+HlD2OkXPj6y_JOjCs+m=-|CR -MrP>v-RC98^Pvw7u}?vu=ed$nn$-~M)X{#pK2ap37aY`MEG4Mv`}%^q~d_+YL>vxiPDN}xN7=uGuSqh -NmWR16oCnS#qv9f;)uy0ituS#j|P2g?x%Tyh^YvD6?I^Go{n)(9u-p*mn!1Cz>066i-0s@$DOYS+k@? -nd3u~pQ+cx;y#W#dvG0FH&;M=b`M*X#9PeStWDb;7av>52MHY-+%~y-;fzzrsA<;xknoMG{^0u2Nq?aL8$>x~eWzrf0;(5*)OKGMS`AEc(%m)~ot;G0 -#VJQB3q}n61)z0P{IY&y&&h#>SJsVOIuKJx1q%lM?!&G{f2Xth -t{ZQ3UlYqo$KT!I1F_74H}ey+bB)k#_N59!ZF_YH1Vud}kE7jFy -t&0p)?oSJ->r|-%)MVa!C$=$nr#NTJU?b7!Wp}ogt_8p9R|I;X?xA=~p?!%e|THnuM6>)Wb+$^YfP5t -O~QYRCuq%`z_*_AGKL>qGxVkvFoga4q_$h-`He*pOcruQcO12bNyl@IA<`Uy)NU1zgn;^|ydkjg(aT9 -4*4>NkPjtCDXa}t;|{Y*UQVY){{8Wr!+p>a>N|cmN8NF^EEE1coB1%u -=Mxvn8~Er5wx=t*R7yIWX$?1wcNg@nxXnjELxedRV#;eF>RzRv7y%pIt9m-I;Qb}JN&KEnWNA&zGML! -Q0Dt5K0-Gk+DGn6yL=EEa|FVC=qCm>ut@nzFe)7IdZ?a-uA;mTxT5F8AMpY`GPbRO@^ZDiONKvVp)B; -u-p`e)UqE^jcd8-Dvqja3g+93vsVgcb|1{_imz>K5|9~D1&WM8IJ*yCuPD*g?U#3*$pdqX`%@t6x$Fh -z%g)#`HMFQ%$Y@=JbcmdS?v&}b)aO$*atV4i2AVw@UK^O;KmC^qaWc>#fDIUoaC|87!@t_e|~9cRFej -$wZXF3C_Y6<5sii|iBdw1iviLaoG_6a-It4uA$R?fsw5z5(WrJBe_A*ioSS6_)FDzv=cjvWo~y?T{3B -_Gb6L&JIAGkm*1fvKZ!wRf+{ -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 -1a9&HP3fX|nhTKm=Rcf48S(%Ot5 -I-9u~J5l)VysTu7;)I%;UYm7#fM@*HIY=uj#>f@rD&#*M95I0&KluNFwSJca*-*n(<-x~usShK&s; --QC!{e-N{H`#ceOqgFIy{69bK*9zc7S#i*rwZ@~^;wntG!%#ELf~+vbLm!h=Vc3CeS(o{)Mv4~vhJ_2 -0ssA{>Eyd&d?y#;>_|4O4nY0DslUTRdVy;{F==fziU`!+9Y;eKB=*`KPRxh`v`0Kn0 -0@u+`3V>3fFKesrWNRZ0&epOrlO^ks|)NUPQl<0j5^nSBYa=!LQKyw<8eBxuZGXPKz~_snHN=^jg*57Q -pZHX#1->eT|2HxR1B#=j#J6*2o3(^e^W?QS}4z*xHo6_RUr`sDT@Wi`H&E2Dk!rJHOp;fyqZlC(#F^_ -rbBM2(ha%&?IA|h`j~g^BHCWUo!d0QNwhJ-wx1}fZc&z%ftkanX;-Fc!`aHyM+4gvo1R)F2G#Gkhe3X ->ht+vnJ*~E)r`1kGmOo*Vy>1kBpAjweb1Z*cidFC7smvN-=IPsgV_{FQHVyJXN~I_ -Sq)9C!}%(ucmbOxOca{b(JNIlJ*){8!uXIkI_g1#i3e0H?m&qVg$U|MQ~sYd^Uy^G)mNYtR09r3JSfV`ufF+8(p-EcRWXS3 -6BkV1W~t6Amq|9!r(+-DAw4^;G1{`x^@QU2*TtxqSi%5WF4ZupEh@za>5+dY%pQ77Km3RT_GMfuV2cF4_qpi+jKKPp<-4I?T7Bzl@Y@ZCfwn8yt%rSfbIv -w8Y@-xaL=KWg#N3JSEy}B`}Y?Y^>-XO!4|BUn94gSri1T=S8}THZ7lL@dw8k5+(=3;L -V((O1=TciIF7B^b*~VOXpH5RADRj1SHvF1k_WiyS6GRn?`(kZ!vmDK{(TOCOP5sKCxg;2@H)$35QnF!N*u~dzPD5sbBAX~3wraHZ+ -<5)t7_U!eqn(I@PQTI_qdz{nVIfXWA=`bxi?sY!ay{AAol{W?+0#IFO5L++lF+RkqM!)xZqvKvJLm>V -;@_S%{wULL;CK%Y4Et@l!U!hNQ&TH27pPsW5zBcbtw2S}GKZs?4UG5{lweV}^D6Ha1T<=iTo}7|G8 -Om~_L&lJ&`8*%*QXlQeMbg3B67|*6nI3tq5k??m1II3OdzFEzv)%BB4~F;PAwV~%&(!1z0r&F`gu3p; -r)nal^n56Fd_d!W7VHp828^4?2}648AHN9emn -CQXZ8>Rc(!m=?wNPjFa^k^tzZ>RrtEqD0L~BDjHSVoiooTM589QT^of4DKAKNVVZ-+f$U%g{{3J6{H( -uu*8Ba_C%+qR{eOS^cBgZ4>IL6&dx_4^Ed<#@jVq8M)Yf>iv#xIOi`WaNSIo}rA_>pPe58qGj4&JK7+GRn|o)*GZ8`1-6q@73o7W+5m!!So;wU_+H0O -05&!T@VnV9YgrJ$S8hpdv&~`P@ivvrbf|V^g{EUFdSVl$WZXpdfaMy?lT!HhNBYI8$ld*&nub%iqaKL -@WfQoTI#zo1V(Sdul8T>{&0AF_T%>l$NOh{-|v1n!h_uCWB#%JSaKO)Yv)CU;k6~^Z>o1CdjIcem&ry -f*=rt9r6B8(=<=h^GeL -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 -Xgw<|5n>o;vAkvUvEkHIY*3~&o -Isx$e`JTof7swe+G>;1~Cgbi-y7H?qzy8$6}Tr9NH@ohU31ayFoF1$7stywBDmAmU*_$Eg9IeULIp1v -@Dd?vpyYO7$*jAlBvg3HV2#>e*W*YEZ&uGiZZ?DR1u9M?GoL<9X?r1?KEd=OoCcMHl0sZ1|h&O*0G@!BhFqlq65GK3C?QAX_6QWa$I&>X0HE_*A1Y)ztJ -3S*gT+xA2Wl5_2@B0Sb4(Rsu(pj^#d3)S230+--5yKX+5{F_MQ?)dM3nfzkjmxb;FyQ3gXRb$u}M_^C -+0lWmh}dK5ZP#`@KU~WE?XoMyghO8+J#APKF)(BP`m`e;|8s09s452!W=TK?|^UgO7EC+M63WA{+UZTzj*e*lUq%?Uz2eP;Egzg9b~x!I%Pico@42b -SZ;;AQ3hw2apm_%Wf4t;AV^enVk*Q;l*-E@Oiz$~CpXpW7^RWc_@NtNAyL?-$ef43d}G>AqxQe^c*&6 -_d?F{<)#ve_DHP|J=4{-<7}3-!C?Q@5*J1^owTlw^jP9m}MUFpBt+bCS=XI{e9aiMK|EzX7CrAK}7G; -W3Mln&EHn)uVS8g$iHu_)GWVAOPz6T>BRo6ZJE9;>fQX#oj~0ke5I2XS&eh@5uq&1?J*?#mwRjAswnH -xoShNKQ^U!1g5DZ(L(WSlBH`ZecRalVL-dT3Isr=gu_(vAWHP%-s(woj70|k9*G@bt=94k%{b-}ai_=N9VnSV@-Lvn+I?&O8@DIL6!9>QB`il}U3NSCj4=@I?^{E|OQMBfX&b-@mVGYnEZPqg=mLp+l?I$Y8zo282=FeEE+rQEqQp0-!1AtaSK!L`Y|nCMoK3R^CIr2}&bI7bgmyhlr!>m+76yWYU>bH{6 -!Tu!xz&a@cM@!0Hq&Jmtr>0yEh4$D^aIZ3*R&5+-19=$>2@y(=O)Gl2~ba^{QIwcg~n&YUK4lV~6s&^`OLcXsvE;5sOurU&N_ -pMqcf-n2t*CNkpo@QI{n&6bkri%O0nmTM^QE!#t=6(s~ -{VV-Y#hQCcTSbdK6AamKqJGq{b~f0_D=SW#PLGS%{rFj|!VsvR`lweTX@+9WV8D^tB%wT*Cb+buo2Wv -Bq^YM#_I~P6t8+*+XR`DuoSQY6CGqW?Q8{q<#p;lJgjle&U#bPIC9Ul86|LqPvI#ZmtEH4_l;gl@p(p~E;E}EBxc#f7Xq;N*xZ~}!h;3Oes#+LFZiJ-0Drn5zL_;x2BC7DS!w? --TF{41-${kRIJh7OL3awmUK>5>&)(Y_`^)u9*Mo-!Kf`VvP;?=tH5F?ul%Pfk6;+{5OyjAr>2kqXAqm -7x3c5l4lf-buD_O7)gn(YZ|ax4=f}kL$!SJn*eUAb-7(;5(k#;9pL8`tYzc68VJHBLs--Kmqonmn8a) -b7wp1SplpPfx;Q=9adDEC^A$Al_A&;P!~e~BfeoYniQ3hS{8;8YTgV&#GnWxP6a-q2H1>s=|Sfo~@Lw`&GA6H9+;^Lh*lT@D7q5j%;kA+3_oL@O -V(7~+F?_DjDSlF)h*UhrmkWB!=|i-F9ndCD%@M2;0l)S0d}8VR0CuMx4dn$Oa*=irmV<@Sd|mXobX%G -0(z-=cEd#+WJPSr2#ayieBVd^U}@zwa?F9N-s -_f|)yG_DLfc$$^+~OKalhKvKep3uTui=@oet@~6KIwbn63*bS1rjghQ?Ng0{4kt^)iuh`C^m8rO&)MH6Y#YsGMY>ZJVBX -|vY91(^$iVmwlXc+G@v5qE7#c3!0_5NK%s|r{y-qnV`T>FM2SLEHWM-$fY_YXIMm9Ho=R8N$O?spJcW -|)W!9(5nUHnYv%!yQi-PrLG#JmPGe3-LFswvdlvY4Z_ha6rzK7j|tWJ^SIZFo|pDExZ<(2P7-Df?_<| -B3L{?;~Hn7e=OuH&HOL1bWUU~C)$1D~Z{H*H{EbqYYecZ_He>`G(uyisJo758qf9#Ip1k38j8vb1-otS6*Tle-VZ>FH%qmvM8sC`C<;@ohoJ35`)ogah&>ry=GkNMa8 -dp)|1K7^U=MjnXN?f!VQ@A95N8Y4|kG&Z8ti3x$YZ9wA=M#33Jr`ZICN^MHofgktZptuwJS9WM|=b5> -%mbksPQ;fAY6aCKPg|OJG;F(V1cLRq<^J(bd~*z#C>yPuivb#Cuyo?1gV -u?v*ja3(TKS|`JR50VDWEsC6QuZV=V{gHMo%q61|zcHM!frRvdLuef(a~#7QTS*pG5CDM -`}XBBj-5Y9f^-zU=-+-L;kC5@6oeKQ9qNanVhjvf>hL-Ce{&@P_N92&Opwt)?O(U1LX+{k7d0z%F59^ -t8s_^2_-;S3%FSqAl#7^4S^*$MK;!lmt^Fu@O+g41=0sTe7U11wwriyI1G}rADFY}td`+$J0k0htYXt -=s~m>>?Du_}xk;+A>41!__4uXS>Ff|lL)Og3GT#lWs4%rZa-!x3Gc}yyM5TL<_2njv&Lgh-YJhfd^NR -wn3aV>-d693agwdMBFzih+onb5n9l}@oe(yLXD7M_mE;@bQ3QZV^2`~*gvd=EMbP -@wTLWTtj^s{eqF!X~?RhI*gJ`K0K_c34kgWtWoAuyn>zP`3Q!s!L2;I`1QD=1nnSp`uM(Frv$^Zb}|Q ->*Y93R8@KjflO^*%+$O!Q;KGZLPytC(JA`DEA6BM^a6k)zT`dS*>U`61FHIX=-+rFA#roy?2cGhgk$-u>b5`0S7SfBNy=hgWBN-|v1 -n0&-SesWv-j|I%??b6WP-I5L!73`2# -XD$UMQdCmJ8Y1lusJ%nr5BzYJ;Bz?c(h)ANtnza)_a{JnKjR!8=0_6Jcm94;EvB(7t#^)|!b$6Y+~pz -(W;8tV<`2=#Su;mnmF#V9yX|IbFJle(sI5qh%4}_$gU(ZRMPI?ur<#t4qT7A@?zmB$U7;} -0qbsqjt>t@&X_&zRTZeV}4=N!CY)sYUv0YA-fPuSnFy$Pn5X`LtiZc7)<>HRomF>!FZqARhDXqWf(5t -nSv)~{f${<|IS89g*m9ecOwJu(8t(4UwwYD{5iNQPxk2HP1h}7_RkQn{}+PzTDkF6@Y4oHg&87H@*%s -%7|fqoUDXB*26><5NTz7j7L(y&GD7iUh=?TJ+~1oA^cJ+-MICb>~#gFsl-FUEJaBHBZbLh)+%R81W!v -e77Zzb}>~t5pg}JDLnT!gO+kH;GMY9lj_|6 -*4ZklP^F+HpY9`e&87CW{*N||=difbgbo!EY3kQZLt*Z{A9xF$0BN`;dcs8$5WmH_o2GuWLgiX#QLSQq<=3cDy`|?OT8ZbMw-KEZNwUqZz%Y5q -(xB_nm$D@qvw8b732{s#uj107EPdv%gDM15NvJHu{dZ40M!iI_-k4gTT+l#V1_5+C9~2_qtjW=1}6C8 -c9pvyz#Y7iD#@p1c-CcA#NZ8gtspa7(h3y{yjLCP_d&VTb}n_^uWcn-W&zTy1Z|G$yEE1<{Lhgb>;<( -MAW8}xgq#aBb4~~HX*hh&ZZpKehR>Tm_#xsR1{!Q}3@cMD^LI|3R;QaN)Sso*D4C@n(#!PIha|t08#h -g$ff!FHZUqXqgg0sQ;c11vc!s_BxX_SjKD{y>aEC(;ubS_D(U=|d_TyDT!vvAL{?JyRiC=u2iqpAdzSIBU(Yrh6f{B6jb&g=r_sXMyPiZzY8D=CrI^Lo -MazpeNrFJn8Ga#(nf|iqq4-R&}CW7XU*JAu7(z!Hrbl54SiwnIrr8O3+iW$6f{e-Kp{zw^j>b$xGn;0 -R+8vZi<|at)GP%2xhKD3)4XZW%I>5p(ItJyXe;vAs42*8%m$==* -+?E(!J;I7&noQ`+vEA#Pcw4zLbHP7!Py1?) -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 -t<%Ca*@`lBhkh!AGy{-&^rJn?x&So3p*} -F4BY}9LvztgG{m7G}0hi%%Vfrfw<=|fN8QO}(zBY!)} -2@pG~@VGEQdj|RU^e5g(~p16mCb0jc9`nUodPB+u*=anDwTV{Md1}16eZy^Tgl0n^t(?^?jMLTG8;pI -RmPy(OK0BaoU!2o4}uBmV1Vz{*#KFW!BH4O|?{#Kvl+LzH21X_K#j0+7Klmt)VeIq4!GP>%(;n%Y^aM -C5s^W6XHgfz*(?7Y)j6MjRz`9<4B-X;jp_cldKi!5v6FD&d|q>~7uHqEl$@aQLhMKh@4+aHOY(`gfYb`pFk{npcPya2N -p_vyqAZ7Fephxmq!Xz{ukq?m*A2DYv+f-71PzDL*FI0+l`*t8ljo;C*XV_~j7u3&OWqthRG}K2>z5_$ -w!&P{LW-i;cHmNHoSx6y1GXtn&SiO+JEzemuy$+Uu%J)F^;oecoEv@-cAgn&%tKCJs2 -#|Ja$chPi{Bchj58e~Ni)v-P5DSjZoI@$Leu^9O@(ec=GU$0We+P9|bAX(p@VL-vL2)`d=BU8oIy?_B -Pon;1#0`=x##gF2WFPfnffaEiCjoDwjln{?rBE$K=(3J`slmaBAh{UIIAOVU@P6MOg8y$&U6T8=9(Wf -R_$(z4?s(XrvqV7W2i=Hj(x)annQpk?rsBwjXGNjh#*`d-{+O+E*m!w7bsF;J;$-PEZlPT4ue(!sbv5 -+2F&C0145B#lUB^Gf;d?tSiVc0o3uv1V+HOY}wD!Dwk5)y0f6XDGy4+R$*5H)_6F*nCdy9BaNP!vzV`VpKmwR5)ACIh5bSdWX!H{L;2Mw4Oz)(HpO=a2zg -g-)he3IWXqy6-+4r5GW(5MR<-oxQ}N4VID# -c!M#h~E6|AP@hoc~=Ps?iu7nEZwbtFB^>&oY4Cf2rz=Pk)TCq;e`dYNx?0`ReraoaMgPE<`;_QG9})ANS?P_+umcHy1?0f)VvOD$C;%|Jh)*)=e{Nm4{sa3U -QHK!dpC702SP>cdLFthSba|n1)T@@ur|8)bH)KYsLIzE35E4}ZYhe|28FRE$Xk*QUf -+{S#qSi8hvJKDL=-gQ0|<>QNdt}nVIS!O@bJ>2Ejlm$U1SR -@Mm8pWbNKb~NuhWXDX@hXYV9M1fMBs;cyAS{%JQc4Ml&+!ShNAet1SXr9;`InLA46c5Y>eiC#9wxxCR -r^6(on@?c7RFjT;BTVaLq#{sSV@P>m#`o6r47`rZDmd(WW=(Grn;XxREW~|#!#DmCokqD{INv1<_cI@ -Ch+mIL@vP30@)zk8B^)eWCR2$tn8maLa_Fje>O?s=iej+B`NGDqpQa}?~BO^1>M>6yI=me@AqFQ5`>S -)5GVhjGcy02s~Hvo`-7PL1IDqd9#I0&p$?n|0_&-m)!Q7ml`<0bvM~%5M+5sy*i+Fz08-)lya?3@Slk -}TOLr|$o(m1Y`kG<2foV(+dNq-UVi5jm5E1l_JbbY4o78MTYQxC>cImZ-_}^(+yer=nW$F*o055r=j@ -L$Co-P3GLTv-sz*$cr&FZ?<(&DBzhk;==%Qu#RdoEc|XNM~6gZ&T~+Bh1zP1;^OPy{+3sUmvL>@u`#z -eCo>22K6qI!$M9R3J2O?Q*OSyM!kzPiOn$x}mzcsd}>+q@vAbc)Ca^o10^FrRQoIRhw&Asx)5123UE$ -qLnJ#1b17>8=(`)*4(RRW>j^XVGSiY(X%CQ_ybak^%$Hu@mYg_tXOTE|eVHf(P$gz=?9 -ei0P|au4?uI=aL>FT5kRVE;znv#fXqdYI& -Xr>^(O8oQ&(*&@)tn1-*N`MH~DOw^nrb+mnBDcS0LS9j+yCxzHe|A$|_h6+K$Wtp;W@|M=PuGJ7`xrx -jqdqI;^+pCq`^7jRWCqhfvQQjE$)Vz^3*8I=-QViU6=Z;=4Bj^gi_U3lh`pa=&g}>i56tQtsVEZgW4s -B9^hv{X|i%RO$Q2zoKXqn5Jv4?P%(Z;X!v47a1goX&NULQXO0+M@rxyPeBShR*U~myG2N8X--5vtm3& -$hw8YRovPI4quuK3NEL%Fj;5Ng{!yJ4??zJ#=3}=Ky5}1DNwc))4@u?iX8}>$Tt0${sYxosTGsRaV=A -DFXVSlf_{F}BtDXFHI<)iJE^40DZs}#j@D#V)|Fc~A2Aw?L8< -ZB>)e>QLXwtj*`i3a#soVplX!QCli*E$s=K@q}*hR27KsjV5Ly)k02zOb++6AvL%Gd+oImhS0da*GOR93?%@wq@nIaL2op{FWSTb^q2i=UVHNDtBcunX -Z_=AS|zA!K*+4NcIV -=E@0E9JCu-hwLRp7lR&P(y$58VdLo*?|7`*}Xz6+t)gG57e>HVtxd5Ff>4;nO}YqVJKO#6NFP+@YJeTYWILuuT5P;+GxDFsEP@xGa6Kl4qmF#s;HD+JVn-z-P*a;@cwJqR2tdGMy423TFz?bx20mqw -;0a#rJ6N(|+!% -0sEeh_~BWCEq#TFVnB<%K>7I6T-DS+7Twe&lya5Lp{e32hEdPmu47{v?k`dr?Y&w6|2p^U_U(Ew7_53 -c^H}je%yuHSrS&3zxOQ(xR%}MKFpemuStDJjQ(b#MEjV}R-$%}HTKY}Uu-t|Mf`+ckf7VKp_6Ym`Vr! -JubrF(GOKnZ~_Nmp`&_9KeaMH$fPFG+$61C+m=+B(Mt2qBlI#MU8Jl6A~n25V{Gg_rNUpa#fHOG}kH{ -MjW2F}7@FpA8Xa=rrn`i*#?4DG)4l~SY95iitfG}j4RssFyTFwjAV$;ZJ{9MxzwBl`q1X$LjX>KO`a-5+JM%OWp(F*{n=O!!~kf7+4}Vhi0!<3HAZ~|O2u)pnD237LKIj -~1(^}!L$fO0F#w(+cV#dE3H+?H3Gp1|@n7x**vayzW>;44ATT)P+@V{v86H+!nh?KHXb%LAta^6iW9Udf4wD+lO%Wd2X;S68EguTbM_VzZ% -CbO89H${%aEzuML*38&I`l=xUn-hD>E6$kkK$mWD;vpeDV{(aQf@rw0 -pn-lg_d97;r8cxSykG!rV~>CZ%|F(WkX4aD?oqcH9u92@*bx78%OpxWbL+c?Kpy)7OCW&~LNWG0ez5e -ytggpj-2T}~97IxL@<8VF%bX=pc9y2GWND%Y-zUgLNKTCa}KOEqt8U2P&I2&*+nUVGC`G&(V%Nu_ -k@MET=s$rO4uN4E_WpD)vMpavxVi|iA!G-^wWy+2;1xgS>!Sl@MC+~(v$f-*QfzRX#2l79}Fw0wPm?2Zm!Ul_+7oMPsGbxH|m2FSb+$i9c9>tMlXh&H*j=Dd0Q -47R;Ss?J1c5M)dbLt_Ws37rp$0nhR+c9maFJ3_9pQCg5Pm?z%^BUE268K?WQd|>s5j&|IC_NiY--km< -2F~xu_nDG@afVmWr3}Te7 -T1Ce>AT;WzVli4s8t!}m4lmHVdU!bVWbFw$%IQbt&NacYHbWpFL%UXG)% -LNFhsOR*sl0WuCN4L6P_Q~ll7r!O+qUjQi7RQ~)Jsx@jgfl-shv~-M1%(CJ*Q2dy1d~>yd+_bWd=gF5 -X;E@42jH;C11nqBK$y -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 -#`cTi`e^ZW#6&qCCctv -n8;#zahujAcHATR_kY^sK2>3lymDE-`uyE1pim!#*CusDJM3-4RPq|!iYB-Hnx4SvA^|n^NN&_05F>> -vq~3+5#dU&@puypL+zu_>wY=_mhZ_A)W70Z?1-3=H|^O4KtM#HCA(oAQAqTl-%?K4RXrj}01!(-!TUb -P-sM9|#6XTlDLOro%c$HY;^oII@6q%ZI(R)|(?7iieua*Ado(=)Qntu1x2P#1#dlyf1g1FrsutcMPRJ -8@Pp00U{pA*S3YjVlU#nE$n<7yBo}BnjUb6cl8JYr7I47u@J~-^GbU$yuc@o8c;4Q=1IZT`0T5$c);< -`Urg{80XzvJUBG{I**fZTuhla~|(l)xRSN%A=zvwP5w!52rcFmtIev;{Oci(;;~5$@03@>YB;=#Q(_%U;J{Zng(jl -Ei#wpTx60{BuTOLd~f6~6+61g6xyw>lE6u?h5+U_DoVCzLx)RaJGJ|*)EJg3E2o(4MIoDggCtwjD -`--eWkJ2>SX+9nBXGokcAkN6_cowRYwpWj%xWhNG9(_P#-@yCFJfUqq^XGd9X!$Xz4WvWsJGeqa&WEw -c`Jze=WDA8p6~!5or7ARwQQZ(PfivAOS*sGq!2}wKtTy`$lL_zp}AO0(zD@UI2z6t9ta1w;&=u-(>2& -VMC=}Zc^!5hb$Nc%nRua=vvg3rr&v`!;ZlTj8k`UfTQCEWG0l#se8Awe1`>_vNy(%;@P>oZrQnTi3q%Q)GvzMJG3c-KU7-M8M6a+=j7|ZSbbig@BL-sVxw&$RRueP%#R;mW -b5BMzXfgG~%#G4$gx_HRt|K6NsTX+@`O4vVLia34B&t|hjb?I(D2WOS?Vg1Fl5v{CI>o7E_W}-ed5M- -}o(>zn_*{9rc@@Q1jY>FQR6daI9cstNu*iPIw7zCo?0TZ;%Ach({s -(rQiLUafy`jj!s@=Z?}W4*xysI7JE3tG(xuG{(~@4r;!6v@Ez8Lb~*Omc>L@2S`S*Whtp=*GEcOhgSzI@$#?K5 -*!W$3rZC&nLQkt6{|&qjqR4$;2A0L)?0pgaKeZ8_q@<&IR^ki5&&W#uw!#iXKw8hN^z8QzSq6lPy1JY -FfKGqt@k>hg8EW**>IhVv4HR`7T3in6q0n>0|G-KhQEopvyN3bw4y4RiJF%MS9q?~hdQQLS6wuZ!gCM -dD;CuNk<$fvFN!W58uQvn19Rh8s&!cwrhs}*s_NeyQRFIK6XtU4wrb>!{b!qk>c+F@|Jb~;K9(3PU&m -As0nr&Rj>f~P!Gz6;#&e+TN(SDA32If)Q0;VxeyFx~S*wl-bRC`hJC;C@Ymbd*KwX&< -JDSqfs3!H4iSiN9dTXtX!E4wTWaO&MRFq+JZEAxqw59dqtDj24RW-Y8IiDm5Y$V%z?XXgE&}>vnhHU5 -uWgj5fv@HAZdJ2vCWR$8a1DgK9b=^K@{fY>N-OPa0Yao(5tm|ZXi)gSQ!Un8LA^CLrFi#HV(`3ba?KV -Ni~(Pgf!s!2(R{5I0{@w&M}>!`&V9a&LEZPA2`xCnP?cT89ivhU^?jF2T_X+i{mkb!|t%$(cOCS^U*)Hc3_(B0O_~y{43??Zbe^^5CGX|w1)+DJBLli*Gu}#aWbT_+8hy -kUh(ER(X2hl;mSQmv|3vRG@ebx(>5T13Ve36v`I>hp(@?DU$h+$d>${5aTk+VFg#uk5pL{I~K-6t~Su -A!S)Wc{#)NqTQtw|nB$9XWd-yPwgz;cG -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 -yD7z=~bktB_D|z}f+1|dfToOD@f@6aWAK2ml -EMt4LrT2aRR{000F8000;O003}la4%nWWo~3|axZdaEp}yYWN&gVaCvo7!EVAZ488X&Ja$Mr(Ms%y#A -ob;P!zx|9wDugfI!;E&q>o#X)K4-ao%&DpG%j%a{)SE&=C2@6!z4QVQaemgg9LTnW8wML)b92?sYiTd -hg>`j^`set1l>(YRI5cWmyzQY~bp3i?Uo7z^iv;gIs*Y+!`EKH?+sj)%^pu2ZP%F42j*MOn6pTh&CI_jnFPgzyLDmOtj9^L?c+d38#Y{Ff+=JMalUt -)3Y*`#}9XBaHYHZtzY}1KZxBqlnuu)3ISSi?SKqwXUC?y6eUmLVCPENb_R>Io`KA%@6aWAK2mlEMt4JYxyb8|=008tR000*N003}la4%nWWo~3|a -xZdabaHuVZf7oVdA(ZyZ{xNV|J{ED;ruW;=Tn?qiw@abG`maDU~MuaX>Y~tAkY#O^G=pDl8R$={onT< -sV}5N*|{Q{akyCI-!H?QHpugERkkspdIVPbN01pmm7JZ8CM38``zBx=p -bMM-1$Q_sxFFHA^Y -z-B}9y0Ym8#0D3DWFP{LDc6B4Rwq1Q^4=O-uI?KY%Z*su@}Cz&CWCqG`jy}Z7=d<_EE3x3WsCM6N<-x -U|2>}p472?E7*1!87&OA0~ggJ3YH3KV!NxZ?T4oX8?oTPhgBBwQ-NSCw+?)rbdL+XP@Snv?0p9l5%jl -D}QtUER$Qv#GllK?5w-?v=9av@xqf$be|2*WPv4V^>tD&=udd(C2?J{ZANEuV)Bwcf -Xi1i6v)wV~h>#XWBw4bUr#uGL@`s8(F!E45GLeHuq-0{lC3;kXfC<86e8Ux0dT5|mIGF&;LMS4Dyx2@ -qQEW)b%P@LmFrX#EX~~}&O1YEGSH(B1{Vr(CRy6)RnM{t5DBRE|o-1-f&cMdS#;VlDT@{d5-)Ys+ul3hpMyMo@G~)>vFjw?RFXJi6SRQB%W@HIA)N5jzWtb9X -;=|%&Z8rR%VYh;ok;DjOK?-y2q*e+7AVdlYbIwe&v}8$?2FHzCTCdn2kh&6it8*|5QK%q=1=c#3bG@c -z(al;jjohtjhKgm94VCkd+Ztifu|2<<(}z#6j|eWpp=2g$$#VKe1#lg{eo;hUb=`?I?ZPyBLa%V3=)So(Pg&C>z9?Z6EdmLW -zc1tQ}djsWQc}ObZ*-p%@oW=WSHvk=Z_>vK*v@y#Y9miigqPfqhRQ)9uP#umnJOt||_aV}! -zHrveIB2~y^gxGc;)v^+9G9puzLbWYnxbbx33;N$DhTjbkv9U&QV^%EZCsy)^=G+wS=*YzlTe7bC -O{ALXgVY|sLb&tsX&AS^y6KE3F(4y;h#AFg#G3d~EA*4m^Omu=ZB}RfYA3nC@`_mo|7AT^-opx9u?uB -`xcd}3q(-ak`k*pomoBMwhUITc*anT;!1_dn8l6N31t6>_yjIYk -D!&=`@?!eB~Zi4-Ae6N|;_j1I!MF28NP1G@OZcyxm-MXw40x(=Xy%FCAm-ucre6#>?Or3FC4b5ox^m4aD+PtS^$sUOy(zGqOtu*s>$V61lJvc*ull1ZNhOrN_?{hH!Ay4R$&;yHVSn*ODFw-~*Ve$VT+;250FKzA$~ZeMj+l!8IMtYmM0tOG -F8mnTpQg;XuYMp`G(>2!LB#av_SYlQ}}iE;Ana%>k4o9hPr)CbKGfi%j#KHsaWR^orADc(U5eQkwLdZ -|}w&A_MYKF%J6&C#LNGcQavdpZ5y6?iYEK|v8hvkW#=C_$`ED4!OKP9$sldqJxkc?>wAo1%g}3GZ;x{ -41umj6o5j@!Ck~6S05H>Lj)|&X*V2ppVJi?+Ng#tk(#5bTN~9$LG08mfN%F4VMi=8zTjmIZ13N-Z4HTKta_=w0P9 -vdEzg0a_oA{^%5_m?iMz5nblOXC8gwvxEv;+Xa7|^`3-k@37yoBD+Hp0#X!uC8ihVf!*hTKJeHn!_3c -Fad!R2z@9<>J0_ti%4G@tj6YnBakUPii(ED2Ua0ktIwa3IA0f*Hgd-dS0BH@F@=?P|;#UoWCFT=hVi; -hlT4(=W+I-BG1^&=+-1`U$AL<=*rgn-4jt6$biF!T~0R3X>MW>={z1)sQKw)VuNLsXptN?_=LpYTpn!Q#+qg%+ou>9#%h()39p7! -kc}*427r^GuB}?XHRVnkGe5#~*)lDVyGBjkiPli!4%vg7|exY+IL?K%5PY$m)jpDL9bbgr|d|vQgG5R -Gp^LR$;8lqyOL&uBa1!+~#e_!ci_+nmT4<*zQTJN)Z#fc8naM26-UX6K$MW%Uq*5qmX?hF?+-N;4W%o -CQ!w1n!Z-x*U~6ER3Afph>b_ueq$uN)XgD$r`Aybv4cw(6ACh1WW(G;8tq3AuGdP8eRB=&Ls9PuF$-R -m5>6@COb_C2$TQ*b|E@TobkmwDsG4322xPi;|N}c2P&~8G)B(tvfX+;QLvA)o%v(>VB*y3HKJ??nlpaj~^#gom8T7>3_=16U^>SS(CZ!3mgGqol3s?wz -1o&v}F(s}lWelbDJXH^pZ6bh!72c7#rEI!VyXn-EXc|1GNl5{6O6GT>c3Ra3bJT2co!w5C%w04C&C%- -LzpA)U^`M>htPJf?%sVLtgUP)h>@6 -aWAK2mlEMt4NtxO#x8m(=qj)%1fNI -bCan%RcxivJ%63#H0|YiI1~w4tSOQuNZWdE?tj1C1wewIlI3_Z5drKj7K_DV@xkSAIQ*OiVUPt}uvMJ -0#}uBUJLbhcXW81#Sei$I#Sy#SW^4F4W^UxOx5MFZFnGmI<7Ar#ck7H<-iRH)e)IZ!`0oeyfk%J3n*g -wQ##0eTgI553&C^XFL;z$0!PY$G%PqT0-6-S!n5|OIS-fK2+D-5Hm}N0kwPgw6vv`@gL4>Tiz+AEgas -ZpHfslw-*`u3s;>;By_5v3uGC%h64UaN6!x~pX2yL{oHD|+x!WfRI8lSr%&;xd{R2Q}-0*hLZ)Phhz3-cJt}#^9=)t`Q-BE4|esDO)mdn|Cn8#joI|~>-luCU{`Y>HoLe!pG -_fmc6oaK`D}LiEBgSLmsdCJe0DLr0kSt&RHY&|n=X*t#dLo936hf!v-8=_A7dc(advZwbU$9r*@RtB< -~Os`&*zglyZ$`CzFJIyg)<<0IlKHghkB+L)5{wh>V-Tu{S6Xq@o92?j+Ftm$!BQ89JyqtSJ!{cXTN^B -VV|zf&!&+1VG7($KAcZwWze+K^U3UD%+4kklV7KVbp>>=g$O8lWM4i_F%#>Yz`xU*+0`XB=Je|FW)8_ -Qv~_+{Ab*)Hreijl&lV__kMpYwY$gg8aIT09U|voo9Ta-CZvX_-pBK}DBs-f<&Vkwjk!3?QxIGxGQcz -jPS>;)ta>rr81~maRPQ)P$4@ag41C_tZBQJ~NQ0R;REKMaN24!@!IMr#fEsC;j!o^_VIBpJuIEC6y*s -x)=*iTE9^-Dt5&yLf(pND|<0Og>g67P-u=C%DA>>;%0v*4>dBvT47TTpQ~6`Uz}811VSm_1Z<)Ny-o{ -l_to0M2YpGKg03*Vnh~`I_qx|BPMI -vDTaiY`LTSwrdla0zMNjmIzD<(q^92X_{=kpZ|yO4u6TQJ?Mj27v?l*5>enBui4&eCla7Km8CTcc5t$ -)7x)Wb6vGa|N@?G)?2Qit-91^gT$ZlDShxO*u(gKfc?G){k#%w0s`2-(digEL`7rz<03F!i>?2P!a*3 -XX4l03iMK>EyD4PZV`fFhV38~G7<2riAw|IfIEuA&w1@RRNZ-F1F7S6f*uk9HZURu)+sTbgLWlAx|%wQM)Fw@37Xd?cYcz3)+1G+F869W -@msyxhapdkqdl5zOJt&u?*EzxxW-vs`-I2c)e7lJ1SJ{Qax%z -cOhU!a?-M2%o~;A?{Us!>V#CVnV@e3V&`zyxF0##5+S2NjT($uSzy7#)*Ld4Mg$BLQqfYSfof21N&6f -Jf$ENPQ|hb4M>ir-a~hNBr^RGL9;pn!~hd8AK)PBfFqdZG<_u7oMhJu&1j3w%*&Nby$hpXysIl;* -U&2amVTt{!Cd8&p -xp)q_Xe)&)moBvasM=1XE142FPZTj(=DPt$;wxq%#G4U)*~ZFQg(gY}tX4b_0gn|#0=tcOOQ%rBDdgR -!Xi;PHqx_l`+NjlKSHiBN6SW$$#62O!<%ZXnGELA5Y~e@$!9f~l)&HbawEuzMSswpQ&DPQf;dN&~`xq0p0YI3Rvb9>Ep*Iri#hM77Y#c;_b_Brh -#g)DCJ8VgbreawyAcY)VDV&(Er`K;^AG1{u`9KoU!`OvKSR`k9$S!f~VFQ(;D@@eT6EC)=>>uP7P#D7 -08VK6RQtKkxO^|SJiw1QKT4;q5tJCB0)S#*`_fh;9sbVxE|NcLtHgqtJZlleiG5Z@V97Ciej0(~0>3l -SO1x$;sR_gp(vif&6w1@D&#)ZWr)@U$<&RT^b4ceSTpI1{^2li`w9b?j_y5)o6WwyInZB+mUcXf`DaN -RTA)!2p$+1>iGtr}Ljb^qYo7Kbi%1HqXbWmIepX+%h62Y(*aC5khKmFU54iqvpc5aszTp?yvd*4H>9T -xb}QX>F^qWO!yHD_*NKp~_`2gkQGE=<`4X5$*(f+@e8Y_@T9RsU!>kw>e==YfgZUlyc$2lw-E7t!kLm -#8MeKfp8MnyLaz;dO({_{F_S}S(<~|2XcQC!F}D;v_?@R?Q)&|1;990d#qrA*D>wjf;7P(rYUaUiiCS -i8f`&lu|QVl@vZ%i0hb&t?Yke0#jnYrYUJ7#g?HM~Yvj7&$OYHUP)Y>FO_g4G%;Nbw{fFa? -VuDJBZ;o6PobhkDK*03(OlU@J$RFo#{MmB2Jo6LFq;%894eAUT1i$Q4E2VBi!p;7R)&s(o8yswIVxEFstY*rB(eownH(y5p5Z5aa(v&S>0ZP@EVf;1-RueTyGuc5qh>|>$OAf`GT!EO3rtYItz{!VBYEWO)LJn+6 -7tY2_`o*LG@TY?WXrK4!BTy{L|7C7oKK7$8+wse%Wfy+xMt6LN44(=h`Orm*8xvRyNjbQ}U^!H4ns;Q -@sep(Rem8YL_u9x7=LTJvVjc1)=dV1@2t5g4kxl)I`S>7*QyWSh7@q}C&jOD$&V2UbFyyqY*vT -q_pM#^TQT6klsFFX8T&DIwfBJ@s@ULLUs89g-(@ny`t#B^e-(xmvkY>`Pt#(){e3&h5gZ1KQ72G9w;6 -s+~l+xES-HwoKQ?5jwY#n@VgY%ag=Pr_?8=mnOer(KY19$v3X^f&_-gNWeH4j@qNJmb -W_P7CU7)D0*mdkcn1X`g@KPXWYK}T2^~Bm%M>nVPJ_9N72pwBmGDg^hX@kT4nlZ -}S0(hJl0$?DsErW9e!982Uhwomc3U%txm!&2l1<5`vn -7!xYT>zs==3m*9yz5P-A8>@ -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 -X)?_Z;v6+*a -&Lf5@H!aq*E+ORsdeHnT!>M++%PiTnCVkjuEou6W1P*jENUycAY89;AjQI2Z@bPY7J%xq0YV{-;J?KY -|WE4+cgWS|L8nN7)pff3}tSiISqzZ8m~8gWI=l+qFPc#Kkd$t@vzje!r2rgqKRL7$_$k=!jsf`+`T%tEroj#6@$S2wD`G%nUG7m5a; -G=Fa{={2Vu_Nct6E*XeKK;&_*qlbeX7^5`t1uVq`mBq208a+81eFXmmnepWBsHAcS3O1&%rgO6=H8+5 -B**e}Tn<5;H|Zo$Me856JB_bP;k_G`9?N6uCc?u16O2;`>#X1|;{HjwZ2vN!TwhP;&SbHe{d$QXd0Es -i5#))FpC@WT`h0yhxtT5ux$81XsOJ{9hc!Z~DwYg?lB#bqq?W -U^Lc_qpbYy1X6LdHEL*)gA;@S@8tHU*NWn5OE7QUSd>7UA&kvgz{jLW(tbR+sU!+Ua>mU!i!1wMCCeZ -abI+BWbrm5uUs1KLhoL78&Z$gNt4gANV -up#tHB;CfYLm~sSZ+(B2&ILa0iPK^5vkUIB~ZzR+{;#h%lCicp9E8)RE&*f#V!ieJz6 -C*V{tiE*C-9`;{JUtAh*oG^u)Eh%P;1q9s0f!84l40%Oz^0s$Op864GpsRrC5%*rWWwSXE5Bl}beS6+ -e|D^ZgUVX`1x(I#yGq>9LoBRR)<)_2Mv-PV0oS?G6SXbX)cAOdqJe#%2raLD9CAL6GNRK!~WP65V9E| -z>m?6aBo7Qg7BV<@jd0d>%Eyh)VMhH3RXc3Lr^&+kIDuY+wLE1saJ%7h^W`Vt84Oj6MCBnh|r!yrZD< -R{e(1E57fTX4sf4YMOv(UEjh>laPh9LnEtQLa47UwV3hd2WP6!FNSI1MaitvZ(Sm1lGS3fZ(~g;99ED -k?t!k6>DS<*?0Z4bUSn`+r>a%gEh6m}KN_3UvJRw+B_&X1v#zV1VX4(OK#WG!Y>KY=DE)6Pt0K8y8H~ye1 -DvuBX=wL*t{G;q$v6-Vg>M6H;-75yKSA2+!>8k3X_s=A7d6_!Dvmux04(sBJ_L6!~%>*M#G3xlz+D>N -2~CC&H+N(JF$cmhT9lr41swoXfXNDqfPtfWHy8> -IpXjT5B{u5m|E}L~ek*WiIbMma)9mcL{l=RGbyGSm~#P=EW71SNH5gWp>;2*OxiL7U_(ODAb4Bd7% -!1oQPsgC52!4c~G~8=yy0k*O`dfM;4oWdZ -3@x254ng~_0RQEdUXhpl^2#qD4Dp@wpJ)FyE{V>UbNYfzoV%cU)X8l8^cdki9sEIvm6h$Lfv4TCI7O^ -yuTxUmP73T;wk-zqLFj|Tq_P)h>@6aWAK2mlEMt4K}&E>Nxj001rl001Na003}la4%nWWo~3|axY_HV -`yb#Z*FvQZ)`7LUukY>bYEXCaCvQyK@Ng25JmSr#iSc{jc(kTXow3FJp-B40UDswOpCUpM-mGLtsg8_lcxNOGc?Db_6Z~Vf;YiO{s+kuyfb0-N~hv#{@n520RWFL%tA=(psyk!ZBm -1DmX)N6KCOp64@6S(SWXB2xW3{?1gP-SehBXNn(x`;b4wOb1Ibj4KR~n2qfF8|7TVMUl`K9ab3_Vu7C -9VZjRL#P)h>@6aWAK2mlEMt4P{ZMx_G+001Zj001BW003}la4%nWWo~3|axY_HV`yb#Z*FvQZ)`7LV{ -K$EaCwDOO>dkq5WVv&T;+g~R?dAT4rx*~hjdjno3mxXELa8GwN0Y-*LVEIvQ5%(0nfaddGnr;v(eU*N -e_cmLsgxuo{5o$@n=xIwnQd?gyG^;9l=`4SJe2Y~+4)bz&V2(OEJkmVB3RMo5T -o-!FqyVK;yrx&@j$#L-u@5>7a-h(m -;e%ziJCaV~W$k{@FL6TNL;4t(r~ftYdG2@7JS!T#v@*CC|APKgHV7sMuOJ8Q(?UJj(>hp%qd8Y%2F^* -_lA7~i9OreajV?CTUrc!Jc4cm4Z*nhVVPj}zV{d -MBa&K%eVPs)&bY*fbaCx0qQETKj5PsiZAug2lhQ%aYp91^P=5p6UDF@x-9!fD6iWf>reh!= -a!eMwpvLEx;J{{VP8Nl>c~~*L -ME%O8sJIpNb8!ZHQk7vlX252DuD^cUueWUd>FKwp{nPrd&C0n{aAZXxYGI2aGa!#!=HlM{u4>pilhNf -~7GU|a+3j_kGzp0)T<}X!2DHF%{{_8dae`J0V@0`SaLe_x*|OW)mrIud2i#)nvP8V`IH_h2M1I%J>y9Y?#qta*OuBw?;h>qGhGGPM%{DY}QVb;#g7ltFIzw=P0Q -MGZ77hp9&MNr -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 -=+`Yp;v^FV`;wSUIMF*IuO~2Uw -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 -ZFL16HuQl*u$KY8QYnEa8fFt}ib%K -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 -{qP(YrYI#hD-DTkQ)8W;64TY@N(S- -Jks~FFSu&F&(Z=KcIb3WSpak-@v1!oI9aM)UI;b8w~{h~wH@^sZ~I_qn*N96CMF< -4=$q85elqr--4{Cr@ycg|VucnO^-%C%-KI~v)86uKzGGLqz3{sC-= -{25b_^(VCt2`|-<&T=ptMI{__NnM#*NKoOwk}y5o0=vU -?L!wP~N2_Q!*Vb0M9uO*$42g!Q=74ooWZhvWWs7P@YCa@OC_p7Tx28mBsxoixvAyMtFc;f^dUIILE?KH#`oMX__>)SLLW^D(aN%T3 -!qUY0@~0dzwX(yJTqIn>@}%L2tVE|xnnN}Pqaq8~1^h13XBn^=TmkoCOrdDr!VNB-8&=-~wgt%Fyeaq -r1M$-k`t!5#9aqU}Jgg&6Uj)U;JDs?PEtnIqJ@WxS;7>$?aIzm~X@XhjoozUC~ -T_MkbG-@N|nSfX5zXZ%)t^c)6l!E4Rx5{7h}>h=Yi?V1F^cdTG{ir;8un{LroLLzp0s@c#$M6)a3+4Y -M9~3JHeolrUc@Qi%&xr%fNuNsUukFVf~`(OC6ap0+&Lsk+E#%E$5Pj@iSlLBc9k;KW&F*|`J<>)=07O -9KQH0000800{-FNPX7WFNFdC0DT7l03rYY0B~t=FJE?LZe(wAFJo_PZ*pO6VJ~TJX>@5}Y-w|4E^v8` -R85c5Fc7`xSB%udrmGOa773|RB%lH8Z~#=f&7?Irb~Sb=`S;k)my_+%ja1d18IRwadE*(Z)UhV -MKN$F|Br^vl;S(bxu!ftLuNEo1zyAP>7c0RkjUf0ArN#s_B7C`Bt?%yFP;h~1#LJ^11I*xGZo1u0NxL -_KZ##>wbrcn(N=TX1+^7?miyLUY@2u%TeRHNLij)Pm(I!`wfeh}H#NLENohdpIJ03BgL*EB{d=LdOu)E735;74Mg|2*An0|IZmb8Zq_o+ -LoK3S^XMfaIVu*LG^lj|vo|lmGpd~!S1v#?Ehko>NT~6yGWMw?!HLiLBK||b``2?^d^gqCXx}OBx#5_ -858L*9e2H>!T!GMJt49o+%#%Ptl=h`5}{Rqq_A>F(>akaydqaTl9Rzr1!vwFUHLG(01Tkt0nG_@P4Gk -QC`bZNURnPht;4|b=7B381EbHvwnPILm-*S}A0)lqPCrY`R>R1Y9(jw1KFf=gSfz}1zcf6yisnh5rCk -;Vtkq#Y!&_iP)h>@6aWAK2mlEMt4IxPqCHFn008wA0012T003}la4%nWWo~3|axY_VY;SU5ZDB8 -WX>KzzE^v9xS8Z>jI1v7xUtzV{3+z$Umv7beLz<@7oU%z3vT9eW6(TSc?g)--(=>Ow|9)dkz$8F7TAj -40ipuzz@jS!KczjBRbiHNqOC-o2H1$N}rR2e5B7rTrJioZI$*prouFi&umvJCT>_f`IA{>BB#>3In8I -dqf!0z=JFv$&gmUXUAdc5xwJM)P_8a@p$%w!Q8VeB#xbGC_Q?T?5Z0xe@igTb;y9= -CSRBdI7K;gqs9Og4SwFhElVi;`DAZG26_cvf2*bd{_%&41{3uG7~Ujj53Rib6oynUf1`V(iC!u17b*1 -P7_$bnk5tf-a2*#aM6H(gu`{4x63Vq&?1gtnJ+SivG-j+^3cu4*?77b&3n1#a5kI -Yq3sHW+otrvXdb8>RRb1{iiY1XHy9oZ-?5pEbm5^x -u{{aOm;W1IvVC;l5wEajJJ9V`zs#wmNGJQgucTY;N0vK%obxu+&9XZYa*y3v8or@>_6&PT&VgAbp)w| -JGUlvazs-^%|22#M=rNL<(8kc3*O -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 -`E(OG*hf@6aWAK2 -mlEMt4P93*vL;R007jU0012T003}la4%nWWo~3|axY|Qb98KJVlQKFZE#_9E^vA6eOr6mII`e-eg%#^ -IiehyiJh6gct^dHcARPNq~n~}>Fzy_M@@^A%{E1Ei=H%PA=cjMZ@JDF(U|tprF`dqvx+#ps>qr;nez7s59IGfr9PF3`c$ndcs|Gf! -@H!c0KaLP&x?yEm*UUj>7fuiVp&Gli|A0~1)vO#9tilOvCjbi{QBfnjjX;?i;BKX>(w%;#6(<0Rl+}w ->2aOBujwH?Yw{F`pR|CvlZ5&r&60C$7*%2ppQ#tMl?|rTt0v9rGzS_$8~LR&n;aGSJiUG$l@*e{LfXU -kC4Fy)4X0Pp?3Owx`rI^inzb#OGNVrFx%xiFPfgx_{-sEB`cN&iw3d(Qvjr?muN0^NzLp74D7ga=zcx -ipGoI2X0FbK>Wp=QP>YF@TBuq-*pK5Uc&Br+X8ieb-D9&zRtmCpQ%CR7HkeH(f1Sd}BJZ?~p4mD(uj< -f=D)=@r#Cu7AeqY1Ga_*jKWj*o&Nt)byaw}6Q?WiBTJ;2)UGVLBYRggMZZd6WFwfbh`Vi)8|Yef#F6o -WytM=dbx=FDh_#@}CW>VIbSv%b7Z;~?&%%9#bvT`|de*BLnX-F)7#SbmTzB~W^rIc>jvi-##XOV#*r1c7z%`2pg?qSIXVy69O;|_&O`DqO8TpTmUVaX+?P2vfrvz^H+0jWfIPc#S*kvF#LNE?vDO~|N -LPn7Xtn*qIz}%O?9`Be&P9>$k5M+YDv+z@VYFTWw1XM2c0#C=#B+KdGH_s!f(*&X|atqhp$~V4nO+fo -I{h7(;v%dxzu9QRpLIa-O6BRpg4KZ#{19age>7A(YUu~9D>9Aq3K5vH9*lF=VvN%wdOy7fshLG(Tut_E1Mxj}0Cgpz*$iYFKY -?5R=EaftV*lVzuvkGj(G&k7{A2jE)xanhhmRi<6~i>C=V4J^KSnk@F6T4sx+8n;aZh7NV-htg$csx;W -#>gL;@mbGQa!(DjUjh0sehb@q*8)mXt+^XGOrf}YKbygL@5Ol6Yc{Z`&fS3KfG)KY9c{H)g5e6lLP48 -p=DUwrr+4~wGrPDCshSr$j<(kUw`%FP(*h{8Ux4j6s95yu-!>{2jP&oS^=8#rb7BJ3dcI`K$sNe0*sR -$SD<~EqZDBvB*ZgOH6;kr3W371jIOe@x=CUO&a%kT*-Au<0>(Kzdx>J4Q7}dERt4swxQECrtx*cDz!7 -l(lu1+-IY3Xu?4|(BLz_`i3TCAv!X2$P0Koyem)+56S*a1V50I -tmv*3bfGBxVpdiTlSz3)B^?9yUq&0zt}P`eiT@lZjqH>Nj)bp7jA=n=8;R3$Y&>t2P5FuRG$XomU<-KG*404h;r(N&U(0$P?V -J=h0@dJmC)6WYkar=ip1H?h%ybq{<=`uGSWP8L -^590LTL0!RQb@Y$Z^sSE3^dN9XkDGXfZw49?X{Re=E3)FwKnEJ^LUUyQ>qGb{&5|2#MSBXo%QxvWH6d -gqr3(fjmvs^mDW08c{p}4=l-~E))!NFK&5rom@@ZBy(BPb7`g{^fM#W4|)RXi$BC0G!Ar`I`XQekNs- -FIHF{>0^_4aDIV2x2D_320>pEr9Q{A*!t5yKoqy2OS`!5&!g`Lo`U?$zNZczBxX6c6QteLZGn`&LG!D -hNZV1jsP`gYwK;({zFox^A)cCTI^!OT@k=+;{@{#F;W14q4&fC!%tT$-5-p@Suwj!>hLyvR_a#$)hh!7+M`OoRnf$;YUs!50CiiE%UsX{qj-7+f -=Cd}1(@VjiVg15_5LIV@)YR)IJm!t*AlweHZo!cox+3QhXC#sewf*bOpwt%DW}Smyyg=-IV6jvc;<94 -r^=$$qnb34l7=j^a$X`HbKFjJ?&=>?dFmwg7>^51A#T}9bmlt$*asYE|L52pbyklx)1IME!U|vDD_(9z4 -JW{h#iI`WjKm(O42N06NqMY>{=?0IL8)Q+ -MN!*>Yht|!NpENt-r=Nc6I{CO@d1;XC2ajroA`4+6c~l)NTX2g8+JlGKz?uuaZ45TEgGSQgM^Co@EUv -4+9`38Z%~Aj%WT0A&z_H)=t@e&{JJ5^zp>HJ);v%W?p>&SaiUnK!u>rjJa8?*Vh_%*#=)(q<5U{5#z>mcspp_fcS|0T51qJbXl7U{mPsq+Rc|1VdX-DtYh -RGQPK-jlhg0q3v5Hlp;4kZ5AeM9iB`UVBzWgBGu`I)%J{0*$Lnd(4mnbgUm$?6o4;zeDNsgTi_GSpt6 -ln0oC7gSHD0lVb8ySMjId0n~t5AZOYPUCb2O~Ymx1E~YqvO_LTAS!xsB=RYQ*}-N;jLA^tp5IfM89>| -#bTC_hW3T`RWl~h(i|Og##QzcS=_mL!rVQQ$le9Kn|C-(b-6E0}a6Y*InEa4Pync56o%Tefi-r0QVL5 ->n=1{>Ys?^5hHNiz-=v2^FB>)?vC?XQ5GEChMo<<7`6Ss!H@-ssD8Rr=RdufEu{6g_FB4xK?@3w-k8W -!k|0=JLu(z3|OP7DyQ!H6)Mfw2ZrLHd^|gb+D<3P2rD*vQW$Dh)hwL<3J#pmxL&nmKkLBS{UMml`7?& -q@X<$Ee9rxc+~OwY)8V9ni$bCEETnM<@Du_nYX+#J^z1_%lcdw&}Qo_@h@u^x -m5Ro~F)8NJ)q)S&l3ev1|YW+8Q+sn&-iNnm;`l_`J;y-9;VUqPWA?1XlDcF_uQ?5?P8L0D=DK6Lx2!F -b~tb!m#eixb0zJ%@`r6j(<9*MVcV{B1EW=%eW5_1dm~gpY#9^s6QBtJVdic#{v)5v!((vvke75P2Pj= -iZLUx1cpv>EYYaCSsa+ieVSz=!Z;NCSDZ|BLz`%6Icw6I?Jy5FSdygzeUJ=XP_T@lF?vXh(3pdR8n;D -Az0r1Jp%Nvq=EY4U7g{Gepk`fe!VGbts3atiGUl`v%uxL}BIg0AEx8RJebGXiabySZF0H3s|IBSK9uf -TRI4vM=k|>ucNG17|SQQPB3|&g51%};~$kN+HOB&KA0R}<2sIKhxz4!n%@mT_{iBwnf*R ->plp-)9?G>lTNSqW;g+UKb5@su$g5Ah`g)5t-= -aenxxSL#}60`6f-h*F>N1^R>t5jx`z2UlrzcZrP;se1QjZ{rLWMyNUS${LpIyu8LsK;qk|!bBwKFCr` -;4O-)sV>_Fmzq>}=!wyg&jwkbA@UMKWGf+b1h>Y1>Ls8%F4)YKN~^uUA7-u5G2JS01>f1p(LCc}G8kI|aK7|i`m75w?JIoBcBiiPj=#Oe7%$BI2 -f9*)KBFlJhP3K#V6f7+l3OZ&?3<^mK80D1ke60K1d5Vf_DcQ`aZ}Jgucc@4FP0i$4%~i$8h8)IJ!bCZ -o5UatHC7ZjdYLrBNCY1x)PO}5Z(&B1X8M1_raYS@LhA8W^nt5;gPiLeR2*(3Sxh0Sk#s)KC0AHR% -x`JnTKq%MkH9Xb?II`U0#Nr$X=ac2$lY=jwBEjd-pAxVLh9{tUm1?}Yr8r41@h-|zE(EF)WHl*m3pBubg{NIewo-fQ0FCy%S{(Uo$zGLSQ{9%@J~HWy8Hp -CJ)SwS#2$ASsIwo(qvI==>y6six{h-Cw-<=NTM$q8*oVO5~46rhnkz_656 -F+d^Ettdb&r!p>_&^P4`bxu$jM1IQ$uz30&5yHPkg%Ag7`2o_4w4zE}A33_4(CNIDM7fjI-G{IT8-Dm -mDZCH=R(>3YT!|IHqp^n6P0O)1FmwTSaI*C(FFt8qpAZEGbbMeC(3_Q%eS0O*S};W7b}@C_B?%1605* -#!Id;fYa^wO9*UQkC)TW1%GP{43+(dUNm{VZnBAr!M?f|nypv-HhBvTT?WaK6$4x0QnFYen_!==g{+o -NaT&9bk7r$yvYeE2v}Vxl94wNAR9i1@dS9HsMCiF(Vkh-I8B`8TaJ`znuq6I0}X+cBmk=1>!HSr%7Wv -XF4d9L%1tyHX7s#loN_fe -4E=xc^w8c=a_y;&zf?HX)nACu-N#sj=(F9X4J0UT(8J4d9l-!L^|bbWW(7^QpLB>eH`{=>3%dt-hV}W -_k0mz7NPbj|hCAW~mWdSk9adifO8+kYy!Vax_h_;FA0nw|p%cdAp&k`jbMDC(xf@~nb?P+t9hyHpz*F -UVb&#>7jvad7u#Aa9~H|2|rNM*5)x>wDgG1ZYdW1}zIm9kFuHIJ{mavj -zxphsJqHhdihDPn-$tA+cOtZXeV+85N98hG7`Gx#r=})*k2&Q9#JY7H%=hAZ&DO+h(2^EGfIFMcx93F -=S^vS0PZqi#}9-JypX5c&b!?^VWeZ%W2uN`L`lD)8k;dF+Oi3HsVhDOSL9q*+ -5NOfZxc#YV=2!m)DB9zNA^gL8eWeym#R}G -}ItcAgu&$U3f$yz8;4t9kP;B5eAwH%JYE%f+z%oz#ZW{oxuot0?rwkj1F*!s;_Rj|IrGu=+mk@~8wT* -WQuN(7)WxcXzk0ICtF8L(M@zBjOxP`< ->hOSLHfi@|fG6T%DG+eDvPm^mQ$!FKo%K6(NJ}06qhJu@Y2kJQ=^y?cX -CRlsTyCfTn{SzxeR+vTe5`qU=6e!SEn9NmNk<7b4{nMR3&|Egg4oVdAt#AAh6BQ1y2i2vOeJdXtQ*GHUW+(KzpF298E@ -f@RX+q@?b;iK-h__K#Jq1x!wFmbc@kn+jd9LGQ>eG&TEvf842D6^6#S;Pgy8p% -XZbuGe@>SMB9)5TNqD3+@ooYkUoAo%Km8$Dl5UI(!-S}jbz+zfGD7$H!lF=2G}pun7Bwd+?o~3m5-joSIK>g7@tvNFF_)i!*lG6ej -E}AuS4SXdhH@~>yF_|Huu{#$Gi{W6X&!jUzQq -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 -Ty;n`8+AZqd2b<}?743WJ?xDG ->yb;j=E!!I9O`Dab+ExueRcli%@PBLVf9+L^fA-2vMuv9uYSPSro -Mi5et!Jsm2h7A`}g2~zW(#F1i0_N!I<;%(dh8;WBifNYJC&#j{abtYW(r^>MhcrW$4`$r@A-`E3we -%e3$yU^d~Vj(^<>R}@gr+KB&1ViO6b!14|eto%*Rk1(*LU6(3ZjBY<^8m;D1k( -k7hFI$3!gB>l?7MQC_R?D{2iFzkP{bSjIVrEWK*|7y5;EC^6ff&NvelOsD+B^8yv?I0{Rb_cbA$=9sw -Qp6t=r!BPim{a6^lqA0SvL?YnUwwU6Ne{Z<8j-v5}gS2ESdAp6y6Uc-N-FU2GUK>lgsxVwToV8u4^_AdvU#OSl1S32g+-ZvCr)CQ5h%T*C;wv6v+