Merge branch 'reject-request-fix'
diff --git a/app/app.yaml.template b/app/app.yaml.template
index e7d5374..accfeaa 100644
--- a/app/app.yaml.template
+++ b/app/app.yaml.template
@@ -15,7 +15,7 @@
# TODO(proto): uncomment and supply a Google App Engine application instance
# application: FIXME
# TODO(release): see the instructions in README about the "version:" field
-version: 2-0-20121207
+version: 2-0-20121217
runtime: python
api_version: 1
diff --git a/app/soc/content/css/v2/gci/account_deletion.css b/app/soc/content/css/v2/gci/account_deletion.css
new file mode 100644
index 0000000..96e2d99
--- /dev/null
+++ b/app/soc/content/css/v2/gci/account_deletion.css
@@ -0,0 +1,7 @@
+.delete-info {
+ color: #000;
+}
+
+.moderate-delete-message {
+ padding: 10px 0 10px 0;
+}
diff --git a/app/soc/logic/delete_account.py b/app/soc/logic/delete_account.py
index 035a47e..789b015 100644
--- a/app/soc/logic/delete_account.py
+++ b/app/soc/logic/delete_account.py
@@ -19,9 +19,16 @@
from google.appengine.api import mail
+from google.appengine.ext import db
from soc.logic import accounts
from soc.logic import system
+from soc.logic import user as user_logic
+from soc.models import user
+
+from soc.modules.gci.logic import profile as profile_logic
+from soc.modules.gci.models import comment
+from soc.modules.gci.models import task
ADMIN_REQUEST_EMAIL_SUBJEST = """
@@ -39,7 +46,7 @@
def request_account_deletion(user):
"""Requests deletion of user's account from application administrators
by sending them an email.
-
+
This is a temporary method, until we have an automated solution.
"""
account = accounts.getCurrentAccount(normalize=False)
@@ -55,3 +62,85 @@
}
mail.send_mail_to_admins(sender, subject, body)
+
+
+def confirm_delete(profile):
+ """Deletes the given profile entity and also the user entity if possible.
+
+ 1. Deletes the profile.
+ 2. Deletes the user entity if no other profiles exist for the user.
+ 3. Removes the user from task notification subscription lists
+ 4. Replaces GCITask created_by, modified_by, student and GCIComment
+ created_by properties with dummy "melange_deleted_user" profile or user
+ entity.
+
+ This method implements a giant XG transaction, but should not take a long
+ time because experience has shown that there won't be too much data to
+ modify or delete.
+
+ Args:
+ profile: GCIProfile entity of the user.
+ """
+ profile_key = profile.key()
+
+ # Cannot delete the user entity if the user has other profiles, so set it
+ # to False in that case.
+ user_delete = not (profile_logic.hasOtherGCIProfiles(profile) or
+ profile_logic.hasOtherGCIProfiles(profile))
+
+ task_sub_q = task.GCITask.all().filter('subscribers', profile)
+
+ tasks_created_by_q = task.GCITask.all().filter('created_by', profile)
+
+ tasks_modified_by_q = task.GCITask.all().filter('modified_by', profile)
+
+ tasks_student_q = task.GCITask.all().filter('student', profile)
+
+ comments_created_by_q = comment.GCIComment.all().filter(
+ 'created_by', profile.user)
+
+ dummy_user = user_logic.getOrCreateDummyMelangeDeletedUser()
+ dummy_profile = profile_logic.getOrCreateDummyMelangeDeletedProfile(
+ profile.scope)
+
+ options = db.create_transaction_options(xg=True)
+
+ def delete_account_txn():
+ entities_to_save = set([])
+ entities_to_del = set([])
+
+ # The batch size for query.run() is 20, in most of the cases we have
+ # seen so far the user had a few tasks with subscriptions, created_by,
+ # modified_by etc., so this should still be single datastore hits per
+ # loop.
+ for task in task_sub_q.run():
+ task.subscribers.remove(profile_key)
+ entities_to_save.add(task)
+
+ for task in tasks_created_by_q.run():
+ task.created_by = dummy_profile
+ entities_to_save.add(task)
+
+ for task in tasks_modified_by_q.run():
+ task.modified_by = dummy_profile
+ entities_to_save.add(task)
+
+ for task in tasks_student_q.run():
+ task.student = dummy_profile
+ entities_to_save.add(task)
+
+ for comment in comments_created_by_q.run():
+ comment.created_by = dummy_user
+ entities_to_save.add(comment)
+
+ if profile.student_info:
+ entities_to_del.add(profile.student_info)
+ entities_to_del.add(profile)
+
+ if user_delete:
+ entities_to_del.add(profile.parent())
+
+ db.put(entities_to_save)
+ db.delete(entities_to_del)
+
+ db.run_in_transaction_options(options, delete_account_txn)
diff --git a/app/soc/logic/user.py b/app/soc/logic/user.py
index 0d411f7..9a4bcbb 100644
--- a/app/soc/logic/user.py
+++ b/app/soc/logic/user.py
@@ -19,17 +19,20 @@
from google.appengine.api import users
-from google.appengine.ext import db
+from google.appengine.runtime import apiproxy_errors
from soc.logic import accounts
+from soc.logic import exceptions
+from soc.models import user
-from soc.models.user import User
+
+MELANGE_DELETED_USER = 'melange_deleted_user'
def isFormerAccount(account):
"""Returns true if account is a former account of some User.
"""
- return User.all().filter('former_accounts', account).count() > 0
+ return user.User.all().filter('former_accounts', account).count() > 0
def forCurrentAccount():
@@ -41,25 +44,24 @@
If there is no user logged in, or they have no valid associated User
entity, None is returned.
"""
-
account = accounts.getCurrentAccount()
if not account:
return None
- user = forAccount(account)
+ user_ent = forAccount(account)
- if user and not user.user_id and account.user_id():
- from google.appengine.runtime.apiproxy_errors import CapabilityDisabledError
+ if user_ent and not user_ent.user_id and account.user_id():
# update the user id that was added to GAE after Melange was launched
try:
- user.user_id = account.user_id()
- user.put()
- except CapabilityDisabledError, e:
+ user_ent.user_id = account.user_id()
+ user_ent.put()
+ except apiproxy_errors.CapabilityDisabledError, _:
# readonly mode, that's fine
pass
- return user
+ return user_ent
+
def forCurrentUserId():
"""Retrieves the user entity for the currently logged in user id.
@@ -67,26 +69,26 @@
If there is no user logged in, or they have no valid associated User
entity, None is returned.
"""
-
user_id = accounts.getCurrentUserId()
if not user_id:
return None
- user = forUserId(user_id)
+ user_ent = forUserId(user_id)
current_account = accounts.getCurrentAccount()
- if user and (str(user.account) != str(current_account)):
+ if user_ent and (str(user_ent.account) != str(current_account)):
# The account of the user has changed, we use this account to send system
# emails to.
try:
- user.account = current_account
- user.put()
- except CapabilityDisabledError, e:
+ user_ent.account = current_account
+ user_ent.put()
+ except apiproxy_errors.CapabilityDisabledError, _:
# readonly mode, that's fine
pass
- return user
+ return user_ent
+
def current():
"""Retrieves the user entity for the currently logged in user.
@@ -95,10 +97,10 @@
The User entity of the logged in user or None if not available.
"""
# look up with the unique id first
- user = forCurrentUserId()
+ user_ent = forCurrentUserId()
- if user:
- return user
+ if user_ent:
+ return user_ent
# look up using the account address thereby setting the unique id
return forCurrentAccount()
@@ -110,13 +112,12 @@
If there is no user logged in, or they have no valid associated User
entity, None is returned.
"""
-
if not account:
- raise base.InvalidArgumentError("Missing argument 'account'")
+ raise exceptions.BadRequest("Missing argument 'account'")
account = accounts.normalizeAccount(account)
- q = User.all()
+ q = user.User.all()
q.filter('account', account)
q.filter('status', 'valid')
return q.get()
@@ -128,38 +129,52 @@
If there is no user logged in, or they have no valid associated User
entity, None is returned.
"""
-
if not user_id:
- raise base.InvalidArgumentError("Missing argument 'user_id'")
+ raise exceptions.BadRequest("Missing argument 'user_id'")
- q = User.all()
+ q = user.User.all()
q.filter('user_id', user_id)
q.filter('status', 'valid')
return q.get()
-def isDeveloper(account=None, user=None):
+def isDeveloper(account=None, user_ent=None):
"""Returns true iff the specified user is a Developer.
Args:
account: if not supplied, defaults to the current account
- user: if not specified, defaults to the current user
+ user_ent: if not specified, defaults to the current user
"""
-
current = accounts.getCurrentAccount()
if not account:
# default account to the current logged in account
account = current
- if account and (not user):
+ if account and (not user_ent):
# default user to the current logged in user
- user = forAccount(account)
+ user_ent = forAccount(account)
# pylint: disable=E1103
- if user and user.is_developer:
+ if user_ent and user_ent.is_developer:
return True
if account and (account == current):
return users.is_current_user_admin()
+
+def getOrCreateDummyMelangeDeletedUser():
+ """Fetches or creates the dummy melange deleted user entity.
+ """
+ q = user.User.all().filter('link_id', MELANGE_DELETED_USER)
+ user_ent = q.get()
+
+ # If the requested user does not exist, create one.
+ if not user_ent:
+ account = users.User(email=MELANGE_DELETED_USER)
+ user_ent = user.User(
+ key_name=MELANGE_DELETED_USER, account=account,
+ link_id=MELANGE_DELETED_USER)
+ user_ent.put()
+
+ return user_ent
diff --git a/app/soc/modules/gci/callback.py b/app/soc/modules/gci/callback.py
index 9962de4..627dc29 100644
--- a/app/soc/modules/gci/callback.py
+++ b/app/soc/modules/gci/callback.py
@@ -43,6 +43,7 @@
from soc.modules.gci.views import homepage
from soc.modules.gci.views import invite
from soc.modules.gci.views import leaderboard
+ from soc.modules.gci.views import moderate_delete_account
from soc.modules.gci.views import org_app
from soc.modules.gci.views import org_home
from soc.modules.gci.views import org_profile
@@ -78,6 +79,7 @@
self.views.append(invite.ListUserInvitesPage())
self.views.append(leaderboard.LeaderboardPage())
self.views.append(leaderboard.StudentTasksPage())
+ self.views.append(moderate_delete_account.ModerateDeleteAccountPage())
self.views.append(org_app.GCIOrgAppEditPage())
self.views.append(org_profile.OrgProfilePage())
self.views.append(org_app.GCIOrgAppPreviewPage())
diff --git a/app/soc/modules/gci/logic/profile.py b/app/soc/modules/gci/logic/profile.py
index 1c85d07..bd779bc 100644
--- a/app/soc/modules/gci/logic/profile.py
+++ b/app/soc/modules/gci/logic/profile.py
@@ -18,12 +18,38 @@
"""
+import datetime
+
+from soc.logic import user as user_logic
from soc.tasks import mailer
+from soc.modules.gsoc.models import profile as gsoc_profile_model
+
from soc.modules.gci.logic.helper import notifications
-from soc.modules.gci.models.profile import GCIProfile
-from soc.modules.gci.models.profile import GCIStudentInfo
-from soc.modules.gci.models.task import GCITask
+from soc.modules.gci.models import comment as comment_model
+from soc.modules.gci.models import profile as profile_model
+from soc.modules.gci.models import task as task_model
+
+
+MELANGE_DELETED_USER_PNAME = 'Melange Deleted User'
+
+MELANGE_DELETED_USER_GNAME = 'Melange Deleted User GName'
+
+MELANGE_DELETED_USER_SNAME = 'Melange Deleted User Surname'
+
+MELANGE_DELETED_USER_EMAIL = 'melange_deleted_user@gmail.com'
+
+MELANGE_DELETED_USER_RES_STREET = 'No address'
+
+MELANGE_DELETED_USER_RES_CITY = 'No city'
+
+MELANGE_DELETED_USER_RES_COUNTY = 'United States'
+
+MELANGE_DELETED_USER_RES_POSTAL_CODE = '00000'
+
+MELANGE_DELETED_USER_PHONE = '0000000000'
+
+MELANGE_DELETED_USER_BIRTH_DATE = datetime.datetime(1, 1, 1)
def hasStudentFormsUploaded(student):
@@ -46,7 +72,7 @@
"""
# get all mentors keys first
- query = GCIProfile.all(keys_only=keys_only)
+ query = profile_model.GCIProfile.all(keys_only=keys_only)
query.filter('mentor_for', org)
mentors = query.fetch(limit=limit)
@@ -84,7 +110,7 @@
Args:
org: The GCIOrganization entity for which the admins should be found.
"""
- query = GCIProfile.all()
+ query = profile_model.GCIProfile.all()
query.filter('org_admin_for', org)
return query.fetch(limit)
@@ -98,7 +124,7 @@
user: User entity for which the profile should be found
program: GCIProgram entity for which the profile should be found
"""
- return GCIProfile.all().ancestor(user).filter('scope = ', program)
+ return profile_model.GCIProfile.all().ancestor(user).filter('scope = ', program)
def queryStudentInfoForParent(parent):
@@ -108,4 +134,114 @@
Args:
parent: GCIProfile entity which is the parent of the entity to retrieve
"""
- return GCIStudentInfo.all().ancestor(parent)
+ return profile_model.GCIStudentInfo.all().ancestor(parent)
+
+
+def hasTasks(profile):
+ """Returns True if the given student profile has been assigned to a task.
+
+ Assign also means the tasks completed by the student.
+
+ Args:
+ profile: GCIProfile entity of the student.
+ """
+ q = task_model.GCITask.all()
+ q.filter('student', profile)
+ return q.count() > 0
+
+
+def hasCreatedOrModifiedTask(profile):
+ """Returns True if the given user has created or modified a task.
+
+ Args:
+ profile: GCIProfile entity of the user.
+ """
+ q = task_model.GCITask.all()
+ q.filter('created_by', profile)
+ if q.count() > 0:
+ return True
+
+ q = task_model.GCITask.all()
+ q.filter('modified_by', profile)
+ return q.count() > 0
+
+
+def hasTaskComments(profile):
+ """Returns True if the given profile has task comments associated with it.
+
+ Args:
+ profile: GCIProfile entity of the user.
+ """
+ user = profile.parent()
+
+ q = comment_model.GCIComment.all()
+ q.filter('created_by', user)
+
+ return q.count() > 0
+
+
+def hasOtherGCIProfiles(profile):
+ """Returns True if the given user had profiles in previous GCIs.
+
+ Args:
+ profile: GCIProfile entity of the user.
+ """
+ user = profile.parent()
+
+ q = profile_model.GCIProfile.all()
+ q.ancestor(user)
+
+ # We check for > 1 not > 0 because we already know that there is one profile
+ # for this program. So we need to check if there are others.
+ return q.count() > 1
+
+
+def hasOtherGSoCProfiles(profile):
+ """Returns True if the given user has profiles in previous GSoCs.
+
+ Args:
+ profile: GCIProfile entity of the user.
+ """
+ user = profile.parent()
+
+ q = gsoc_profile_model.GSoCProfile.all()
+ q.ancestor(user)
+
+ return q.count() > 0
+
+
+def getOrCreateDummyMelangeDeletedProfile(program):
+ """Fetches or creates the dummy melange deleted profile for the given program.
+
+ Args:
+ program: The program entity for which the dummy profile should be fetched
+ or created.
+ """
+ q = profile_model.GCIProfile.all()
+ q.filter('link_id', user_logic.MELANGE_DELETED_USER)
+ q.filter('scope', program)
+ profile_ent = q.get()
+
+ # If the requested user does not exist, create one.
+ if not profile_ent:
+ user_ent = user_logic.getOrCreateDummyMelangeDeletedUser()
+ key_name = '%s/%s' % (program.key(), user_logic.MELANGE_DELETED_USER)
+
+ profile_ent = profile_model.GCIProfile(
+ parent=user_ent, key_name=key_name,
+ link_id=user_logic.MELANGE_DELETED_USER, scope=program,
+ scope_path=program.key().id_or_name(), user=user_ent,
+ public_name=MELANGE_DELETED_USER_PNAME,
+ given_name=MELANGE_DELETED_USER_GNAME,
+ surname=MELANGE_DELETED_USER_SNAME,
+ email=MELANGE_DELETED_USER_EMAIL,
+ res_street=MELANGE_DELETED_USER_RES_STREET,
+ res_city=MELANGE_DELETED_USER_RES_CITY,
+ res_country=MELANGE_DELETED_USER_RES_COUNTY,
+ res_postalcode=MELANGE_DELETED_USER_RES_POSTAL_CODE,
+ phone=MELANGE_DELETED_USER_PHONE,
+ birth_date=MELANGE_DELETED_USER_BIRTH_DATE)
+
+ profile_ent.put()
+
+ return profile_ent
diff --git a/app/soc/modules/gci/views/moderate_delete_account.py b/app/soc/modules/gci/views/moderate_delete_account.py
new file mode 100644
index 0000000..c8932ab
--- /dev/null
+++ b/app/soc/modules/gci/views/moderate_delete_account.py
@@ -0,0 +1,60 @@
+# Copyright 2011 the Melange authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Module for the GCI delete account page."""
+
+from soc.logic import delete_account
+
+from soc.views.helper import url_patterns
+
+from soc.modules.gci.logic import profile as profile_logic
+from soc.modules.gci.views import base
+from soc.modules.gci.views.helper.url_patterns import url
+
+
+class ModerateDeleteAccountPage(base.GCIRequestHandler):
+ """View for the GCI delete account page.
+ """
+
+ def templatePath(self):
+ return 'v2/modules/gci/moderate_delete_account/base.html'
+
+ def djangoURLPatterns(self):
+ return [
+ url(r'admin/delete_account/%s$' % url_patterns.PROFILE,
+ self, name='gci_moderate_delete_account')
+ ]
+
+ def checkAccess(self):
+ self.check.isHost()
+
+ self.mutator.profileFromKwargs()
+
+ def context(self):
+ profile = self.data.url_profile
+
+ return {
+ 'page_name': 'Moderate delete account requests',
+ 'profile': profile,
+ 'has_tasks': profile_logic.hasTasks(profile),
+ 'has_created_or_modified_tasks': profile_logic.hasCreatedOrModifiedTask(
+ profile),
+ 'has_task_comments': profile_logic.hasTaskComments(profile),
+ 'has_other_gci_profiles': profile_logic.hasOtherGCIProfiles(profile),
+ 'has_other_gsoc_profiles': profile_logic.hasOtherGSoCProfiles(profile),
+ }
+
+ def post(self):
+ delete_account.confirm_delete(self.data.url_profile)
+ self.redirect.program().to('gci_moderate_delete_account', validated=True)
diff --git a/app/soc/templates/v2/modules/gci/moderate_delete_account/base.html b/app/soc/templates/v2/modules/gci/moderate_delete_account/base.html
new file mode 100644
index 0000000..6d7322f
--- /dev/null
+++ b/app/soc/templates/v2/modules/gci/moderate_delete_account/base.html
@@ -0,0 +1,73 @@
+{% extends "v2/modules/gci/base.html" %}
+{% comment %}
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+{% endcomment %}
+
+{% block stylesheets %}
+ {{ block.super }}
+ <link rel="stylesheet" type="text/css" media="screen" href="/soc/content/{{ app_version }}/css/v2/gci/account_deletion.css" />
+{% endblock stylesheets %}
+
+{% block page_content %}
+{% if posted %}
+<div class="block block-user-message">
+ <p>You have requested your account to be deleted. All your information will be removed shortly.</p>
+</div>
+{% endif %}
+<div class="block block-page block-delete-account">
+ <div class="block-form-title">
+ <span class="title">Moderate account delete request</span>
+ </div>
+ <div class="block-content clearfix">
+ <div class="delete-info">
+ Name: {{ profile.name }}
+ </div>
+ <div class="delete-info">
+ Link ID: {{ profile.link_id }}
+ </div>
+ <div class="delete-info">
+ User has tasks assigned/closed: {{ has_tasks|yesno:"Yes,No,Maybe" }}
+ </div>
+ <div class="delete-info">
+ User has created or modified tasks: {{ has_created_or_modified_tasks|yesno:"Yes,No,Maybe" }}
+ </div>
+ <div class="delete-info">
+ User has task comments: {{ has_task_comments|yesno:"Yes,No,Maybe" }}
+ </div>
+ <div class="delete-info">
+ User has profiles in previous GCIs: {{ has_other_gci_profiles|yesno:"Yes,No,Maybe" }}
+ </div>
+ <div class="delete-info">
+ User has profiles in previous GSoCs: {{ has_other_gsoc_profiles|yesno:"Yes,No,Maybe" }}
+ </div>
+ <div class="moderate-delete-message">
+ {% if has_tasks or has_created_or_modified_tasks or has_task_comments %}
+ Deleting this profile will replace the current user's profile or
+ user entity with a dummy melange_deleted_user entity.
+ {% endif %}
+ </div>
+ <div class="moderate-delete-message">
+ {% if has_other_gci_profiles or has_other_gsoc_profiles %}
+ Confirming delete will only delete the profile entity for this program
+ and not the corresponding user entity because the user has profiles
+ for other programs.
+ {% endif %}
+ </div>
+ <form action="#" method="post" class="clearfix">
+ <p class="delete-btn-p">
+ <input value="Confirm delete" class="delete-btn" type="submit">
+ </p>
+ </form>
+ </div>
+</div>
+{% endblock page_content %}
\ No newline at end of file