libertree-model 0.9.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/libertree/compat.rb +29 -0
- data/lib/libertree/console.rb +17 -0
- data/lib/libertree/db.rb +37 -0
- data/lib/libertree/embedder.rb +69 -0
- data/lib/libertree/embedding/custom-providers.rb +148 -0
- data/lib/libertree/model/account-settings.rb +14 -0
- data/lib/libertree/model/account.rb +487 -0
- data/lib/libertree/model/chat-message.rb +75 -0
- data/lib/libertree/model/comment-like.rb +68 -0
- data/lib/libertree/model/comment.rb +164 -0
- data/lib/libertree/model/contact-list.rb +81 -0
- data/lib/libertree/model/embed-cache.rb +6 -0
- data/lib/libertree/model/file.rb +6 -0
- data/lib/libertree/model/forest.rb +82 -0
- data/lib/libertree/model/group-member.rb +13 -0
- data/lib/libertree/model/group.rb +47 -0
- data/lib/libertree/model/has-display-text.rb +34 -0
- data/lib/libertree/model/has-searchable-text.rb +19 -0
- data/lib/libertree/model/ignored-member.rb +13 -0
- data/lib/libertree/model/invitation.rb +6 -0
- data/lib/libertree/model/is-remote-or-local.rb +30 -0
- data/lib/libertree/model/job.rb +93 -0
- data/lib/libertree/model/member.rb +222 -0
- data/lib/libertree/model/message.rb +154 -0
- data/lib/libertree/model/node.rb +22 -0
- data/lib/libertree/model/node_affiliation.rb +14 -0
- data/lib/libertree/model/node_subscription.rb +23 -0
- data/lib/libertree/model/notification.rb +71 -0
- data/lib/libertree/model/pool-post.rb +17 -0
- data/lib/libertree/model/pool.rb +172 -0
- data/lib/libertree/model/post-hidden.rb +21 -0
- data/lib/libertree/model/post-like.rb +72 -0
- data/lib/libertree/model/post-revision.rb +9 -0
- data/lib/libertree/model/post.rb +735 -0
- data/lib/libertree/model/profile.rb +25 -0
- data/lib/libertree/model/remote-storage-connection.rb +6 -0
- data/lib/libertree/model/river.rb +249 -0
- data/lib/libertree/model/server.rb +35 -0
- data/lib/libertree/model/session-account.rb +10 -0
- data/lib/libertree/model/url-expansion.rb +6 -0
- data/lib/libertree/model.rb +48 -0
- data/lib/libertree/query.rb +167 -0
- data/lib/libertree/render.rb +28 -0
- metadata +198 -0
@@ -0,0 +1,164 @@
|
|
1
|
+
require_relative '../embedder'
|
2
|
+
|
3
|
+
module Libertree
|
4
|
+
module Model
|
5
|
+
class Comment < Sequel::Model(:comments)
|
6
|
+
include IsRemoteOrLocal
|
7
|
+
extend HasSearchableText
|
8
|
+
include HasDisplayText
|
9
|
+
|
10
|
+
def after_create
|
11
|
+
super
|
12
|
+
|
13
|
+
if self.local? && self.post.distribute?
|
14
|
+
Libertree::Model::Job.create_for_forests(
|
15
|
+
{
|
16
|
+
task: 'request:COMMENT',
|
17
|
+
params: { 'comment_id' => self.id, }
|
18
|
+
},
|
19
|
+
*self.forests
|
20
|
+
)
|
21
|
+
end
|
22
|
+
Libertree::Embedder.autoembed(self.text)
|
23
|
+
end
|
24
|
+
|
25
|
+
# TODO: DB: association
|
26
|
+
def member
|
27
|
+
@member = Member[self.member_id]
|
28
|
+
end
|
29
|
+
|
30
|
+
# TODO: DB: association
|
31
|
+
def post
|
32
|
+
@post = Post[self.post_id]
|
33
|
+
end
|
34
|
+
|
35
|
+
def before_destroy
|
36
|
+
if self.post
|
37
|
+
remaining_comments = self.post.comments - [self]
|
38
|
+
self.post.time_commented = remaining_comments.map(&:time_created).max
|
39
|
+
end
|
40
|
+
|
41
|
+
if self.local? && self.post.distribute?
|
42
|
+
Libertree::Model::Job.create_for_forests(
|
43
|
+
{
|
44
|
+
task: 'request:COMMENT-DELETE',
|
45
|
+
params: { 'comment_id' => self.id, }
|
46
|
+
},
|
47
|
+
*self.forests
|
48
|
+
)
|
49
|
+
end
|
50
|
+
super
|
51
|
+
end
|
52
|
+
|
53
|
+
# TODO: the correct method to call is "destroy"
|
54
|
+
def delete
|
55
|
+
self.before_destroy
|
56
|
+
super
|
57
|
+
end
|
58
|
+
|
59
|
+
# NOTE: deletion is NOT distributed when force=true
|
60
|
+
def delete_cascade(force=false)
|
61
|
+
self.before_destroy unless force
|
62
|
+
DB.dbh[ "SELECT delete_cascade_comment(?)", self.id ].get
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.create(*args)
|
66
|
+
comment = super
|
67
|
+
account = comment.member.account
|
68
|
+
post = comment.post
|
69
|
+
|
70
|
+
post.time_commented = comment.time_created
|
71
|
+
post.mark_as_unread_by_all except: [account]
|
72
|
+
if account
|
73
|
+
post.mark_as_read_by account
|
74
|
+
account.subscribe_to comment.post
|
75
|
+
end
|
76
|
+
post.notify_about_comment comment
|
77
|
+
post.save
|
78
|
+
|
79
|
+
comment
|
80
|
+
end
|
81
|
+
|
82
|
+
def likes
|
83
|
+
@likes ||= CommentLike.where(comment_id: self.id).reverse_order(:id)
|
84
|
+
end
|
85
|
+
|
86
|
+
def notify_about_like(like)
|
87
|
+
notification_attributes = {
|
88
|
+
'type' => 'comment-like',
|
89
|
+
'comment_like_id' => like.id,
|
90
|
+
}
|
91
|
+
local_comment_author = like.comment.member.account
|
92
|
+
like_author = like.member.account
|
93
|
+
|
94
|
+
if(
|
95
|
+
local_comment_author &&
|
96
|
+
(!like_author || local_comment_author.id != like_author.id) &&
|
97
|
+
! local_comment_author.ignoring?(like.member)
|
98
|
+
)
|
99
|
+
local_comment_author.notify_about notification_attributes
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def like_by(member)
|
104
|
+
# take advantage of cached self.likes
|
105
|
+
if self.likes.is_a? Array
|
106
|
+
self.likes.find {|like| like.member.id == member.id}
|
107
|
+
else
|
108
|
+
CommentLike[ member_id: member.id, comment_id: self.id ]
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# overriding method from IsRemoteOrLocal
|
113
|
+
def forests
|
114
|
+
if self.post.remote?
|
115
|
+
self.post.server.forests
|
116
|
+
else
|
117
|
+
Libertree::Model::Forest.all_local_is_member
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def to_hash
|
122
|
+
{
|
123
|
+
'id' => self.id,
|
124
|
+
'time_created' => self.time_created,
|
125
|
+
'time_updated' => self.time_updated,
|
126
|
+
'text' => self.text,
|
127
|
+
'post_id' => self.post_id,
|
128
|
+
}
|
129
|
+
end
|
130
|
+
|
131
|
+
# TODO: When more visibilities come, restrict this result set by visibility
|
132
|
+
def self.comments_since_id(comment_id)
|
133
|
+
self.where{ id > comment_id }.order(:id)
|
134
|
+
end
|
135
|
+
|
136
|
+
# @param [Hash] opt options for restricting the comment set returned
|
137
|
+
# @option opts [Fixnum] :from_id Only return comments with id greater than or equal to this id
|
138
|
+
# @option opts [Fixnum] :to_id Only return comments with id less than this id
|
139
|
+
# @option opts [Account] :viewing_account An account to use to hide comments by ignored members
|
140
|
+
def self.on_post(post, opt = {})
|
141
|
+
res = Comment.where(post_id: post.id)
|
142
|
+
if opt[:viewing_account]
|
143
|
+
# Array() because sometimes opt[:viewing_account] is a strange nil-like object for some reason.
|
144
|
+
# Ramaze weirdness?
|
145
|
+
res = res.exclude(member_id: Array(opt[:viewing_account].ignored_members).map(&:id))
|
146
|
+
end
|
147
|
+
|
148
|
+
# reverse ordering is required in order to get the *last* n
|
149
|
+
# comments, rather than the first few when using :limit
|
150
|
+
res = res.reverse_order(:id)
|
151
|
+
res = res.where{ id >= opt[:from_id].to_i } if opt[:from_id]
|
152
|
+
res = res.where{ id < opt[:to_id].to_i } if opt[:to_id]
|
153
|
+
res = res.limit(opt[:limit].to_i) if opt[:limit]
|
154
|
+
res.all.reverse
|
155
|
+
end
|
156
|
+
|
157
|
+
def guid
|
158
|
+
server = self.member.server
|
159
|
+
origin = server ? server.domain : Server.own_domain
|
160
|
+
"xmpp:#{origin}?;node=/comments;item=#{self.public_id}"
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
class ContactList < Sequel::Model(:contact_lists)
|
4
|
+
def account
|
5
|
+
@account ||= Account[self.account_id]
|
6
|
+
end
|
7
|
+
|
8
|
+
def members
|
9
|
+
return @members if @members
|
10
|
+
|
11
|
+
@members = Member.s(
|
12
|
+
%{
|
13
|
+
SELECT
|
14
|
+
m.*
|
15
|
+
FROM
|
16
|
+
contact_lists_members clm
|
17
|
+
, members m
|
18
|
+
WHERE
|
19
|
+
clm.contact_list_id = ?
|
20
|
+
AND m.id = clm.member_id
|
21
|
+
},
|
22
|
+
self.id
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
def member_ids
|
27
|
+
Libertree::DB.dbh[:contact_lists_members].
|
28
|
+
select(:member_id).
|
29
|
+
where(:contact_list_id => self.id).
|
30
|
+
all.
|
31
|
+
flat_map(&:values)
|
32
|
+
end
|
33
|
+
|
34
|
+
def members=(arg)
|
35
|
+
DB.dbh.transaction do
|
36
|
+
DB.dbh[ "DELETE FROM contact_lists_members WHERE contact_list_id = ?", self.id ].get
|
37
|
+
Array(arg).each do |member_id_s|
|
38
|
+
DB.dbh[ "INSERT INTO contact_lists_members ( contact_list_id, member_id ) VALUES ( ?, ? )", self.id, member_id_s.to_i ].get
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def delete_cascade
|
44
|
+
DB.dbh[ "SELECT delete_cascade_contact_list(?)", self.id ].get
|
45
|
+
end
|
46
|
+
|
47
|
+
def <<(member)
|
48
|
+
# refuse to add anything that's not a Member
|
49
|
+
return unless member.is_a? Member
|
50
|
+
|
51
|
+
Libertree::DB.dbh.transaction do
|
52
|
+
unless self.member_ids.include?(member.id)
|
53
|
+
Libertree::DB.dbh[:contact_lists_members].
|
54
|
+
insert(contact_list_id: self.id, member_id: member.id)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# refresh any river containing a reference to this contact list
|
60
|
+
def refresh_rivers
|
61
|
+
rivers = account.rivers.select do |r|
|
62
|
+
vals = r.parsed_query['contact-list'].values.flatten(1)
|
63
|
+
! vals.empty? && vals.map(&:first).include?(self.id)
|
64
|
+
end
|
65
|
+
|
66
|
+
# refresh rivers in background jobs
|
67
|
+
rivers.each do |river|
|
68
|
+
if ! river.appended_to_all
|
69
|
+
Libertree::Model::Job.create(
|
70
|
+
task: 'river:refresh',
|
71
|
+
params: {
|
72
|
+
'river_id' => river.id,
|
73
|
+
}.to_json
|
74
|
+
)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
class Forest < Sequel::Model(:forests)
|
4
|
+
def trees
|
5
|
+
Server.s(
|
6
|
+
%{
|
7
|
+
SELECT
|
8
|
+
s.*
|
9
|
+
FROM
|
10
|
+
forests_servers fs
|
11
|
+
, servers s
|
12
|
+
WHERE
|
13
|
+
fs.forest_id = ?
|
14
|
+
AND s.id = fs.server_id
|
15
|
+
},
|
16
|
+
self.id
|
17
|
+
)
|
18
|
+
end
|
19
|
+
alias :servers :trees
|
20
|
+
|
21
|
+
def add(server)
|
22
|
+
DB.dbh[
|
23
|
+
%{
|
24
|
+
INSERT INTO forests_servers (
|
25
|
+
forest_id, server_id
|
26
|
+
) SELECT
|
27
|
+
?, ?
|
28
|
+
WHERE NOT EXISTS(
|
29
|
+
SELECT 1
|
30
|
+
FROM forests_servers fs2
|
31
|
+
WHERE
|
32
|
+
fs2.forest_id = ?
|
33
|
+
AND fs2.server_id = ?
|
34
|
+
)
|
35
|
+
},
|
36
|
+
self.id,
|
37
|
+
server.id,
|
38
|
+
self.id,
|
39
|
+
server.id
|
40
|
+
].get
|
41
|
+
end
|
42
|
+
|
43
|
+
def remove(server)
|
44
|
+
DB.dbh[ "DELETE FROM forests_servers WHERE forest_id = ? AND server_id = ?", self.id, server.id ].get
|
45
|
+
end
|
46
|
+
|
47
|
+
def local?
|
48
|
+
! origin_server_id
|
49
|
+
end
|
50
|
+
def self.all_local_is_member
|
51
|
+
where local_is_member: true
|
52
|
+
end
|
53
|
+
|
54
|
+
def origin
|
55
|
+
Server[origin_server_id]
|
56
|
+
end
|
57
|
+
|
58
|
+
def local_is_member?
|
59
|
+
local_is_member
|
60
|
+
end
|
61
|
+
|
62
|
+
# @param [Array(String)] domains
|
63
|
+
# @return [Array(Model::Server)] any new Server records that were created
|
64
|
+
def set_trees_by_domain(domains)
|
65
|
+
DB.dbh[ "DELETE FROM forests_servers WHERE forest_id = ?", self.id ].get
|
66
|
+
new_trees = []
|
67
|
+
|
68
|
+
domains.each do |domain|
|
69
|
+
tree = Model::Server[domain: domain]
|
70
|
+
if tree.nil?
|
71
|
+
tree = Model::Server.create(domain: domain)
|
72
|
+
new_trees << tree
|
73
|
+
end
|
74
|
+
|
75
|
+
self.add tree
|
76
|
+
end
|
77
|
+
|
78
|
+
new_trees
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
class Group < Sequel::Model(:groups)
|
4
|
+
def after_create
|
5
|
+
super
|
6
|
+
# Add creator of the group to the group
|
7
|
+
Libertree::Model::GroupMember.create(group_id: self.id, member_id: self.admin_member_id)
|
8
|
+
end
|
9
|
+
|
10
|
+
def add_member(member)
|
11
|
+
Libertree::Model::GroupMember.create(group_id: self.id, member_id: member.id)
|
12
|
+
end
|
13
|
+
|
14
|
+
def remove_member(member)
|
15
|
+
self.group_member(member).delete
|
16
|
+
end
|
17
|
+
|
18
|
+
def member?(member)
|
19
|
+
self.group_member(member).any?
|
20
|
+
end
|
21
|
+
|
22
|
+
def members
|
23
|
+
Libertree::Model::GroupMember.where(group_id: self.id).map { |gm| gm.member }
|
24
|
+
end
|
25
|
+
|
26
|
+
def posts( opts = {} )
|
27
|
+
time = Time.at(
|
28
|
+
opts.fetch(:time, Time.now.to_f)
|
29
|
+
).strftime("%Y-%m-%d %H:%M:%S.%6N%z")
|
30
|
+
|
31
|
+
Post.s(
|
32
|
+
"SELECT * FROM posts_in_group(?,?,?,?,?,?)",
|
33
|
+
self.id,
|
34
|
+
opts.fetch(:viewer_account_id),
|
35
|
+
time,
|
36
|
+
opts[:newer],
|
37
|
+
opts[:order_by] == :comment,
|
38
|
+
opts.fetch(:limit, 30)
|
39
|
+
)
|
40
|
+
end
|
41
|
+
|
42
|
+
def group_member(member)
|
43
|
+
Libertree::Model::GroupMember.where(group_id: self.id, member_id: member.id)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'libertree/render'
|
2
|
+
|
3
|
+
module Libertree
|
4
|
+
module Model
|
5
|
+
# Provides a "glimpse" instance method to objects that have a "text" field.
|
6
|
+
module HasDisplayText
|
7
|
+
def glimpse( length = 60 )
|
8
|
+
set_without_blockquotes = text_to_nodeset
|
9
|
+
set_without_blockquotes.xpath('.//blockquote').each(&:remove)
|
10
|
+
plain_text = set_without_blockquotes.inner_text.strip
|
11
|
+
|
12
|
+
if plain_text.empty?
|
13
|
+
plain_text = text_to_nodeset.inner_text.strip
|
14
|
+
end
|
15
|
+
|
16
|
+
plain_text = plain_text.gsub("\n", ' ')
|
17
|
+
snippet = plain_text[0...length]
|
18
|
+
if plain_text.length > length
|
19
|
+
snippet += '...'
|
20
|
+
end
|
21
|
+
|
22
|
+
snippet
|
23
|
+
end
|
24
|
+
|
25
|
+
def text_as_html
|
26
|
+
Render.to_html_nodeset(self.text)
|
27
|
+
end
|
28
|
+
|
29
|
+
def text_to_nodeset
|
30
|
+
Render.to_html_nodeset(self.text, [:no_images, :filter_html])
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
# Provides a "search" class method to a class that has a "text" field
|
4
|
+
# and a time_created field.
|
5
|
+
module HasSearchableText
|
6
|
+
def search(q, limit = 42, exact=true)
|
7
|
+
if exact
|
8
|
+
dict = 'simple'
|
9
|
+
else
|
10
|
+
dict = 'english'
|
11
|
+
end
|
12
|
+
self.where("(to_tsvector('simple', text) || to_tsvector('english', text)) @@ plainto_tsquery('#{dict}', ?)", q).
|
13
|
+
reverse_order(:time_created).
|
14
|
+
limit(limit.to_i).
|
15
|
+
all
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Libertree
|
2
|
+
module Model
|
3
|
+
module IsRemoteOrLocal
|
4
|
+
def public_id
|
5
|
+
self.remote_id || self.id
|
6
|
+
end
|
7
|
+
|
8
|
+
def remote?
|
9
|
+
!! remote_id
|
10
|
+
end
|
11
|
+
|
12
|
+
def local?
|
13
|
+
! remote_id
|
14
|
+
end
|
15
|
+
|
16
|
+
def server
|
17
|
+
@server ||= self.member.server
|
18
|
+
end
|
19
|
+
|
20
|
+
def forests
|
21
|
+
if self.remote?
|
22
|
+
self.server.forests
|
23
|
+
else
|
24
|
+
Libertree::Model::Forest.all_local_is_member
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
@@ -0,0 +1,93 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
module Libertree
|
5
|
+
module Model
|
6
|
+
class Job < Sequel::Model(:jobs)
|
7
|
+
MAX_TRIES = 48
|
8
|
+
RETRY_FACTOR = 0.2
|
9
|
+
|
10
|
+
def params
|
11
|
+
if val = super
|
12
|
+
JSON.parse val
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def retry!
|
17
|
+
self.pid = self.time_started = self.time_finished = nil
|
18
|
+
self.time_to_start = Time.now
|
19
|
+
self.tries = 0
|
20
|
+
self.save
|
21
|
+
end
|
22
|
+
|
23
|
+
# First parameter can be a Forest Array.
|
24
|
+
# Otherwise, assumed to create for all member forests.
|
25
|
+
def self.create_for_forests(create_args, *forests)
|
26
|
+
if forests.empty?
|
27
|
+
forests = Forest.all_local_is_member
|
28
|
+
end
|
29
|
+
|
30
|
+
trees = Set.new
|
31
|
+
forests.each do |f|
|
32
|
+
if f.local_is_member?
|
33
|
+
trees += f.trees
|
34
|
+
end
|
35
|
+
end
|
36
|
+
trees.each do |tree|
|
37
|
+
params = ( create_args[:params] || create_args['params'] || Hash.new )
|
38
|
+
params['server_id'] = tree.id
|
39
|
+
Libertree::Model::Job.create(
|
40
|
+
task: create_args[:task],
|
41
|
+
params: params.to_json
|
42
|
+
)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [Job] nil if no job was reserved
|
47
|
+
def self.reserve(tasks)
|
48
|
+
job = self.where("task IN ? AND pid IS NULL AND tries < #{MAX_TRIES} AND time_to_start <= NOW()", tasks).order(:time_to_start).limit(1).first
|
49
|
+
return nil if job.nil?
|
50
|
+
|
51
|
+
self.where({ id: job.id, pid: nil }).
|
52
|
+
update({ pid: Process.pid, time_started: Time.now })
|
53
|
+
|
54
|
+
job = Job[job.id]
|
55
|
+
if job.pid == Process.pid
|
56
|
+
job
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def unreserve
|
61
|
+
new_tries = self.tries+1
|
62
|
+
self.update(
|
63
|
+
time_started: nil,
|
64
|
+
pid: nil,
|
65
|
+
tries: new_tries,
|
66
|
+
time_to_start: Time.now + 60 * Math::E**(new_tries * RETRY_FACTOR)
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.pending_where(*args)
|
71
|
+
query = args[0]
|
72
|
+
params = args[1..-1]
|
73
|
+
|
74
|
+
self.where(
|
75
|
+
query + %{
|
76
|
+
AND time_finished IS NULL
|
77
|
+
AND tries < ?
|
78
|
+
},
|
79
|
+
*params,
|
80
|
+
MAX_TRIES
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
def self.unfinished(task=nil)
|
85
|
+
if task
|
86
|
+
self.where("task = ? AND time_finished IS NULL", task).all
|
87
|
+
else
|
88
|
+
self.where("time_finished IS NULL").all
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|