Commit d347d51e authored by Kostas Papadimitriou's avatar Kostas Papadimitriou
Browse files

Merge branch 'latest-quota' of https://code.grnet.gr/git/synnefo into latest-quota

Conflicts:
	snf-astakos-app/astakos/im/endpoints/qh.py
parents d1b8e7d5 de1fe6b9
......@@ -114,6 +114,7 @@ def send_quota(users):
return data
QuotaLimits = namedtuple('QuotaLimits', ('holder',
'resource',
'capacity',
'import_limit',
'export_limit'))
......@@ -124,18 +125,18 @@ def qh_add_quota(serial, sub_list, add_list):
context = {}
c = get_client()
sub_quota = []
sub_append = sub_quota.append
add_quota = []
add_append = add_quota.append
for ql in sub_quota:
for ql in sub_list:
args = (ql.holder, ql.resource, ENTITY_KEY,
0, ql.capacity, ql.import_limit, ql.export_limit)
sub_append(args)
for ql in add_quota:
for ql in add_list:
args = (ql.holder, ql.resource, ENTITY_KEY,
0, ql.capacity, ql.import_limit, ql.export_limit)
add_append(args)
......
......@@ -914,9 +914,15 @@ class ProjectApplicationForm(forms.ModelForm):
_(astakos_messages.DOMAIN_VALUE_ERR),
'invalid'
)],
widget=forms.TextInput(attrs={'placeholder': 'eg. foo.ece.ntua.gr'}),
help_text="Name should be in the form of dns"
widget=forms.TextInput(attrs={'placeholder': 'myproject.mylab.ntua.gr'}),
help_text=" The Project's name should be in a domain format. The domain shouldn't neccessarily exist in the real world but is helpful to imply a structure. e.g.: myproject.mylab.ntua.gr or myservice.myteam.myorganization "
)
homepage = forms.URLField(
help_text="This should be a URL pointing at your project's site. e.g.: http://myproject.com ",
widget=forms.TextInput(attrs={'placeholder': 'http://myproject.com'}),
required=False
)
comments = forms.CharField(widget=forms.Textarea, required=False)
class Meta:
......@@ -964,7 +970,8 @@ class ProjectApplicationForm(forms.ModelForm):
if uplimit:
append(dict(service=s, resource=r, uplimit=uplimit))
else:
append(dict(service=s, resource=r, uplimit=None))
append(dict(service=s, resource=r, uplimit=None))
return policies
......
......@@ -45,6 +45,7 @@ from django.contrib.auth import (
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError
from urllib import quote
from urlparse import urljoin
......@@ -63,7 +64,9 @@ from astakos.im.settings import (
PROJECT_MEMBERSHIP_CHANGE_SUBJECT)
from astakos.im.notifications import build_notification, NotificationError
from astakos.im.models import (
ProjectMembership, ProjectApplication, trigger_sync)
AstakosUser, ProjectMembership, ProjectApplication, Project,
trigger_sync, get_closed_join, get_auto_accept_join,
get_auto_accept_leave, get_closed_leave)
import astakos.im.messages as astakos_messages
......@@ -424,7 +427,7 @@ def get_project_by_application_id(project_application_id):
def get_user_by_id(user_id):
try:
return AstakosUser.objects.get(user__id=user_id)
return AstakosUser.objects.get(id=user_id)
except AstakosUser.DoesNotExist:
raise IOError(_(astakos_messages.UNKNOWN_USER_ID) % user_id)
......@@ -433,17 +436,18 @@ def create_membership(project_application_id, user_id):
project = get_project_by_application_id(project_application_id)
m = ProjectMembership(
project=project,
person__id=user_id,
person=user_id,
request_date=datetime.now())
except IntegrityError, e:
raise IOError(_(astakos_messages.MEMBERSHIP_REQUEST_EXISTS))
else:
m.save()
return m
def get_membership(project, user):
if isinstace(project, int):
if isinstance(project, int):
project = get_project_by_application_id(project)
if isinstace(user, int):
if isinstance(user, int):
user = get_user_by_id(user)
try:
return ProjectMembership.objects.select_related().get(
......@@ -452,7 +456,7 @@ def get_membership(project, user):
except ProjectMembership.DoesNotExist:
raise IOError(_(astakos_messages.NOT_MEMBERSHIP_REQUEST))
def accept_membership(request, project, user, request_user=None):
def accept_membership(project, user, request_user=None):
"""
Raises:
django.core.exceptions.PermissionDenied
......@@ -460,14 +464,14 @@ def accept_membership(request, project, user, request_user=None):
"""
membership = get_membership(project, user)
if request_user and \
(not membership.project.current_application.owner == request_user and \
(not membership.project.application.owner == request_user and \
not request_user.is_superuser):
raise PermissionDenied(_(astakos_messages.NOT_ALLOWED))
if not self.project.is_alive:
if not membership.project.is_alive:
raise PermissionDenied(
_(astakos_messages.NOT_ALIVE_PROJECT) % membership.project.__dict__)
if len(self.project.approved_members) + 1 > \
self.project.definition.limit_on_members_number:
if len(membership.project.approved_members) + 1 > \
membership.project.application.limit_on_members_number:
raise PermissionDenied(_(astakos_messages.MEMBER_NUMBER_LIMIT_REACHED))
membership.accept()
......@@ -476,13 +480,13 @@ def accept_membership(request, project, user, request_user=None):
try:
notification = build_notification(
settings.SERVER_EMAIL,
[self.person.email],
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % membership.project.definition.__dict__,
[membership.person.email],
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % membership.project.__dict__,
template='im/projects/project_membership_change_notification.txt',
dictionary={'object':membership.project.current_application, 'action':'accepted'})
dictionary={'object':membership.project.application, 'action':'accepted'})
notification.send()
except NotificationError, e:
logger.error(e.messages)
logger.error(e.message)
return membership
def reject_membership(project, user, request_user=None):
......@@ -493,24 +497,24 @@ def reject_membership(project, user, request_user=None):
"""
membership = get_membership(project, user)
if request_user and \
(not membership.project.current_application.owner == request_user and \
(not membership.project.application.owner == request_user and \
not request_user.is_superuser):
raise PermissionDenied(_(astakos_messages.NOT_ALLOWED))
if not membership.project.is_alive:
raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % project.__dict__)
raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % membership.project.__dict__)
membership.reject()
try:
notification = build_notification(
settings.SERVER_EMAIL,
[self.person.email],
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % self.project.definition.__dict__,
[membership.person.email],
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % membership.project.__dict__,
template='im/projects/project_membership_change_notification.txt',
dictionary={'object':self.project.current_application, 'action':'rejected'})
dictionary={'object':membership.project.application, 'action':'rejected'})
notification.send()
except NotificationError, e:
logger.error(e.messages)
logger.error(e.message)
return membership
def remove_membership(project, user, request_user=None):
......@@ -521,10 +525,10 @@ def remove_membership(project, user, request_user=None):
"""
membership = get_membership(project, user)
if request_user and \
(not membership.project.current_application.owner == request_user and \
(not membership.project.application.owner == request_user and \
not request_user.is_superuser):
raise PermissionDenied(_(astakos_messages.NOT_ALLOWED))
if not self.project.is_alive:
if not membership.project.is_alive:
raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % membership.project.__dict__)
membership.remove()
......@@ -533,13 +537,13 @@ def remove_membership(project, user, request_user=None):
try:
notification = build_notification(
settings.SERVER_EMAIL,
[self.person.email],
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % membership.project.definition.__dict__,
[membership.person.email],
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % membership.project.__dict__,
template='im/projects/project_membership_change_notification.txt',
dictionary={'object':membership.project.current_application, 'action':'removed'})
dictionary={'object':membership.project.application, 'action':'removed'})
notification.send()
except NotificationError, e:
logger.error(e.messages)
logger.error(e.message)
return membership
def leave_project(project_application_id, user_id):
......@@ -549,7 +553,7 @@ def leave_project(project_application_id, user_id):
IOError
"""
project = get_project_by_application_id(project_application_id)
leave_policy = project.current_application.definition.member_join_policy
leave_policy = project.application.member_join_policy
if leave_policy == get_closed_leave():
raise PermissionDenied(_(astakos_messages.MEMBER_LEAVE_POLICY_CLOSED))
......@@ -569,7 +573,7 @@ def join_project(project_application_id, user_id):
IOError
"""
project = get_project_by_application_id(project_application_id)
join_policy = project.current_application.definition.member_join_policy
join_policy = project.application.member_join_policy
if join_policy == get_closed_join():
raise PermissionDenied(_(astakos_messages.MEMBER_JOIN_POLICY_CLOSED))
......@@ -605,10 +609,10 @@ def approve_application(application):
try:
notification = build_notification(
settings.SERVER_EMAIL,
[self.owner.email],
_(PROJECT_APPROVED_SUBJECT) % application.definition.__dict__,
[application.owner.email],
_(PROJECT_APPROVED_SUBJECT) % application.__dict__,
template='im/projects/project_approval_notification.txt',
dictionary={'object':application})
notification.send()
except NotificationError, e:
logger.error(e.messages)
logger.error(e.message)
......@@ -37,7 +37,8 @@ from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from astakos.im.models import ProjectApplication
from astakos.im.functions import approve_application
class Command(BaseCommand):
args = "<project application id>"
help = "Update project state"
......@@ -58,7 +59,7 @@ class Command(BaseCommand):
except ProjectApplication.DoesNotExist:
raise CommandError('Invalid id')
try:
app.approve()
approve_application(app)
except BaseException, e:
transaction.rollback()
raise CommandError(e)
......
......@@ -1372,16 +1372,16 @@ class ProjectApplication(models.Model):
blank=True,
db_index=True)
name = models.CharField(max_length=80)
name = models.CharField(max_length=80, help_text=" The Project's name should be in a domain format. The domain shouldn't neccessarily exist in the real world but is helpful to imply a structure. e.g.: myproject.mylab.ntua.gr or myservice.myteam.myorganization ",)
homepage = models.URLField(max_length=255, null=True,
blank=True)
description = models.TextField(null=True, blank=True)
start_date = models.DateTimeField()
end_date = models.DateTimeField()
blank=True,help_text="This should be a URL pointing at your project's site. e.g.: http://myproject.com ",)
description = models.TextField(null=True, blank=True,help_text= "Please provide a short but descriptive abstract of your Project, so that anyone searching can quickly understand what this Project is about. ")
start_date = models.DateTimeField(help_text= "Here you specify the date you want your Project to start granting its resources. Its members will get the resources coming from this Project on this exact date.")
end_date = models.DateTimeField(help_text= "Here you specify the date you want your Project to cease. This means that after this date all members will no longer be able to allocate resources from this Project. ")
member_join_policy = models.ForeignKey(MemberJoinPolicy)
member_leave_policy = models.ForeignKey(MemberLeavePolicy)
limit_on_members_number = models.PositiveIntegerField(null=True,
blank=True)
blank=True,help_text= "Here you specify the number of members this Project is going to have. This means that this number of people will be granted the resources you will specify in the next step. This can be '1' if you are the only one wanting to get resources. ")
resource_grants = models.ManyToManyField(
Resource,
null=True,
......@@ -1399,6 +1399,11 @@ class ProjectApplication(models.Model):
resource = Resource.objects.get(service__name=service, name=resource)
q.create(resource=resource, member_capacity=uplimit)
@property
def grants(self):
return self.projectresourcegrant_set.values('member_capacity', 'resource__name', 'resource__service__name')
@property
def resource_policies(self):
return self.projectresourcegrant_set.all()
......@@ -1654,13 +1659,13 @@ class Project(models.Model):
# try:
# notification = build_notification(
# settings.SERVER_EMAIL,
# [self.current_application.owner.email],
# [self.application.owner.email],
# _(PROJECT_TERMINATION_SUBJECT) % self.__dict__,
# template='im/projects/project_termination_notification.txt',
# dictionary={'object':self.current_application}
# dictionary={'object':self.application}
# ).send()
# except NotificationError, e:
# logger.error(e.messages)
# logger.error(e.message)
def suspend(self):
self.last_approval_date = None
......@@ -1670,13 +1675,13 @@ class Project(models.Model):
# try:
# notification = build_notification(
# settings.SERVER_EMAIL,
# [self.current_application.owner.email],
# _(PROJECT_SUSPENSION_SUBJECT) % self.definition.__dict__,
# [self.application.owner.email],
# _(PROJECT_SUSPENSION_SUBJECT) % self.__dict__,
# template='im/projects/project_suspension_notification.txt',
# dictionary={'object':self.current_application}
# dictionary={'object':self.application}
# ).send()
# except NotificationError, e:
# logger.error(e.messages)
# logger.error(e.message)
class ProjectMembership(models.Model):
......@@ -1747,6 +1752,7 @@ class ProjectMembership(models.Model):
self.save()
def remove(self):
state = self.state
if state != self.ACCEPTED:
m = _("%s: attempt to remove in state '%s'") % (self, state)
raise AssertionError(m)
......@@ -1756,6 +1762,7 @@ class ProjectMembership(models.Model):
self.save()
def reject(self):
state = self.state
if state != self.REQUESTED:
m = _("%s: attempt to remove in state '%s'") % (self, state)
raise AssertionError(m)
......@@ -1774,30 +1781,28 @@ class ProjectMembership(models.Model):
sub_append = sub_list.append
add_append = add_list.append
holder = self.person.username
holder = self.person.id
synced_application = self.application
if synced_application is not None:
# first, inverse all current limits, and index them by resource name
cur_grants = synced_application.resource_grants.all()
for grant in cur_grants:
sub_append(QuotaLimits(
holder = holder,
resource = grant.resource.name,
resource = str(grant.resource),
capacity = grant.member_capacity,
import_limit = grant.member_import_limit,
export_limit = grant.member_export_limit))
if not remove:
# second, add each new limit to its inverted current
new_grants = self.pending_application.projectresourcegrant_set.all()
for new_grant in new_grants:
add_append(QuotaLimits(
holder = holder,
resource = new_grant.resource.name,
capacity = new_grant.capacity,
import_limit = new_grant.import_limit,
export_limit = new_grant.export_limit))
resource = str(new_grant.resource),
capacity = new_grant.member_capacity,
import_limit = new_grant.member_import_limit,
export_limit = new_grant.member_export_limit))
return (sub_list, add_list)
......@@ -1899,7 +1904,7 @@ def sync_projects():
# which has been scheduled to sync with the old project.application
# Need to check in ProjectMembership.set_sync()
qh_add_quota(serial, sub_quota, add_quota)
r = qh_add_quota(serial, sub_quota, add_quota)
sync_finish_serials()
......
......@@ -13,15 +13,16 @@
/* dropkick select extra styles */
.form-row .dk_container { border-radius:0; margin-bottom:0; border: 1px solid #ccc; height: 21px; letter-spacing: 1px; line-height: 22px; margin-bottom: -1px; width:240px; padding:5px 0; font-weight:normal; font-family: 'Didact Gothic', Verdana, sans-serif; font-size:1em; background:transparent; color:#808080;}
.form-row .dk_toggle { border-radius:0; padding:0; border:0 none; text-indent:1.5em; text-decoration:none;background-image:url(../images/arrow-down_grey.png); background-position:90% 5px;}
.form-row .dk_container { border-radius:0; margin-bottom:0; border: 1px solid gray; height: 21px; letter-spacing: 1px; line-height: 22px; margin-bottom: -1px; width:270px; padding:0.8em; padding-left:1.5em; font-weight:normal; font-family: 'Didact Gothic', Verdana, sans-serif; font-size:1em; background:transparent; color:#808080;}
.form-row .dk_toggle { border-radius:0; padding:0 0 10px; border:0 none; text-decoration:none;background-image:url(../images/arrow-down_grey.png); background-position:87% 5px;}
.form-row .dk_toggle:hover { text-decoration:none; }
.form-row .dk_open { background:transparent; box-shadow: none; }
.form-row .dk_open .dk_toggle { background-color:transparent; border:0 none; color:#000; box-shadow: none;}
.form-row .dk_focus .dk_toggle { background-color:transparent; border:0 none; color:#000; box-shadow: none;}
.1form-row .dk_options { display:block; }
.form-row .dk_options { box-shadow:none; border-radius:0; z-index:3; margin:6px -1px 0; width:auto; left:0;}
.form-row .dk_options a { font-weight:normal;color:#808080; padding:5px 0; text-indent:1.5em; border-bottom-color: #ccc }
.form-row .dk_options li { float:none; margin:0; padding:0; }
.form-row .dk_options a { font-weight:normal;color:#808080; padding:5px 0; text-indent:1.5em; border-bottom-color: #ccc;height:auto; width:auto; }
.form-row .dk_options a:hover { border-bottom-color: #ccc }
.form-row .dk_options_inner { padding:0; margin:0; box-shadow:none; text-shadow:none; border-radius:0; border:1px solid #ccc ; margin-top:4px;}
.form-row .dk-options_inner li { list-style:none outside; }
......
......@@ -3,4 +3,4 @@
@import url(colorbox.css);
@import url(browser-fixes.css);
@import url(forms.css);
/* @import url(dropkick.css); */
\ No newline at end of file
@import url(dropkick.css);
\ No newline at end of file
......@@ -233,7 +233,7 @@ table.alt-style tr th { font-weight:normal; color:#3582AC }
table.alt-style tr td { color:#222; }
table.alt-style tr td:first-child,
table.alt-style tr th:first-child { padding-left:5px; }
table.alt-style tr td a { margin:0 0 0 20px; }
table.alt-style tr td a { margin:0 0 0 5px; }
table.alt-style tr td:first-child a { margin:0; }
.content a.submit { margin:0; display:inline-block; margin:10px 0 ; height:auto; min-width:100px; text-align:center;}
table.alt-style tr:nth-child(2n) td { background:#F2F2F2 }
......
......@@ -118,6 +118,7 @@ $(document).ready(function() {
}
});
$('.with-info select').dropkick();
$('.top-msg .success').parents('.top-msg').addClass('success');
$('.top-msg .error').parents('.top-msg').addClass('error');
......
--- A translation in English follows ---
Η αίτησή σας για το project {{object.definition.name}} έχει γίνει δεκτή.
Η αίτησή σας για το project {{object.name}} έχει γίνει δεκτή.
--
Your project application request ({{object.definition.name}}) has been approved.
\ No newline at end of file
Your project application request ({{object.name}}) has been approved.
\ No newline at end of file
......@@ -160,15 +160,7 @@
{% with page|concat:sorting as args %}
{% with object.project.projectmembership_set.select_related.all|paginate:args as membership %}
{% if membership %}
<form method="GET" class="minimal" action="#members-table">
<div class="form-row">
<select name="sorting" onchange="this.form.submit();" class="dropkicked">
<option value="person__email" {% if sorting == 'person__email' %}selected{% endif %}>Sort by User Id</option>
<option value="person__first_name" {% if sorting == 'person__first_name' %}selected{% endif %}>Sort by Name</option>
<option value="acceptance_date" {% if sorting == 'acceptance_date' %}selected{% endif %}>Sort by Acceptance Date</option>
</select>
</div>
</form>
<table class="alt-style" id="members-table">
<caption>MEMBERS:</caption>
<thead>
......@@ -208,10 +200,14 @@
<div class="pagination">
<p class="next-prev">
{% if membership.has_previous %}
<a href="?page={{ membership.previous_page_number }}{% if sorting %}&sorting={{sorting}}{% endif %}">previous</a>
<a href="?page={{ membership.previous_page_number }}{% if sorting %}&sorting={{sorting}}{% endif %}">< previous</a>
{% else %}
<a href="javascript:void()" class="disabled">< previous</a>
{% endif %}
{% if membership.has_next %}
<a href="?page={{ membership.next_page_number }}{% if sorting %}&sorting={{sorting}}{% endif %}">next</a>
<a href="?page={{ membership.next_page_number }}{% if sorting %}&sorting={{sorting}}{% endif %}">next ></a>
{% else %}
<a href="javascript:void()" class="disabled">next ></a>
{% endif %}
</p>
<p class="nums">
......
......@@ -3,7 +3,7 @@
{% load astakos_tags %}
{% load filters %}
{% block page.body %}
{% block page.body %}
<div class="maincol {% block innerpage.class %}{% endblock %}">
<div class="projects">
<h2>PROJECTS</h2>
......@@ -17,6 +17,7 @@
</div>
</form>
{% else %}
<div class="two-cols clearfix">
<div class="rt">
&nbsp;
......@@ -58,24 +59,9 @@
{% with page_obj.object_list as object_list %}
<!-- Search project -->
{% if is_search %}
<div>
{% comment %}
<form method="GET" class="minimal" action="#searchResults">
<div class="form-row">
<select name="sorting" onchange="this.form.submit();" class="dropkicked" tabindex="1">
<option value="name">Sort by Name</option>
<option value="issue_date" {% if sorting == 'issue_date' %}selected{% endif %}>Sort by Issue date</option>
<option value="start_date" {% if sorting == 'start_date' %}selected{% endif %}>Sort by Start Date</option>
<option value="end_date" {% if sorting == 'end_date' %}selected{% endif %}>Sort by End Date</option>
<!-- <option value="approved_members_num" {% if sorting == 'approved_members_num' %}selected{% endif %}>Sort by Participants</option> -->
<option value="state" {% if sorting == 'state' %}selected{% endif %}>Sort by Status</option>
<option value="member_join_policy__description" {% if sorting == 'member_join_policy__description' %}selected{% endif %}>Sort by Member Join Policy</option>
<option value="member_leave_policy__description" {% if sorting == 'member_leave_policy__description' %}selected{% endif %}>Sort by Member Leave Policy</option>
</select>
<input type="hidden" name="q" value="{{q}}"/>
</div>
</form>
{% endcomment %}
{% if object_list %}
<div>
<table class="alt-style complex" id="searchResults">
<caption>
{% if q %}SEARCH RESULTS{% else %}ALL PROJECTS{% endif %}
......@@ -101,17 +87,17 @@
{% with o.project.members.all as members %}
{% with o.project.approved_members as approved_members%}
<tr class="{% cycle 'tr1' 'tr2' %}">
<td style="width:22%"><a href="{% url project_detail o.id %}" title="visit project page">{{o.name|truncatename}}</a></td>
<td><a href="{% url project_detail o.id %}" title="visit project page">{{o.name|truncatename}}</a></td>
<!--td>{{o.kindname|capfirst}}</td-->
<td style="width:13%">{{o.issue_date|date:"d/m/Y"}}</td>
<td style="width:13%">{{o.start_date|date:"d/m/Y"}}</td>
<td style="width:13%">{{o.end_date|date:"d/m/Y"}}</td>
<td style="width:11%">{{approved_members|length}}</td>
<td style="width:11%">
<td>{{o.issue_date|date:"d/m/Y"}}</td>
<td>{{o.start_date|date:"d/m/Y"}}</td>
<td>{{o.end_date|date:"d/m/Y"}}</td>
<td>{{approved_members|length}}</td>
<td>
{{o.state}}
</td>
<td>{% if o.state != 'Replaced' %}<a href="{% url project_update o.id %}">Update</a>{% endif %}</td>
<td style="width:17%">
<td>
<div class="msg-wrap">
{% if user == o.owner %}
Owner
......@@ -128,7 +114,7 @@
{% endif %}
</div>
</td>
<td style="width:15%">
<td>
<div class="msg-wrap">
{% if user in members %}
......@@ -150,7 +136,7 @@
&nbsp;
{% endif %}
{% else %}
{% if o.project.is_alive %}
{% if o.project.is_alive and not user == o.owner %}
<form action="{% url project_join o.id %}?next={{request.path}}" method="post" class="link-like">{% csrf_token %}
<input type="submit" value="+ join group" class="join_group join" />
</form>
......@@ -165,12 +151,12 @@
{% endif %}
</div>
</td>
<td class="centered" style="width:9%">{{o.member_join_policy}}</td>
<td class="centered" style="width:9%">{{o.member_leave_policy}}</td>
<td class="centered">{{o.member_join_policy}}</td>
<td class="centered">{{o.member_leave_policy}}</td>
<!--td><a href="#" class="more-info" title="more info">+ more info</a></td-->
</tr>
<tr class="{% cycle 'tmore1' 'tmore2' %}" style="display:none">
<td colspan="7" class="info-td">
<td colspan="11" class="info-td">
<div>
<p>{{o.desc}}</p>
<p>{% if o.homepage%}
...