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