Revision 4adb68b8

/dev/null
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
from api.models import Container, Object, Metadata
6
from django.contrib import admin
7

  
8
admin.site.register(Container)
9
admin.site.register(Object)
10
admin.site.register(Metadata)
/dev/null
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
def camelCase(s):
6
    return s[0].lower() + s[1:]
7

  
8

  
9
class Fault(Exception):
10
    def __init__(self, message='', details='', name=''):
11
        Exception.__init__(self, message, details, name)
12
        self.message = message
13
        self.details = details
14
        self.name = name or camelCase(self.__class__.__name__)
15

  
16
class NotModified(Fault):
17
    code = 304
18

  
19
class BadRequest(Fault):
20
    code = 400
21

  
22
class Unauthorized(Fault):
23
    code = 401
24

  
25
class ResizeNotAllowed(Fault):
26
    code = 403
27

  
28
class ItemNotFound(Fault):
29
    code = 404
30

  
31
class LengthRequired(Fault):
32
    code = 411
33

  
34
class PreconditionFailed(Fault):
35
    code = 412
36

  
37
class RangeNotSatisfiable(Fault):
38
    code = 416
39

  
40
class UnprocessableEntity(Fault):
41
    code = 422
42

  
43
class ServiceUnavailable(Fault):
44
    code = 503
/dev/null
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
from django.http import HttpResponse
6
from django.template.loader import render_to_string
7
from django.utils import simplejson as json
8
from django.utils.http import http_date, parse_etags
9

  
10
try:
11
    from django.utils.http import parse_http_date_safe
12
except:
13
    from pithos.api.util import parse_http_date_safe
14

  
15
from pithos.api.faults import Fault, NotModified, BadRequest, Unauthorized, LengthRequired, PreconditionFailed, RangeNotSatisfiable, UnprocessableEntity
16
from pithos.api.util import get_object_meta, get_range, api_method
17

  
18
from pithos.backends.dummy_debug import *
19

  
20
import logging
21

  
22
logging.basicConfig(level=logging.DEBUG)
23

  
24
@api_method('GET')
25
def authenticate(request):
26
    # Normal Response Codes: 204
27
    # Error Response Codes: serviceUnavailable (503),
28
    #                       unauthorized (401),
29
    #                       badRequest (400)
30
    
31
    x_auth_user = request.META.get('HTTP_X_AUTH_USER')
32
    x_auth_key = request.META.get('HTTP_X_AUTH_KEY')
33
    
34
    if not x_auth_user or not x_auth_key:
35
        raise BadRequest('Missing auth user or key.')
36
    
37
    response = HttpResponse(status = 204)
38
    response['X-Auth-Token'] = 'eaaafd18-0fed-4b3a-81b4-663c99ec1cbb'
39
    # TODO: Do we support redirections?
40
    #response['X-Storage-Url'] = 'https://storage.grnet.gr/pithos/v1.0/<some reference>'
41
    return response
42

  
43
def account_demux(request, v_account):
44
    if request.method == 'HEAD':
45
        return account_meta(request, v_account)
46
    elif request.method == 'GET':
47
        return container_list(request, v_account)
48
    else:
49
        return method_not_allowed(request)
50

  
51
def container_demux(request, v_account, v_container):
52
    if request.method == 'HEAD':
53
        return container_meta(request, v_account, v_container)
54
    elif request.method == 'GET':
55
        return object_list(request, v_account, v_container)
56
    elif request.method == 'PUT':
57
        return container_create(request, v_account, v_container)
58
    elif request.method == 'DELETE':
59
        return container_delete(request, v_account, v_container)
60
    else:
61
        return method_not_allowed(request)
62

  
63
def object_demux(request, v_account, v_container, v_object):
64
    if request.method == 'HEAD':
65
        return object_meta(request, v_account, v_container, v_object)
66
    elif request.method == 'GET':
67
        return object_read(request, v_account, v_container, v_object)
68
    elif request.method == 'PUT':
69
        return object_write(request, v_account, v_container, v_object)
70
    elif request.method == 'COPY':
71
        return object_copy(request, v_account, v_container, v_object)
72
    elif request.method == 'POST':
73
        return object_update(request, v_account, v_container, v_object)
74
    elif request.method == 'DELETE':
75
        return object_delete(request, v_account, v_container, v_object)
76
    else:
77
        return method_not_allowed(request)
78

  
79
@api_method('HEAD')
80
def account_meta(request, v_account):
81
    # Normal Response Codes: 204
82
    # Error Response Codes: serviceUnavailable (503),
83
    #                       itemNotFound (404),
84
    #                       unauthorized (401),
85
    #                       badRequest (400)
86
    
87
    container_count, bytes_count = get_account_meta(request.user)
88
    
89
    response = HttpResponse(status = 204)
90
    response['X-Account-Container-Count'] = container_count
91
    response['X-Account-Total-Bytes-Used'] = bytes_count
92
    return response
93

  
94
@api_method('GET', format_allowed = True)
95
def container_list(request, v_account):
96
    # Normal Response Codes: 200, 204
97
    # Error Response Codes: serviceUnavailable (503),
98
    #                       unauthorized (401),
99
    #                       badRequest (400)
100
    
101
    marker = request.GET.get('marker')
102
    limit = request.GET.get('limit')
103
    if limit:
104
        try:
105
            limit = int(limit)
106
        except ValueError:
107
            limit = None
108
    
109
    containers = list_containers(request.user, marker, limit)
110
    if len(containers) == 0:
111
        return HttpResponse(status = 204)
112
    
113
    if request.serialization == 'xml':
114
        data = render_to_string('containers.xml', {'account': request.user, 'containers': containers})
115
    elif request.serialization  == 'json':
116
        data = json.dumps(containers)
117
    else:
118
        data = '\n'.join(x['name'] for x in containers)
119
    
120
    return HttpResponse(data, status = 200)
121

  
122
@api_method('HEAD')
123
def container_meta(request, v_account, v_container):
124
    # Normal Response Codes: 204
125
    # Error Response Codes: serviceUnavailable (503),
126
    #                       itemNotFound (404),
127
    #                       unauthorized (401),
128
    #                       badRequest (400)
129
    
130
    object_count, bytes_count = get_container_meta(request.user, v_container)
131
    
132
    response = HttpResponse(status = 204)
133
    response['X-Container-Object-Count'] = object_count
134
    response['X-Container-Bytes-Used'] = bytes_count
135
    return response
136

  
137
@api_method('PUT')
138
def container_create(request, v_account, v_container):
139
    # Normal Response Codes: 201, 202
140
    # Error Response Codes: serviceUnavailable (503),
141
    #                       itemNotFound (404),
142
    #                       unauthorized (401),
143
    #                       badRequest (400)
144

  
145
    if create_container(request.user, v_container):
146
        return HttpResponse(status = 201)
147
    else:
148
        return HttpResponse(status = 202)
149

  
150
@api_method('DELETE')
151
def container_delete(request, v_account, v_container):
152
    # Normal Response Codes: 204
153
    # Error Response Codes: serviceUnavailable (503),
154
    #                       itemNotFound (404),
155
    #                       unauthorized (401),
156
    #                       badRequest (400)
157
    
158
    object_count, bytes_count = get_container_meta(request.user, v_container)
159
    if object_count > 0:
160
        return HttpResponse(status = 409)
161
    
162
    delete_container(request.user, v_container)
163
    return HttpResponse(status = 204)
164

  
165
@api_method('GET', format_allowed = True)
166
def object_list(request, v_account, v_container):
167
    # Normal Response Codes: 200, 204
168
    # Error Response Codes: serviceUnavailable (503),
169
    #                       itemNotFound (404),
170
    #                       unauthorized (401),
171
    #                       badRequest (400)
172
    
173
    path = request.GET.get('path')
174
    prefix = request.GET.get('prefix')
175
    delimiter = request.GET.get('delimiter')
176
    logging.debug("path: %s", path)
177
    
178
    # Path overrides prefix and delimiter.
179
    if path:
180
        prefix = path
181
        delimiter = '/'
182
    # Naming policy.
183
    if prefix and delimiter:
184
        prefix = prefix + delimiter
185
    
186
    marker = request.GET.get('marker')
187
    limit = request.GET.get('limit')
188
    if limit:
189
        try:
190
            limit = int(limit)
191
        except ValueError:
192
            limit = None
193
    
194
    objects = list_objects(request.user, v_container, prefix, delimiter, marker, limit)
195
    if len(objects) == 0:
196
        return HttpResponse(status = 204)
197
    
198
    if request.serialization == 'xml':
199
        data = render_to_string('objects.xml', {'container': v_container, 'objects': objects})
200
    elif request.serialization  == 'json':
201
        data = json.dumps(objects)
202
    else:
203
        data = '\n'.join(x['name'] for x in objects)
204
    
205
    return HttpResponse(data, status = 200)
206

  
207
@api_method('HEAD')
208
def object_meta(request, v_account, v_container, v_object):
209
    # Normal Response Codes: 204
210
    # Error Response Codes: serviceUnavailable (503),
211
    #                       itemNotFound (404),
212
    #                       unauthorized (401),
213
    #                       badRequest (400)
214

  
215
    info = get_object_meta(request.user, v_container, v_object)
216
    
217
    response = HttpResponse(status = 204)
218
    response['ETag'] = info['hash']
219
    response['Content-Length'] = info['bytes']
220
    response['Content-Type'] = info['content_type']
221
    response['Last-Modified'] = http_date(info['last_modified'])
222
    for k, v in info['meta'].iteritems():
223
        response['X-Object-Meta-%s' % k.capitalize()] = v
224
    
225
    return response
226

  
227
@api_method('GET')
228
def object_read(request, v_account, v_container, v_object):
229
    # Normal Response Codes: 200, 206
230
    # Error Response Codes: serviceUnavailable (503),
231
    #                       rangeNotSatisfiable (416),
232
    #                       preconditionFailed (412),
233
    #                       itemNotFound (404),
234
    #                       unauthorized (401),
235
    #                       badRequest (400),
236
    #                       notModified (304)
237
    
238
    info = get_object_meta(request.user, v_container, v_object)
239
    
240
    response = HttpResponse()
241
    response['ETag'] = info['hash']
242
    response['Content-Type'] = info['content_type']
243
    response['Last-Modified'] = http_date(info['last_modified'])
244
    
245
    # Range handling.
246
    range = get_range(request)
247
    if range is not None:
248
        offset, length = range
249
        if not length:
250
            length = 0
251
        if offset + length > info['bytes']:
252
            raise RangeNotSatisfiable()
253
        
254
        response['Content-Length'] = length        
255
        response.status_code = 206
256
    else:
257
        offset = 0
258
        length = 0
259
        
260
        response['Content-Length'] = info['bytes']
261
        response.status_code = 200
262
    
263
    # Conditions (according to RFC2616 must be evaluated at the end).
264
    if_match = request.META.get('HTTP_IF_MATCH')
265
    if if_match is not None and if_match != '*':
266
        if info['hash'] not in parse_etags(if_match):
267
            raise PreconditionFailed()
268
    
269
    if_none_match = request.META.get('HTTP_IF_NONE_MATCH')
270
#     if if_none_match is not None:
271
#         if if_none_match = '*' or info['hash'] in parse_etags(if_none_match):
272
#             raise NotModified()
273
    
274
    if_modified_since = request.META.get('HTTP_IF_MODIFIED_SINCE')
275
    if if_modified_since is not None:
276
        if_modified_since = parse_http_date_safe(if_modified_since)
277
    if if_modified_since is not None and info['last_modified'] <= if_modified_since:
278
        raise NotModified()
279

  
280
    if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
281
    if if_unmodified_since is not None:
282
        if_unmodified_since = parse_http_date_safe(if_unmodified_since)
283
    if if_unmodified_since is not None and info['last_modified'] > if_unmodified_since:
284
        raise PreconditionFailed()
285
    
286
    response.content = get_object_data(request.user, v_container, v_object, offset, length)
287
    return response
288

  
289
@api_method('PUT')
290
def object_write(request, v_account, v_container, v_object):
291
    # Normal Response Codes: 201
292
    # Error Response Codes: serviceUnavailable (503),
293
    #                       unprocessableEntity (422),
294
    #                       lengthRequired (411),
295
    #                       itemNotFound (404),
296
    #                       unauthorized (401),
297
    #                       badRequest (400)
298
    
299
    copy_from = request.META.get('HTTP_X_COPY_FROM')
300
    if copy_from:
301
        parts = copy_from.split('/')
302
        if len(parts) < 3 or parts[0] != '':
303
            raise BadRequest('Bad X-Copy-From path.')
304
        copy_container = parts[1]
305
        copy_name = '/'.join(parts[2:])
306
        
307
        info = get_object_meta(request.user, copy_container, copy_name)
308
        
309
        content_length = request.META.get('CONTENT_LENGTH')
310
        content_type = request.META.get('CONTENT_TYPE')
311
        if not content_length:
312
            raise LengthRequired()
313
        if content_type:
314
            info['content_type'] = content_type
315
        
316
        meta = get_object_meta(request)
317
        for k, v in meta.iteritems():
318
            info['meta'][k] = v
319
        
320
        copy_object(request.user, copy_container, copy_name, v_container, v_object)
321
        update_object_meta(request.user, v_container, v_object, info)
322
        
323
        response = HttpResponse(status = 201)
324
    else:
325
        content_length = request.META.get('CONTENT_LENGTH')
326
        content_type = request.META.get('CONTENT_TYPE')
327
        if not content_length or not content_type:
328
            raise LengthRequired()
329
    
330
        meta = get_object_meta(request)
331
        info = {'bytes': content_length, 'content_type': content_type, 'meta': meta}
332
    
333
        etag = request.META.get('HTTP_ETAG')
334
        if etag:
335
            etag = parse_etags(etag)[0] # TODO: Unescape properly.
336
            info['hash'] = etag
337
    
338
        data = request.read()
339
        # TODO: Hash function.
340
        # etag = hash(data)
341
        # if info.get('hash') and info['hash'] != etag:
342
        #     raise UnprocessableEntity()
343
    
344
        update_object_data(request.user, v_container, v_name, info, data)
345
    
346
        response = HttpResponse(status = 201)
347
        # response['ETag'] = etag
348
    
349
    return response
350

  
351
@api_method('COPY')
352
def object_copy(request, v_account, v_container, v_object):
353
    # Normal Response Codes: 201
354
    # Error Response Codes: serviceUnavailable (503),
355
    #                       itemNotFound (404),
356
    #                       unauthorized (401),
357
    #                       badRequest (400)
358
    
359
    destination = request.META.get('HTTP_DESTINATION')
360
    if not destination:
361
        raise BadRequest('Missing Destination.');
362
    
363
    parts = destination.split('/')
364
    if len(parts) < 3 or parts[0] != '':
365
        raise BadRequest('Bad Destination path.')
366
    dest_container = parts[1]
367
    dest_name = '/'.join(parts[2:])
368
        
369
    info = get_object_meta(request.user, v_container, v_object)
370
        
371
    content_type = request.META.get('CONTENT_TYPE')
372
    if content_type:
373
        info['content_type'] = content_type
374
        
375
    meta = get_object_meta(request)
376
    for k, v in meta.iteritems():
377
        info['meta'][k] = v
378
    
379
    copy_object(request.user, v_container, v_object, dest_container, dest_name)
380
    update_object_meta(request.user, dest_container, dest_name, info)
381
    
382
    response = HttpResponse(status = 201)
383

  
384
@api_method('POST')
385
def object_update(request, v_account, v_container, v_object):
386
    # Normal Response Codes: 202
387
    # Error Response Codes: serviceUnavailable (503),
388
    #                       itemNotFound (404),
389
    #                       unauthorized (401),
390
    #                       badRequest (400)
391
    
392
    meta = get_object_meta(request)
393
    
394
    update_object_meta(request.user, v_container, v_object, meta)
395
    return HttpResponse(status = 202)
396

  
397
@api_method('DELETE')
398
def object_delete(request, v_account, v_container, v_object):
399
    # Normal Response Codes: 204
400
    # Error Response Codes: serviceUnavailable (503),
401
    #                       itemNotFound (404),
402
    #                       unauthorized (401),
403
    #                       badRequest (400)
404
    
405
    delete_object(request.user, v_container, v_object)
406
    return HttpResponse(status = 204)
407

  
408
@api_method()
409
def method_not_allowed(request):
410
    raise BadRequest('Method not allowed.')
/dev/null
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
from django.db import models
6

  
7
class Container(models.Model):
8
    account = models.CharField(max_length = 256)
9
    name = models.CharField(max_length = 256)
10
    date_created = models.DateTimeField(auto_now_add = True)
11
    
12
    def __unicode__(self):
13
        return self.name
14

  
15
class Object(models.Model):
16
    container = models.ForeignKey(Container)
17
    name = models.CharField(max_length = 1024)
18
    length = models.IntegerField()
19
    type = models.CharField(max_length = 256)
20
    hash = models.CharField(max_length = 256)
21
    data = models.FileField(upload_to = 'data', max_length = 256)
22
    date_created = models.DateTimeField(auto_now_add = True)
23
    date_modified = models.DateTimeField(auto_now = True)
24
    
25
    def __unicode__(self):
26
        return self.name
27

  
28
class Metadata(models.Model):
29
    object = models.ForeignKey(Object)
30
    name = models.CharField(max_length = 256)
31
    value = models.CharField(max_length = 1024)
32
    date_created = models.DateTimeField(auto_now_add = True)
33
    date_modified = models.DateTimeField(auto_now = True)
/dev/null
1
{% spaceless %}
2
<?xml version="1.0" encoding="UTF-8"?>
3

  
4
<account name="{{ account }}">
5
  {% for container in containers %}
6
  <container>
7
    <name>{{ container.name }}</name>
8
    <count>{{ container.count }}</count>
9
    <bytes>{{ container.bytes }}</bytes>
10
  </container>
11
  {% endfor %}
12
</account>
13
{% endspaceless %}
/dev/null
1
{% spaceless %}
2
<?xml version="1.0" encoding="UTF-8"?>
3

  
4
<container name="{{ container }}">
5
  {% for object in objects %}
6
  <object>
7
    <name>{{ object.name }}</name>
8
    <hash>{{ object.hash }}</hash>
9
    <bytes>{{ object.bytes }}</bytes>
10
    <content_type>{{ object.content_type }}</content_type>
11
    <last_modified>{{ object.last_modified }}</last_modified>
12
  </object>
13
  {% endfor %}
14
</container>
15
{% endspaceless %}
/dev/null
1
"""
2
This file demonstrates two different styles of tests (one doctest and one
3
unittest). These will both pass when you run "manage.py test".
4

  
5
Replace these with more appropriate tests for your application.
6
"""
7

  
8
from django.test import TestCase
9

  
10
class SimpleTest(TestCase):
11
    def test_basic_addition(self):
12
        """
13
        Tests that 1 + 1 always equals 2.
14
        """
15
        self.failUnlessEqual(1 + 1, 2)
16

  
17
__test__ = {"doctest": """
18
Another way to test that 1 + 1 is equal to 2.
19

  
20
>>> 1 + 1 == 2
21
True
22
"""}
23

  
/dev/null
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
from django.conf.urls.defaults import *
6

  
7
# TODO: This only works when in this order.
8
# TODO: Define which characters can be used in each "path" component.
9
urlpatterns = patterns('pithos.api.functions',
10
    (r'^$', 'authenticate'),
11
    (r'^(?P<v_account>.+?)/(?P<v_container>.+?)/(?P<v_object>.+?)$', 'object_demux'),
12
    (r'^(?P<v_account>.+?)/(?P<v_container>.+?)$', 'container_demux'),
13
    (r'^(?P<v_account>.+?)$', 'account_demux')
14
)
/dev/null
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
from datetime import timedelta, tzinfo
6
from functools import wraps
7
from random import choice
8
from string import ascii_letters, digits
9
from time import time
10
from traceback import format_exc
11
from wsgiref.handlers import format_date_time
12

  
13
from django.conf import settings
14
from django.http import HttpResponse
15
from django.template.loader import render_to_string
16
from django.utils import simplejson as json
17

  
18
from pithos.api.faults import Fault, BadRequest, ItemNotFound, ServiceUnavailable
19
#from synnefo.db.models import SynnefoUser, Image, ImageMetadata, VirtualMachine, VirtualMachineMetadata
20

  
21
import datetime
22
import dateutil.parser
23
import logging
24

  
25
import re
26
import calendar
27

  
28
# Part of newer Django versions.
29

  
30
__D = r'(?P<day>\d{2})'
31
__D2 = r'(?P<day>[ \d]\d)'
32
__M = r'(?P<mon>\w{3})'
33
__Y = r'(?P<year>\d{4})'
34
__Y2 = r'(?P<year>\d{2})'
35
__T = r'(?P<hour>\d{2}):(?P<min>\d{2}):(?P<sec>\d{2})'
36
RFC1123_DATE = re.compile(r'^\w{3}, %s %s %s %s GMT$' % (__D, __M, __Y, __T))
37
RFC850_DATE = re.compile(r'^\w{6,9}, %s-%s-%s %s GMT$' % (__D, __M, __Y2, __T))
38
ASCTIME_DATE = re.compile(r'^\w{3} %s %s %s %s$' % (__M, __D2, __T, __Y))
39

  
40
def parse_http_date(date):
41
    """
42
    Parses a date format as specified by HTTP RFC2616 section 3.3.1.
43

  
44
    The three formats allowed by the RFC are accepted, even if only the first
45
    one is still in widespread use.
46

  
47
    Returns an floating point number expressed in seconds since the epoch, in
48
    UTC.
49
    """
50
    # emails.Util.parsedate does the job for RFC1123 dates; unfortunately
51
    # RFC2616 makes it mandatory to support RFC850 dates too. So we roll
52
    # our own RFC-compliant parsing.
53
    for regex in RFC1123_DATE, RFC850_DATE, ASCTIME_DATE:
54
        m = regex.match(date)
55
        if m is not None:
56
            break
57
    else:
58
        raise ValueError("%r is not in a valid HTTP date format" % date)
59
    try:
60
        year = int(m.group('year'))
61
        if year < 100:
62
            if year < 70:
63
                year += 2000
64
            else:
65
                year += 1900
66
        month = MONTHS.index(m.group('mon').lower()) + 1
67
        day = int(m.group('day'))
68
        hour = int(m.group('hour'))
69
        min = int(m.group('min'))
70
        sec = int(m.group('sec'))
71
        result = datetime.datetime(year, month, day, hour, min, sec)
72
        return calendar.timegm(result.utctimetuple())
73
    except Exception:
74
        raise ValueError("%r is not a valid date" % date)
75

  
76
def parse_http_date_safe(date):
77
    """
78
    Same as parse_http_date, but returns None if the input is invalid.
79
    """
80
    try:
81
        return parse_http_date(date)
82
    except Exception:
83
        pass
84

  
85
def get_object_meta(request):
86
    """
87
    Get all X-Object-Meta-* headers in a dict.
88
    """
89
    prefix = 'HTTP_X_OBJECT_META_'
90
    return dict([(k[len(prefix):].lower(), v) for k, v in request.META.iteritems() if k.startswith(prefix)])
91
    
92
def get_range(request):
93
    """
94
    Parse a Range header from the request.
95
    Either returns None, or an (offset, length) tuple.
96
    If no offset is defined offset equals 0.
97
    If no length is defined length is None.
98
    """
99
    
100
    range = request.GET.get('range')
101
    if not range:
102
        return None
103
    
104
    range = range.replace(' ', '')
105
    if not range.startswith('bytes='):
106
        return None
107
    
108
    parts = range.split('-')
109
    if len(parts) != 2:
110
        return None
111
    
112
    offset, length = parts
113
    if offset == '' and length == '':
114
        return None
115
    
116
    if offset != '':
117
        try:
118
            offset = int(offset)
119
        except ValueError:
120
            return None
121
    else:
122
        offset = 0
123
    
124
    if length != '':
125
        try:
126
            length = int(length)
127
        except ValueError:
128
            return None
129
    else:
130
        length = None
131
    
132
    return (offset, length)
133

  
134
# def get_vm(server_id):
135
#     """Return a VirtualMachine instance or raise ItemNotFound."""
136
#     
137
#     try:
138
#         server_id = int(server_id)
139
#         return VirtualMachine.objects.get(id=server_id)
140
#     except ValueError:
141
#         raise BadRequest('Invalid server ID.')
142
#     except VirtualMachine.DoesNotExist:
143
#         raise ItemNotFound('Server not found.')
144
# 
145
# def get_vm_meta(server_id, key):
146
#     """Return a VirtualMachineMetadata instance or raise ItemNotFound."""
147
#     
148
#     try:
149
#         server_id = int(server_id)
150
#         return VirtualMachineMetadata.objects.get(meta_key=key, vm=server_id)
151
#     except VirtualMachineMetadata.DoesNotExist:
152
#         raise ItemNotFound('Metadata key not found.')
153
# 
154
# def get_image(image_id):
155
#     """Return an Image instance or raise ItemNotFound."""
156
#     
157
#     try:
158
#         image_id = int(image_id)
159
#         return Image.objects.get(id=image_id)
160
#     except Image.DoesNotExist:
161
#         raise ItemNotFound('Image not found.')
162
# 
163
# def get_image_meta(image_id, key):
164
#     """Return a ImageMetadata instance or raise ItemNotFound."""
165
# 
166
#     try:
167
#         image_id = int(image_id)
168
#         return ImageMetadata.objects.get(meta_key=key, image=image_id)
169
#     except ImageMetadata.DoesNotExist:
170
#         raise ItemNotFound('Metadata key not found.')
171
# 
172
# 
173
# def get_request_dict(request):
174
#     """Returns data sent by the client as a python dict."""
175
#     
176
#     data = request.raw_post_data
177
#     if request.META.get('CONTENT_TYPE').startswith('application/json'):
178
#         try:
179
#             return json.loads(data)
180
#         except ValueError:
181
#             raise BadRequest('Invalid JSON data.')
182
#     else:
183
#         raise BadRequest('Unsupported Content-Type.')
184

  
185
def update_response_headers(request, response):
186
    if request.serialization == 'xml':
187
        response['Content-Type'] = 'application/xml; charset=UTF-8'
188
    elif request.serialization == 'json':
189
        response['Content-Type'] = 'application/json; charset=UTF-8'
190
    else:
191
        response['Content-Type'] = 'text/plain; charset=UTF-8'
192

  
193
    if settings.TEST:
194
        response['Date'] = format_date_time(time())
195

  
196
def render_fault(request, fault):
197
    if settings.DEBUG or settings.TEST:
198
        fault.details = format_exc(fault)
199
    
200
#     if request.serialization == 'xml':
201
#         data = render_to_string('fault.xml', {'fault': fault})
202
#     else:
203
#         d = {fault.name: {'code': fault.code, 'message': fault.message, 'details': fault.details}}
204
#         data = json.dumps(d)
205
    
206
#     resp = HttpResponse(data, status=fault.code)
207
    resp = HttpResponse(status = fault.code)
208
    update_response_headers(request, resp)
209
    return resp
210

  
211
def request_serialization(request, format_allowed=False):
212
    """
213
    Return the serialization format requested.
214
       
215
    Valid formats are 'text' and 'json', 'xml' if `format_allowed` is True.
216
    """
217
    
218
    if not format_allowed:
219
        return 'text'
220
    
221
    format = request.GET.get('format')
222
    if format == 'json':
223
        return 'json'
224
    elif format == 'xml':
225
        return 'xml'
226
    
227
    # TODO: Do we care of Accept headers?
228
#     for item in request.META.get('HTTP_ACCEPT', '').split(','):
229
#         accept, sep, rest = item.strip().partition(';')
230
#         if accept == 'application/json':
231
#             return 'json'
232
#         elif accept == 'application/xml':
233
#             return 'xml'
234
    
235
    return 'text'
236

  
237
def api_method(http_method = None, format_allowed = False):
238
    """
239
    Decorator function for views that implement an API method.
240
    """
241
    
242
    def decorator(func):
243
        @wraps(func)
244
        def wrapper(request, *args, **kwargs):
245
            try:
246
                request.serialization = request_serialization(request, format_allowed)
247
                # TODO: Authenticate.
248
                # TODO: Return 401/404 when the account is not found.
249
                request.user = "test"
250
                # TODO: Check parameter sizes.
251
                if http_method and request.method != http_method:
252
                    raise BadRequest('Method not allowed.')
253
                
254
                resp = func(request, *args, **kwargs)
255
                update_response_headers(request, resp)
256
                return resp
257
            
258
            except Fault, fault:
259
                return render_fault(request, fault)
260
            except BaseException, e:
261
                logging.exception('Unexpected error: %s' % e)
262
                fault = ServiceUnavailable('Unexpected error')
263
                return render_fault(request, fault)
264
        return wrapper
265
    return decorator
/dev/null
1
# Create your views here.
/dev/null
1
import os
2
import sqlite3
3
import json
4

  
5
basepath = '/Users/butters/src/pithos/backends/content' #full path
6
if not os.path.exists(basepath):
7
    os.makedirs(basepath)
8
db = '/'.join([basepath, 'db'])
9
con = sqlite3.connect(db)
10
# Create tables
11
print 'Creating tables....'
12
sql = '''create table if not exists objects(name varchar(2560))'''
13
print sql
14
con.execute(sql)
15
# Save (commit) the changes
16
con.commit()
17
    
18
def create_container(name):
19
    """ creates a new container with the given name
20
    if it doesn't exists under the basepath """
21
    fullname = '/'.join([basepath, name])    
22
    if not os.path.exists(fullname):
23
        os.chdir(basepath)
24
        os.mkdir(name)
25
    else:
26
        raise NameError('Container already exists')
27
    return
28

  
29
def delete_container(name):
30
    """ deletes the container with the given name
31
        if it exists under the basepath """
32
    fullname = '/'.join([basepath, name])    
33
    if not os.path.exists(fullname):
34
        raise NameError('Container does not exist')
35
    if not list_objects(name):
36
        raise Error('Container is not empty')
37
    else:
38
        os.chdir(basepath)
39
        os.rmdir(name)
40
    return
41

  
42
def get_container_meta(name):
43
    """ returns a dictionary with the container metadata """
44
    fullname = '/'.join([basepath, name])
45
    if not os.path.exists(fullname):
46
        raise NameError('Container does not exist')
47
    contents = os.listdir(fullname) 
48
    count = len(contents)
49
    size = sum(os.path.getsize('/'.join([basepath, name, objectname])) for objectname in contents)
50
    return {'name': name, 'count': count, 'bytes': size}
51

  
52
def list_containers():
53
    return os.listdir(basepath) 
54

  
55
def list_objects(container, prefix='', delimiter=None):
56
    dir = '/'.join([basepath, container])
57
    if not os.path.exists(dir):
58
        raise NameError('Container does not exist')
59
    search_str = ''
60
    if prefix:
61
        search_str = '/'.join([search_str, prefix])
62
    #if delimiter:
63
    if None:
64
        search_str = ''.join(['%', search_str, '%', delimiter])
65
        print search_str
66
        c = con.execute('select * from objects where name like ''?'' order by name', (search_str,))
67
    else:
68
        search_str = ''.join(['%', search_str, '%'])
69
        print search_str
70
        c = con.execute('select * from objects where name like ''?'' order by name', (search_str,))
71
    l = []
72
    for row in c.fetchall():
73
        s = ''
74
        print row[0]
75
        rest = str(row[0]).split(prefix)[1]
76
        print rest
77
        #if delimiter:
78
        #    rest = rest.partition(delimiter)[0]
79
        #print rest
80
        folders = rest.split('/')[:-1]
81
        for folder in folders:
82
            path = ''.join([s, folder, '/'])
83
            if path not in l:
84
                l.append(path)
85
            s = ''.join([s, folder, '/'])
86
        l.append(rest)
87
    return l
88

  
89
def get_object_meta(container, name):
90
    dir = '/'.join([basepath, container])
91
    if not os.path.exists(dir):
92
        raise NameError('Container does not exist')
93
    else:
94
        os.chdir(dir)
95
    location = __get_object_linkinfo('/'.join([container, name]))
96
    location = '.'.join([location, 'meta'])
97
    f = open(location, 'r')
98
    data = json.load(f)
99
    f.close()
100
    return data
101

  
102
def get_object_data(container, name, offset=0, length=-1):
103
    dir = '/'.join([basepath, container])
104
    if not os.path.exists(dir):
105
        raise NameError('Container does not exist')
106
    else:
107
        os.chdir(dir)
108
    location = __get_object_linkinfo('/'.join([container, name]))
109
    f = open(location, 'r')
110
    if offset:
111
        f.seek(offset)
112
    data = f.read(length)
113
    f.close()
114
    return data
115

  
116
def update_object(container, name, data):
117
    dir = '/'.join([basepath, container])
118
    if not os.path.exists(dir):
119
        raise NameError('Container does not exist')
120
    try:
121
        location = __get_object_linkinfo('/'.join([container, name]))
122
    except NameError:
123
        # new object
124
        location = str(__save_linkinfo('/'.join([container, name])))
125
        print ':'.join(['Creating new location', location])
126
    __store_data(location, container, data)
127
    return
128

  
129
def update_object_meta(container, name, meta):
130
    dir = '/'.join([basepath, container])
131
    if not os.path.exists(dir):
132
        raise NameError('Container does not exist')
133
    try:
134
        location = __get_object_linkinfo('/'.join([container, name]))
135
    except NameError:
136
        # new object
137
        location = str(__save_linkinfo('/'.join([container, name])))
138
        print ':'.join(['Creating new location', location])
139
    __store_metadata(location, container, meta)
140
    return
141

  
142
def copy_object(src_container, src_name, dest_container, dest_name, meta):
143
    fullname = '/'.join([basepath, dest_container])    
144
    if not os.path.exists(fullname):
145
        raise NameError('Destination container does not exist')
146
    update_object(dest_container, dest_name, get_object_data(src_container, src_name))
147
    src_object_meta = get_object_meta(src_container, src_name)
148
    if (type(src_object_meta) == types.DictType):
149
        distinct_keys = [k for k in src_object_meta.keys() if k not in meta.keys()]
150
        for k in distinct_keys:
151
            meta[k] = src_object_meta[k]
152
            update_object_meta(dest_container, dest_name, meta)
153
    else:
154
        update_object_meta(dest_container, dest_name, meta)
155
    return
156

  
157
def delete_object(container, name):
158
    return
159

  
160
def __store_metadata(location, container, meta):
161
    dir = '/'.join([basepath, container])
162
    if not os.path.exists(dir):
163
        raise NameError('Container does not exist')
164
    else:
165
        os.chdir(dir)
166
    location = '.'.join([location, 'meta'])
167
    f = open(location, 'w')
168
    data = json.dumps(meta)
169
    f.write(data)
170
    f.close()
171

  
172
def __store_data(location, container, data):
173
    dir = '/'.join([basepath, container])
174
    if not os.path.exists(dir):
175
        raise NameError('Container does not exist')
176
    else:
177
        os.chdir(dir)
178
    f = open(location, 'w')
179
    f.write(data)
180
    f.close()
181
    
182
def __get_object_linkinfo(name):
183
    c = con.execute('select rowid from objects where name=''?''', (name,))
184
    row = c.fetchone()
185
    if row:
186
        return str(row[0])
187
    else:
188
        raise NameError('Object not found')
189

  
190
def __save_linkinfo(name):
191
    id = con.execute('insert into objects(name) values(?)', (name,)).lastrowid
192
    con.commit()
193
    return id
194
    
195
if __name__ == '__main__':
196
    dirname = 'papagian'
197
    #create_container(dirname)
198
    #assert os.path.exists(dirname)
199
    #assert os.path.isdir(dirname)
200
    
201
    #print get_container_meta(dirname)
202
    
203
    #update_object_meta(dirname, 'photos/animals/dog.jpg', {'name':'dog.jpg'})
204
    #update_object_meta(dirname, 'photos/animals/dog.jpg', {'name':'dog.jpg', 'type':'image', 'size':400})
205
    #print get_object_meta(dirname, 'photos/animals/dog.jpg')
206
    
207
    #f = open('dummy.py')
208
    #data  = f.read()
209
    #update_object(dirname, 'photos/animals/dog.jpg', data)
210
    #update_object(dirname, 'photos/animals/cat.jpg', data)
211
    #update_object(dirname, 'photos/animals/thumbs/cat.jpg', data)
212
    #update_object(dirname, 'photos/fruits/banana.jpg', data)
213
    
214
    #print list_objects(dirname, 'photos/animals');
215
    
216
    copy_object(dirname, 'photos/animals/dog.jpg', 'photos/animals/dog2.jpg')
217
    copy_object(dirname, 'photos/animals/dg.jpg', 'photos/animals/dog2.jpg')
218
    
/dev/null
1
"""
2
Dummy backend with debugging output
3

  
4
A backend with no functionality other than producing debugging output.
5
"""
6

  
7
import logging
8

  
9
def binary_search_name(a, x, lo = 0, hi = None):
10
    """
11
    Search a sorted array of dicts for the value of the key 'name'.
12
    Raises ValueError if the value is not found.
13
    
14
    a -- the array
15
    x -- the value to search for
16
    """
17
    if hi is None:
18
        hi = len(a)
19
    while lo < hi:
20
        mid = (lo + hi) // 2
21
        midval = a[mid]['name']
22
        if midval < x:
23
            lo = mid + 1
24
        elif midval > x: 
25
            hi = mid
26
        else:
27
            return mid
28
    raise ValueError()
29

  
30
def get_account_meta(account):
31
    logging.debug("get_account_meta: %s %s", account, name)
32
    return {'count': 13, 'bytes': 3148237468}
33

  
34
def create_container(account, name):
35
    """
36
    Returns True if the container was created, False if it already exists.
37
    """
38
    logging.debug("create_container: %s %s", account, name)
39
    return True
40

  
41
def delete_container(account, name):
42
    logging.debug("delete_container: %s %s", account, name)
43
    return
44

  
45
def get_container_meta(account, name):
46
    logging.debug("get_container_meta: %s %s", account, name)
47
    return {'count': 22, 'bytes': 245}
48

  
49
def list_containers(account, marker = None, limit = 10000):
50
    logging.debug("list_containers: %s %s %s", account, marker, limit)
51
    
52
    containers = [
53
            {'name': '1', 'count': 2, 'bytes': 123},
54
            {'name': '2', 'count': 22, 'bytes': 245},
55
            {'name': '3', 'count': 222, 'bytes': 83745},
56
            {'name': 'four', 'count': 2222, 'bytes': 274365}
57
        ]
58
    
59
    start = 0
60
    if marker:
61
        try:
62
            start = binary_search_name(containers, marker) + 1
63
        except ValueError:
64
            pass
65
    if not limit or limit > 10000:
66
        limit = 10000
67
    
68
    return containers[start:start + limit]
69

  
70
def list_objects(account, container, prefix = None, delimiter = None, marker = None, limit = 10000):
71
    logging.debug("list_objects: %s %s %s %s %s %s", account, container, prefix, delimiter, marker, limit)
72

  
73
    objects = [
74
            {'name': 'other', 'hash': 'dfgs', 'bytes': 0, 'content_type': 'application/directory', 'last_modified': 23453454},
75
            {'name': 'other/something', 'hash': 'vkajf', 'bytes': 234, 'content_type': 'application/octet-stream', 'last_modified': 878434562},
76
            {'name': 'photos', 'hash': 'kajdsn', 'bytes': 0, 'content_type': 'application/directory', 'last_modified': 1983274},
77
            {'name': 'photos/asdf', 'hash': 'jadsfkj', 'bytes': 0, 'content_type': 'application/directory', 'last_modified': 378465873},
78
            {'name': 'photos/asdf/test', 'hash': 'sudfhius', 'bytes': 37284, 'content_type': 'text/plain', 'last_modified': 93674212},
79
            {'name': 'photos/me.jpg', 'hash': 'sdgsdfgsf', 'bytes': 534, 'content_type': 'image/jpeg', 'last_modified': 262345345},
80
            {'name': 'photos/text.txt', 'hash': 'asdfasd', 'bytes': 34243, 'content_type': 'text/plain', 'last_modified': 45345345}
81
        ]
82
    
83
    if prefix or delimiter:
84
        if prefix:
85
            objects = [x for x in objects if x['name'].startswith(prefix)]
86
        if delimiter:
87
            pseudo_objects = {}
88
            for x in objects:
89
                pseudo_name = x['name'][len(prefix):]
90
                i = pseudo_name.find(delimiter)
91
                if i != -1:
92
                    pseudo_name = pseudo_name[:i]
93
                # TODO: Virtual directories.
94
                if pseudo_name not in pseudo_objects:
95
                    pseudo_objects[pseudo_name] = x
96
            objects = sorted(pseudo_objects.values(), key=lambda o: o['name'])
97
        
98
    start = 0
99
    if marker:
100
        try:
101
            start = binary_search_name(objects, marker) + 1
102
        except ValueError:
103
            pass
104
    if not limit or limit > 10000:
105
        limit = 10000
106
    
107
    return objects[start:start + limit]
108

  
109
def get_object_meta(account, container, name):
110
    logging.debug("get_object_meta: %s %s %s", account, container, name)
111
    meta = {'meat': 'bacon', 'fruit': 'apple'}
112
    return {'hash': 'asdfasd', 'bytes': 34243, 'content_type': 'text/plain', 'last_modified': 45345345, 'meta': meta}
113

  
114
def update_object_meta(account, container, name, meta):
115
    logging.debug("update_object_meta: %s %s %s %s", account, container, name, meta)
116
    for k, v in meta.iteritems():
117
        pass
118
    return
119

  
120
def get_object_data(account, container, name, offset=0, length=0):
121
    logging.debug("get_object_data: %s %s %s %s %s", account, container, name, offset, length)
122
    return ''
123

  
124
def update_object_data(account, container, name, meta, data):
125
    logging.debug("update_object_data: %s %s %s %s %s", account, container, name, meta, data)
126
    return
127

  
128
def copy_object(account, container, name, new_container, new_name):
129
    logging.debug("copy_object: %s %s %s %s %s", account, container, name, new_container, new_name)
130
    return
131

  
132
def delete_object(account, container, name):
133
    logging.debug("delete_object: %s %s %s", account, container, name)
134
    return
/dev/null
1
#!/usr/bin/env python
2
from django.core.management import execute_manager
3
try:
4
    import settings # Assumed to be in the same directory.
5
except ImportError:
6
    import sys
7
    sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
8
    sys.exit(1)
9

  
10
if __name__ == "__main__":
11
    execute_manager(settings)
b/pithos/api/admin.py
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
from api.models import Container, Object, Metadata
6
from django.contrib import admin
7

  
8
admin.site.register(Container)
9
admin.site.register(Object)
10
admin.site.register(Metadata)
b/pithos/api/faults.py
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
def camelCase(s):
6
    return s[0].lower() + s[1:]
7

  
8

  
9
class Fault(Exception):
10
    def __init__(self, message='', details='', name=''):
11
        Exception.__init__(self, message, details, name)
12
        self.message = message
13
        self.details = details
14
        self.name = name or camelCase(self.__class__.__name__)
15

  
16
class NotModified(Fault):
17
    code = 304
18

  
19
class BadRequest(Fault):
20
    code = 400
21

  
22
class Unauthorized(Fault):
23
    code = 401
24

  
25
class ResizeNotAllowed(Fault):
26
    code = 403
27

  
28
class ItemNotFound(Fault):
29
    code = 404
30

  
31
class LengthRequired(Fault):
32
    code = 411
33

  
34
class PreconditionFailed(Fault):
35
    code = 412
36

  
37
class RangeNotSatisfiable(Fault):
38
    code = 416
39

  
40
class UnprocessableEntity(Fault):
41
    code = 422
42

  
43
class ServiceUnavailable(Fault):
44
    code = 503
b/pithos/api/functions.py
1
#
2
# Copyright (c) 2011 Greek Research and Technology Network
3
#
4

  
5
from django.http import HttpResponse
6
from django.template.loader import render_to_string
7
from django.utils import simplejson as json
8
from django.utils.http import http_date, parse_etags
9

  
10
try:
11
    from django.utils.http import parse_http_date_safe
12
except:
13
    from pithos.api.util import parse_http_date_safe
14

  
15
from pithos.api.faults import Fault, NotModified, BadRequest, Unauthorized, LengthRequired, PreconditionFailed, RangeNotSatisfiable, UnprocessableEntity
16
from pithos.api.util import get_object_meta, get_range, api_method
17

  
18
from pithos.backends.dummy_debug import *
19

  
20
import logging
21

  
22
logging.basicConfig(level=logging.DEBUG)
23

  
24
@api_method('GET')
25
def authenticate(request):
26
    # Normal Response Codes: 204
27
    # Error Response Codes: serviceUnavailable (503),
28
    #                       unauthorized (401),
29
    #                       badRequest (400)
30
    
31
    x_auth_user = request.META.get('HTTP_X_AUTH_USER')
32
    x_auth_key = request.META.get('HTTP_X_AUTH_KEY')
33
    
34
    if not x_auth_user or not x_auth_key:
35
        raise BadRequest('Missing auth user or key.')
36
    
37
    response = HttpResponse(status = 204)
38
    response['X-Auth-Token'] = 'eaaafd18-0fed-4b3a-81b4-663c99ec1cbb'
39
    # TODO: Do we support redirections?
40
    #response['X-Storage-Url'] = 'https://storage.grnet.gr/pithos/v1.0/<some reference>'
41
    return response
42

  
43
def account_demux(request, v_account):
44
    if request.method == 'HEAD':
45
        return account_meta(request, v_account)
46
    elif request.method == 'GET':
47
        return container_list(request, v_account)
48
    else:
49
        return method_not_allowed(request)
50

  
51
def container_demux(request, v_account, v_container):
52
    if request.method == 'HEAD':
53
        return container_meta(request, v_account, v_container)
54
    elif request.method == 'GET':
55
        return object_list(request, v_account, v_container)
56
    elif request.method == 'PUT':
57
        return container_create(request, v_account, v_container)
58
    elif request.method == 'DELETE':
59
        return container_delete(request, v_account, v_container)
60
    else:
61
        return method_not_allowed(request)
62

  
63
def object_demux(request, v_account, v_container, v_object):
64
    if request.method == 'HEAD':
65
        return object_meta(request, v_account, v_container, v_object)
66
    elif request.method == 'GET':
67
        return object_read(request, v_account, v_container, v_object)
68
    elif request.method == 'PUT':
69
        return object_write(request, v_account, v_container, v_object)
70
    elif request.method == 'COPY':
71
        return object_copy(request, v_account, v_container, v_object)
72
    elif request.method == 'POST':
73
        return object_update(request, v_account, v_container, v_object)
74
    elif request.method == 'DELETE':
75
        return object_delete(request, v_account, v_container, v_object)
76
    else:
77
        return method_not_allowed(request)
78

  
79
@api_method('HEAD')
80
def account_meta(request, v_account):
81
    # Normal Response Codes: 204
82
    # Error Response Codes: serviceUnavailable (503),
83
    #                       itemNotFound (404),
84
    #                       unauthorized (401),
85
    #                       badRequest (400)
86
    
87
    container_count, bytes_count = get_account_meta(request.user)
88
    
89
    response = HttpResponse(status = 204)
90
    response['X-Account-Container-Count'] = container_count
91
    response['X-Account-Total-Bytes-Used'] = bytes_count
92
    return response
93

  
94
@api_method('GET', format_allowed = True)
95
def container_list(request, v_account):
96
    # Normal Response Codes: 200, 204
97
    # Error Response Codes: serviceUnavailable (503),
98
    #                       unauthorized (401),
99
    #                       badRequest (400)
100
    
101
    marker = request.GET.get('marker')
102
    limit = request.GET.get('limit')
103
    if limit:
104
        try:
105
            limit = int(limit)
106
        except ValueError:
107
            limit = None
108
    
109
    containers = list_containers(request.user, marker, limit)
110
    if len(containers) == 0:
111
        return HttpResponse(status = 204)
112
    
113
    if request.serialization == 'xml':
114
        data = render_to_string('containers.xml', {'account': request.user, 'containers': containers})
115
    elif request.serialization  == 'json':
116
        data = json.dumps(containers)
117
    else:
118
        data = '\n'.join(x['name'] for x in containers)
119
    
120
    return HttpResponse(data, status = 200)
121

  
122
@api_method('HEAD')
123
def container_meta(request, v_account, v_container):
124
    # Normal Response Codes: 204
125
    # Error Response Codes: serviceUnavailable (503),
126
    #                       itemNotFound (404),
127
    #                       unauthorized (401),
128
    #                       badRequest (400)
129
    
130
    object_count, bytes_count = get_container_meta(request.user, v_container)
131
    
132
    response = HttpResponse(status = 204)
... This diff was truncated because it exceeds the maximum size that can be displayed.

Also available in: Unified diff