]> git.parisson.com Git - teleforma.git/commitdiff
working on https://trackers.pilotsystems.net/probarreau/0358
authorroot <root@ellington.pilotsystems.net>
Tue, 27 Nov 2018 10:56:52 +0000 (11:56 +0100)
committerroot <root@ellington.pilotsystems.net>
Tue, 27 Nov 2018 10:56:52 +0000 (11:56 +0100)
teleforma/exam/templates/exam/script_detail.html
teleforma/fields.py
teleforma/forms.py
teleforma/static/teleforma/css/teleforma.css
teleforma/templates/postman/base_write.html
teleforma/templatetags/teleforma_tags.py
teleforma/urls.py
teleforma/views/crfpa.py

index a51087e7e96caba9d37490f387cb4a9ff10147d7..4843e622be23458eddcde7daa05882099d44f287 100644 (file)
@@ -3,16 +3,16 @@
 {% load teleforma_tags %}
 {% load i18n %}
 {% load static %}
-{% load webviewer %}
+{% load pdfannotator %}
 {% load thumbnail %}
 
 {% block extra_javascript %}
-  <link rel="stylesheet" href="//code.jquery.com/ui/1.10.4/themes/smoothness/jquery-ui.css">
-  <script src="//code.jquery.com/jquery-1.10.2.js"></script>
-  <script src="//code.jquery.com/ui/1.10.4/jquery-ui.js"></script>
+{#  <link rel="stylesheet" href="//code.jquery.com/ui/1.10.4/themes/smoothness/jquery-ui.css">#}
+{#  <script src="//code.jquery.com/jquery-1.10.2.js"></script>#}
+{#  <script src="//code.jquery.com/ui/1.10.4/jquery-ui.js"></script>#}
   <script src="/static/exam/js/exam.js"></script>
-  <!--<script src='{% static "WebViewer/lib/html5/external/jquery-3.2.1.min.js" %}'></script>-->
-  <script src='{% static "WebViewer/lib/WebViewer.min.js" %}'></script>
+{#  <!--<script src='{% static "WebViewer/lib/html5/external/jquery-3.2.1.min.js" %}'></script>-->#}
+{#  <script src='{% static "WebViewer/lib/WebViewer.min.js" %}'></script>#}
   <script>
     // increase the default animation speed to exaggerate the effect
   {% if script.comments %}
 
 {% endblock extra_javascript %}
 
+{% block extra_stylesheets %}
+  <link rel="stylesheet" type="text/css" href="{% static "pdfannotator/toolbar.css" %}"/>
+  <link rel="stylesheet" type="text/css" href="{% static "pdfannotator/pdf_viewer.css" %}"/>
+  <style type="text/css">
+    body {
+      background-color: #eee;
+      font-family: sans-serif;
+      margin: 0;
+    }
+
+    
+  </style>
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
+
+{% endblock %}
+
 {% block content %}
 
 {% block course %}
-<div class="course">
+<div class="course viewer">
 
 <div class="course_title">
 
 <br /><br />
 <div class="media">
  <div class="script">
-  {% if script.has_annotations_file %}
-    <iframe id="box-iframe" style="position:fixed; top:12%; left:0px; bottom:0px; right:0px; width:100%; height:85%; border:none; margin:0; padding:0; z-index:0;" src="{% if user.quotas.all or user.is_superuser %}{{ script_service_url }}?url={{ script.safe_url }}&username=Examinator&uuid={{ script.uuid }}{% else %}{{ script_service_url }}?url={{ script.safe_url }}&username={{ user.username }}&uuid={{ script.uuid }}{% endif %}">
-    </iframe>
-  {% else %}
-    {% webviewer url=script.unquoted_url uuid=script.uuid %}
-  {% endif %}
+{#  {% if script.has_annotations_file %}#}
+{#    <iframe id="box-iframe" style="position:fixed; top:12%; left:0px; bottom:0px; right:0px; width:100%; height:85%; border:none; margin:0; padding:0; z-index:0;" src="{% if user.quotas.all or user.is_superuser %}{{ script_service_url }}?url={{ script.safe_url }}&username=Examinator&uuid={{ script.uuid }}{% else %}{{ script_service_url }}?url={{ script.safe_url }}&username={{ user.username }}&uuid={{ script.uuid }}{% endif %}">#}
+{#    </iframe>#}
+{#  {% else %}#}
+    {% pdfannotator url=script.unquoted_url uuid=script.uuid %}
+{#  {% endif %}#}
  </div>
 </div>
 
index 24a94eb83fd898f91adcaba2816aa7c145c0adf9..0cd9181ee6605c27acf530d26d67176eae325684 100644 (file)
@@ -2,6 +2,13 @@
 import django.db.models as models
 from django.forms import ModelForm, TextInput, Textarea
 from south.modelsinspector import add_introspection_rules
+from postman.fields import BasicCommaSeparatedUserField as PostmanBasicCommaSeparatedUserField
+from django.core.exceptions import ValidationError
+
+try:
+    from django.contrib.auth import get_user_model  # Django 1.5
+except ImportError:
+    from postman.future_1_5 import get_user_model
 
 
 class ShortTextField(models.TextField):
@@ -12,4 +19,44 @@ class ShortTextField(models.TextField):
          )
          return super(ShortTextField, self).formfield(**kwargs)
 
-add_introspection_rules([], ["^teleforma\.fields\.ShortTextField"])
\ No newline at end of file
+add_introspection_rules([], ["^teleforma\.fields\.ShortTextField"])
+
+
+
+class BasicCommaSeparatedUserField(PostmanBasicCommaSeparatedUserField):
+    def clean(self, value):
+        """Check names are valid and filter them."""
+        names = super(PostmanBasicCommaSeparatedUserField, self).clean(value)
+        if not names:
+            return []
+        user_model = get_user_model()
+        users = list(user_model.objects.filter(is_active=True, **{'{0}__in'.format(user_model.USERNAME_FIELD): names}))
+        unknown_names = set(names) ^ set([u.get_username() for u in users])
+        errors = []
+
+        if unknown_names == set(['auto']):
+            return unknown_names
+
+        if unknown_names:
+            errors.append(self.error_messages['unknown'].format(users=', '.join(unknown_names)))
+
+        if self.user_filter:
+            filtered_names = []
+            for u in users[:]:
+                try:
+                    reason = self.user_filter(u)
+                    if reason is not None:
+                        users.remove(u)
+                        filtered_names.append(
+                            self.error_messages[
+                                'filtered_user_with_reason' if reason else 'filtered_user'
+                            ].format(username=u.get_username(), reason=reason)
+                        )
+                except ValidationError as e:
+                    users.remove(u)
+                    errors.extend(e.messages)
+            if filtered_names:
+                errors.append(self.error_messages['filtered'].format(users=', '.join(filtered_names)))
+        if errors:
+            raise ValidationError(errors)
+        return users
\ No newline at end of file
index caba072bf1dc0eb24e06d1b59490f437a8605ee9..ca517fc606f63af811754d9b249247e03ccad556 100644 (file)
@@ -1,9 +1,14 @@
-from django.forms import ModelForm
+from django.forms import ModelForm, ModelChoiceField
+from postman.forms import WriteForm as PostmanWriteForm
+
+from teleforma.fields import BasicCommaSeparatedUserField
 from teleforma.models import *
 from registration.forms import RegistrationForm
 from django.utils.translation import ugettext_lazy as _
 from extra_views import CreateWithInlinesView, UpdateWithInlinesView, InlineFormSet
 from captcha.fields import CaptchaField
+
+from teleforma.models.core import Course
 from tinymce.widgets import TinyMCE
 
 class ConferenceForm(ModelForm):
@@ -91,3 +96,15 @@ class NewsItemForm(ModelForm):
             'description': TinyMCE({'cols':80, 'rows':30}),
         }
 
+
+
+class WriteForm(PostmanWriteForm):
+    recipients = BasicCommaSeparatedUserField(label=(_("Recipients"), _("Recipient")), help_text='')
+    course = ModelChoiceField(queryset=Course.objects.all())
+
+    class Meta(PostmanWriteForm.Meta):
+        fields = ('course', 'recipients', 'subject', 'body')
+
+    def clean_recipients(self):
+        """Check no filter prohibit the exchange."""
+        recipients = self.cleaned_data['recipients']
index 02121ce8a52cc4219d248248944afcaafabb16b6..2e244e9baa1ce33a965c45bad2a6cca29216c8a3 100644 (file)
@@ -2418,7 +2418,100 @@ button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra pad
 
 
 
+/*#layout {
+       min-height: 100vh;
+}
+
+#layout #content{
+       min-height: calc(100vh - 151px);
+       margin-top: 41px;
+}*/
+
+#layout .viewer{
+       min-height: calc(100vh - 141px);
+       margin-top: 11px;
+}
+#layout .pdfViewer .canvasWrapper {
+      box-shadow: 0 0 3px #bbb;
+}
+.pdfViewer .page {
+      margin-bottom: 10px;
+}
+
+#layout .annotationLayer {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+    }
+
+#layout .viewer #content-wrapper {
+      position: absolute;
+      top: 85px;
+      left: 0;
+      right: 250px;
+      bottom: 0;
+      overflow: auto;
+      height: calc(100% - 80px);
+         width: calc(100% - 250px);
+    }
 
+#layout .viewer #comment-wrapper {
+      position: absolute;
+      top: 85px;
+      right: 0;
+      bottom: 0;
+      overflow: auto;
+      width: 250px;
+      background: #eaeaea;
+      border-left: 1px solid #d0d0d0;
+    }
+#layout #comment-wrapper h4 {
+      margin: 10px;
+    }
+#layout #comment-wrapper .comment-list {
+      font-size: 12px;
+      position: absolute;
+      top: 38px;
+      left: 0;
+      right: 0;
+      bottom: 0;
+    }
+#layout #comment-wrapper .comment-list-item {
+      border-bottom: 1px solid #d0d0d0;
+      padding: 10px;
+         cursor: pointer;
+    }
+#layout #comment-wrapper .comment-list-item.selected {
+       background-color: rgba(0, 191, 255, 0.5);
+}
+#layout #comment-wrapper .comment-list-item.selected:hover {
+       background-color: rgba(0, 191, 255, 0.3);
+}
+
+#layout #comment-wrapper .comment-list-item:hover {
+       background-color: #dfdfdf;
+}
+#layout #comment-wrapper .comment-list-container {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0px;
+      overflow: auto;
+    }
+#layout #comment-wrapper .comment-list-form {
+      position: absolute;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      padding: 10px;
+    }
+#layout #comment-wrapper .comment-list-form input {
+      padding: 5px;
+      width: 100%;
+    }
 
 
 /* _____  ______  _____ _____   ____  _   _  _____ _______      ________ 
index 4b44e69b28b8f466b1116a6bb0d7042db718b6a7..c7a326a35b5a294675b41b1e09a1411e9819c8d2 100644 (file)
 
 {% block content %}
 
-<script type="text/javascript">
-function update_admin_recipient() {
-       $("#_adminSelect").click(function () {
-      var htmlStr = $(this).val();
-      $("#id_recipients").val(htmlStr);
-    });
-}
-
-function update_professor_recipient() {
-       $("#_professorSelect").click(function () {
-      var htmlStr = $(this).val();
-      $("#id_recipients").val(htmlStr);
-    });
-}
-
-</script>
-
-<div id="postman" class="desk_messages">
-<h1>{% block pm_write_title %}{% endblock %}</h1>
-
-{% if user.student.all or user.is_staff or user.quotas.all %}
-<div style="padding-top: 0.5em;">
-
-{% trans "Vous pouvez ici échanger des messages avec les professeurs et les administrateurs." %}
-<br/>
-{% trans "Pour les questions concernant l'organisation des cours, le planning, les documents de cours ou les copies, adressez-vous à <b>Admin-CRFPA</b>." %}
-<br/>
-{% trans "Pour les questions concernant uniquement l'accès à la plateforme et aux médias vidéo ou audio, lire d'abord" %} <a href="{% url teleforma-help %}">la page d'aide</a> {% trans "puis adressez-vous à <b>Support technique</b>." %}
-<br/><br/>
-
-<select id="_adminSelect" name="admins" onchange="update_admin_recipient();">
-<option value="">{% trans "to an administrator" %}</option>
-{% get_all_admins as admins %}
-{% for a in admins %}
-<option value="{{ a.username }}">{{a.first_name}} {{a.last_name}}</option>
-{% endfor %}
-</select>
-
-<select id="_professorSelect" name="professors" onchange="update_professor_recipient();">
-<option value="">{% trans "to a professor" %}</option>
-{% get_all_professors as professors %}
-{% for p in professors %}
-<option value="{{ p.user.username }}">{{p.user.last_name}} {{p.user.first_name}}</option>
-{% endfor %}
-</select>
-
-</div>
-{% endif %}
-
-<div class="write_content">
-<form id="_messageForm" action="{% if next_url %}?next={{ next_url|urlencode }}{% endif %}" method="post">{% csrf_token %}
-<table>
-{% block pm_write_recipient %}{% endblock %}
-{{ form.as_table }}
-</table>
-</form>
-<a href="#" class="component_icon button icon_next" id="send_button">{% trans "Send" %}</a>
-</div>
-</div>
-
-<script type="text/javascript">
-document.getElementById("id_subject").focus();
-$('#send_button').click(function() {
-  $('#_messageForm').submit();
-});
-</script>
+
+    <div id="postman" class="desk_messages">
+        <h1>{% block pm_write_title %}{% endblock %}</h1>
+
+        {% if user.student.all or user.is_staff or user.quotas.all %}
+            <div style="padding-top: 0.5em;">
+
+                {% trans "Vous pouvez ici échanger des messages avec les professeurs et les administrateurs." %}
+                <br/>
+                {% trans "Pour les questions concernant l'organisation des cours, le planning, les documents de cours ou les copies, adressez-vous à <b>Admin-CRFPA</b>." %}
+                <br/>
+                {% trans "Pour les questions concernant uniquement l'accès à la plateforme et aux médias vidéo ou audio, lire d'abord" %}
+                <a href="{% url teleforma-help %}">la page
+                    d'aide</a> {% trans "puis adressez-vous à <b>Support technique</b>." %}
+                <br/><br/>
+
+            </div>
+        {% endif %}
+        <div class="write_content">
+
+            <form id="_messageForm" action="{% if next_url %}?next={{ next_url|urlencode }}{% endif %}"
+                  method="post">{% csrf_token %}
+
+
+                {{ form.errors.recipients }}
+                <input type="radio" id="recipient_category-admin" name="recipient_category" value="admin" {% if request.POST.recipient_category == 'admin' %}checked="checked"{% endif %}/><label
+                    for="recipient_category-admin">A un administrateur</label>
+                <input type="radio" id="recipient_category-prof" name="recipient_category" value="prof" {% if request.POST.recipient_category == 'prof' %}checked="checked"{% endif %}/><label
+                    for="recipient_category-prof">A un professeur</label>
+
+
+                <div id="category-admin">
+                    <select id="_adminSelect" name="admins">
+                        <option value="">{% trans "to an administrator" %}</option>
+                        {% get_all_admins as admins %}
+                        {% for a in admins %}
+                            <option value="{{ a.username }}" {% if request.POST.admins == a.username %}selected{% endif %}>{{ a.first_name }} {{ a.last_name }}</option>
+                        {% endfor %}
+                    </select>
+                </div>
+
+
+                <div id="category-prof">
+
+                    <select id="_courseSelect" name="course">
+                        <option value="">Matière</option>
+                        {% get_all_courses as courses %}
+                        {% for c in courses %}
+                            <option value="{{ c.id }}" {% if request.POST.course == c.id|stringformat:"i" %}selected{% endif %}>{{ c.title }}</option>
+                        {% endfor %}
+                    </select>
+
+                    <select id="_professorSelect" name="professors">
+                        <option value="auto">N'importe quel professeur</option>
+                        {% get_all_professors_with_courses as professors %}
+                        {% for p in professors %}
+                            <option value="{{ p.username }}"
+                                    data-courses="{{ p.courses }}" {% if request.POST.professors == p.username %}selected{% endif %}>{{ p.name }} {{ p.courses }}</option>
+                        {% endfor %}
+                    </select>
+                </div>
+
+
+              <table>
+                {% block pm_write_recipient %}{% endblock %}
+                {% for field in form.visible_fields %}
+                    {% if field.name != 'course' %}
+                  <tr>
+                    <th>{{ field.label_tag }}</th>
+                    <td>
+                      {{ field.errors }}
+                      {{ field }}
+                      {{ field.help_text }}
+                    </td>
+                  </tr>
+                    {% endif %}
+                {% endfor %}
+              </table>
+            </form>
+            <a href="#" class="component_icon button icon_next" id="send_button">{% trans "Send" %}</a>
+        </div>
+    </div>
+
+
+    <script type="text/javascript">
+        function update_admin_recipient() {
+            $("#_adminSelect").click(function () {
+                var htmlStr = $(this).val();
+                $("#id_recipients").val(htmlStr);
+            });
+        }
+
+        function update_professor_recipient() {
+            $("#_professorSelect").click(function () {
+                var htmlStr = $(this).val();
+                $("#id_recipients").val(htmlStr);
+            });
+        }
+
+
+        function update_desk_messages(event) {
+
+            // reset values
+            if(event){
+                $target = $(event.target);
+                if($target.attr('id') === 'recipient_category-prof' || $target.attr('id') === 'recipient_category-admin') {
+                    $('#_professorSelect').val('');
+                    $('#_courseSelect').val('');
+                    $('#_adminSelect').val('');
+                }
+
+                if($target.attr('id') === '_courseSelect')
+                    $('#_professorSelect').val('');
+            }
+
+            // show or hide field depending on what is selected
+            var recipientCategory = $('[name="recipient_category"]:checked').val();
+            $("#id_recipients").val('');
+            $('#category-admin').hide();
+            $('#category-prof').hide();
+            if (recipientCategory == 'admin')
+                $('#category-admin').show();
+            else if (recipientCategory == 'prof')
+                $('#category-prof').show();
+
+            var course = parseInt($('[name="course"]').val(), 10);
+            if(!course)
+                $('#_professorSelect').hide();
+            else
+                $('#_professorSelect').show();
+
+            $('#_professorSelect option').hide();
+            $('#_professorSelect option:first').show();
+            $('#_professorSelect option').each(function(){
+                var $option = $(this);
+                var courses = $option.data('courses')
+                if(courses && courses.indexOf(course) >= 0)
+                    $option.show();
+            });
+
+            // fill the hidden field
+            if($('#_professorSelect').val() && $('#_courseSelect').val())
+                $("#id_recipients").val($('#_professorSelect').val());
+            if($('#_adminSelect').val())
+                $("#id_recipients").val($('#_adminSelect').val());
+        }
+
+        // hide recipients fields
+        $('#id_recipients').parent().parent().hide();
+        {#$('#id_course').parent().parent().hide();#}
+
+        $('[name="recipient_category"]').bind('change', update_desk_messages);
+        $('[name="course"]').bind('change', update_desk_messages);
+        $('#_professorSelect, #_adminSelect').bind('change', update_desk_messages);
+        update_desk_messages();
+
+        document.getElementById("id_subject").focus();
+        $('#send_button').click(function () {
+            $('#_messageForm').submit();
+        });
+    </script>
 
 {% endblock %}
index 304feb45b3e67bff169363a9fad0e64b09634407..6318859954908058864a41626a2bbf6680d86b8d 100644 (file)
 # Authors: Guillaume Pellerin <yomguy@parisson.com>
 
 from django import template
-from django.utils.http import urlquote
-from django.core.urlresolvers import reverse
-from django.utils import html
-from django import template
-from django.utils.text import capfirst
-from django.utils.translation import ungettext
-from docutils.core import publish_parts
-from django.utils.encoding import smart_str, force_unicode
-from django.utils.safestring import mark_safe
-from django import db
 from django.shortcuts import get_object_or_404
-import re
-import os
-import datetime
-from django.conf import settings
-from django.template.defaultfilters import stringfilter
-import django.utils.timezone as timezone
+import json
 from timezones.utils import localtime_for_timezone
 from django.utils.translation import ugettext_lazy as _
 from urlparse import urlparse
@@ -57,7 +42,6 @@ from urlparse import urlparse
 from teleforma.models.core import Document
 from teleforma.models.crfpa import Course, NewsItem
 from teleforma.views import get_courses
-from teleforma.models import *
 from teleforma.exam.models import *
 
 register = template.Library()
@@ -160,6 +144,21 @@ def from_period(contents, period):
 def get_all_professors():
     return Professor.objects.all()
 
+@register.assignment_tag
+def get_all_professors_with_courses():
+    professors = []
+    for professor in Professor.objects.order_by('user__last_name').all():
+        name = professor.user.last_name + professor.user.first_name
+        if name:
+            professors.append({
+                'username':professor.user.username,
+                'name':professor.user.last_name + professor.user.first_name,
+                'courses':json.dumps([course.id for course in professor.courses.all()])
+            })
+    return professors
+
+
+
 @register.assignment_tag
 def get_all_admins():
     return User.objects.filter(is_superuser=True).order_by('last_name')
index 42030dab501cc765444a787b6f1e27e8ae411d48..ba9a2733863bf83d3cc78c0099b2b531fd67f31f 100644 (file)
@@ -111,8 +111,10 @@ urlpatterns = patterns('',
        name="teleforma-appointment-cancel"),
 
     # Postman
+    url(r'^messages/write/(?:(?P<recipients>[^/#]+)/)?$', WriteView.as_view(), name='postman_write'),
     url(r'^messages/', include('postman.urls')),
 
+
     # Users
     url(r'^users/training/(?P<training_id>.*)/iej/(?P<iej_id>.*)/course/(?P<course_id>.*)/list/$',
         UsersView.as_view(), name="teleforma-users"),
index 0b5da871a5838b7fadb0229bc5f0b4e7a554bebd..aa2d3e75d8a81af154c2a0ebb2f8c7df40db385e 100644 (file)
 # Authors: Guillaume Pellerin <yomguy@parisson.com>
 from teleforma.models.core import Period
 from teleforma.views.core import *
+from teleforma.forms import WriteForm
 from registration.views import *
 from extra_views import CreateWithInlinesView, UpdateWithInlinesView, InlineFormSet
+from postman.views import WriteView as PostmanWriteView
+from postman.forms import AnonymousWriteForm
 from django.utils.translation import ugettext_lazy as _
 from django.views.decorators.csrf import csrf_exempt
 from django.db.models import Q
@@ -592,3 +595,46 @@ class NewsItemList(ListView):
         if course_id:
             query = query.filter(course__id=self.request.GET.get('course_id'))
         return query
+
+
+class WriteView(PostmanWriteView):
+    """
+    Display a form to compose a message.
+
+    Optional URLconf name-based argument:
+        ``recipients``: a colon-separated list of usernames
+    Optional attributes:
+        ``form_classes``: a 2-tuple of form classes
+        ``autocomplete_channels``: a channel name or a 2-tuple of names
+        ``template_name``: the name of the template to use
+        + those of ComposeMixin
+
+    """
+    form_classes = (WriteForm, AnonymousWriteForm)
+
+    # def get_initial(self):
+    #     initial = super(WriteView, self).get_initial()
+    #     if self.request.method == 'GET':
+    #         initial.update(self.request.GET.items())  # allow optional initializations by query string
+    #         recipients = self.kwargs.get('recipients')
+    #         if recipients:
+    #             # order_by() is not mandatory, but: a) it doesn't hurt; b) it eases the test suite
+    #             # and anyway the original ordering cannot be respected.
+    #             user_model = get_user_model()
+    #             usernames = list(user_model.objects.values_list(user_model.USERNAME_FIELD, flat=True).filter(
+    #                 is_active=True,
+    #                 **{'{0}__in'.format(user_model.USERNAME_FIELD): [r.strip() for r in recipients.split(':') if r and not r.isspace()]}
+    #             ).order_by(user_model.USERNAME_FIELD))
+    #             if usernames:
+    #                 initial['recipients'] = ', '.join(usernames)
+    #     return initial
+
+    # def get_form_kwargs(self):
+    #     import pdb;pdb.set_trace()
+    #     kwargs = super(WriteView, self).get_form_kwargs()
+    #     if isinstance(self.autocomplete_channels, tuple) and len(self.autocomplete_channels) == 2:
+    #         channel = self.autocomplete_channels[self.request.user.is_anonymous()]
+    #     else:
+    #         channel = self.autocomplete_channels
+    #     kwargs['channel'] = channel
+    #     return kwargs
\ No newline at end of file